diff --git a/.gitignore b/.gitignore
index a4c735062..64ab7b487 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,6 +48,7 @@ coverage
# Deployment configuration file
config.yaml
config-custom.yaml
+config-generated.yaml
/.coverage
# Test Artifacts
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9b1982fe6..d44717974 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -107,7 +107,7 @@ repos:
- --config-file=pyproject.toml
- --install-types
- --non-interactive
- exclude: ^test
+ exclude: ^test/
- repo: https://github.com/pre-commit/mirrors-eslint
rev: 'v9.39.1'
@@ -125,11 +125,11 @@ repos:
# hooks:
# - id: python-safety-dependencies-check
-# - repo: https://github.com/asottile/pyupgrade
-# rev: v3.19.0
-# hooks:
-# - id: pyupgrade
-# args: [--py313-plus]
+- repo: https://github.com/asottile/pyupgrade
+ rev: v3.19.0
+ hooks:
+ - id: pyupgrade
+ args: [--py313-plus]
# - repo: https://github.com/bridgecrewio/checkov
# rev: '3.2.327'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebf7e66ce..c18503bd2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,61 @@
+# v6.2.0
+
+## Key Features
+
+### Custom Branding
+LISA supports custom branding capabilities, allowing customers to tailor the user interface with specific logos and color schemes. The feature provides three key customization areas:
+
+1. **Visual Assets** - Replace logos (favicon, top navigation logo, login image) in `lib/user-interface/react/public/branding/custom/`
+2. **Display Name** - Change "LISA" brand name to your organization's product name via `customDisplayName` in `config-custom.yaml`
+3. **Theme Customization** - Modify colors, fonts, and visual styling through the [Cloudscape theming system](https://cloudscape.design/foundation/visual-foundation/theming/)
+
+### Video Generation Models
+LISA supports video generation.
+
+- Adding new VideoGen model type that admins can create in the model management
+- Proxying routes to LiteLLM for video generation
+- Saving generated videos in S3 and allowing users to download and share video links
+- Exporting all videos from a selected session as a zip
+
+### 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
+> tsx scripts/generate-config.ts
+
+╔════════════════════════════════════════════════════════════════╗
+║ LISA Configuration Generator ║
+║ Generate a config-custom.yaml for LISA deployment ║
+╚════════════════════════════════════════════════════════════════╝
+
+The generator prompts for and validates:
+- Core Configuration: AWS account, region, partition, deployment stage/name, S3 bucket
+- Partition Support: additional partition configurations
+
+### Reasoning Model Support
+Admins can mark models as "reasoning-capable," which enables those models to output reasoning content alongside their responses. Customers can then configure the level of reasoning effort to be included, and the reasoning content is displayed in LISA Chat and exported with the session data.
+
+## Other Key Changes
+- **Image Editing Support**: Users can now upload reference images during image generation, allowing the model to create precise edits and updates based on the provided visual reference
+- **Security**: Enabled encryption at rest for all S3 buckets, enforced SSL/TLS traffic only to S3, and encrypted all EBS volumes
+- **Deployment**: Updated container source to use AWS ECR, upgraded host EC2 image to use AL2023
+- **Usability**: Added loading/refresh icons across pages to better inform users when a refresh is taking place
+- **Database**: Enabled IAM-based authentication for RDS connections, removed the need for storing master passwords
+- **Accessibility**: Added a retry button next to failed user prompts, made the delete session dropdown button only appear if the config allows it
+- **Bugfixes**: Addressed several IAM permission issues, CDK warnings, and model deployment healthiness checks
+- **UI Quality of Life**: Continued to make subtle UI improvements by:
+ - Adding various highlighting and background updates throughout the UI to make sections and errors stand out and more obvious
+ - Adding loading indicators to various MCP toggles / checkboxes to display that calls are happening in the background
+ - Updated various iconography, logos, and buttons to have a more uniform and clean feel
+
+## Acknowledgements
+* @bedanley
+* @Ernest-Gray
+* @estohlmann
+* @jmharold
+
+**Full Changelog**: https://github.com/awslabs/LISA/compare/v6.1.1..v6.2.0
+
# v6.1.1
## UI Cleanup
diff --git a/Makefile b/Makefile
index ea53341d1..e3ba78f73 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,9 @@
createTypeScriptEnvironment installTypeScriptRequirements \
deploy destroy \
clean cleanTypeScript cleanPython cleanCfn cleanMisc \
- help dockerCheck dockerLogin listStacks modelCheck buildNpmModules
+ help dockerCheck dockerLogin listStacks modelCheck buildNpmModules \
+ test test-coverage test-lambda test-mcp-workbench test-sdk test-rest-api test-sdk-integ test-integ test-rag-integ test-metadata-integ \
+ lock-poetry validate-deps
#################################################################################
# GLOBALS #
@@ -138,6 +140,7 @@ installPythonRequirements:
CC=/usr/bin/gcc10-gcc CXX=/usr/bin/gcc10-g++ pip3 install pip --upgrade
CC=/usr/bin/gcc10-gcc CXX=/usr/bin/gcc10-g++ pip3 install --prefer-binary -r requirements-dev.txt
CC=/usr/bin/gcc10-gcc CXX=/usr/bin/gcc10-g++ pip3 install -e lisa-sdk
+ CC=/usr/bin/gcc10-gcc CXX=/usr/bin/gcc10-g++ pip3 install -e lib/serve/mcp-workbench
## Set up TypeScript interpreter environment
createTypeScriptEnvironment:
@@ -366,14 +369,104 @@ help:
}' \
| more $(shell test $(shell uname) = Darwin && echo '--no-init --raw-control-chars')
-## Run Python tests with coverage report
+## Run all Python unit tests (non-integration) with coverage report
test-coverage:
- pytest --verbose \
+ @echo "Running lambda tests with coverage..."
+ @pytest test/lambda --verbose \
--cov lambda \
--cov-report term-missing \
--cov-report html:build/coverage \
--cov-report xml:build/coverage/coverage.xml \
--cov-fail-under 83
+ @echo ""
+ @echo "Running MCP Workbench tests with coverage..."
+ @pytest test/mcp-workbench --verbose \
+ --cov lib/serve/mcp-workbench/src \
+ --cov-report term-missing \
+ --cov-report html:build/coverage-mcp \
+ --cov-report xml:build/coverage-mcp/coverage.xml \
+ --cov-append \
+ --cov-fail-under 83
+ @echo ""
+ @echo "Running SDK tests with coverage..."
+ @pytest test/sdk --verbose \
+ --cov lisa-sdk/lisapy \
+ --cov-report term-missing \
+ --cov-report html:build/coverage-sdk \
+ --cov-report xml:build/coverage-sdk/coverage.xml \
+ --cov-append \
+ --cov-fail-under 80
+ @echo ""
+ @echo "Running REST API tests with coverage..."
+ @pytest test/rest-api --verbose \
+ --cov lib/serve/rest-api/src \
+ --cov-config lib/serve/rest-api/.coveragerc \
+ --cov-report term-missing \
+ --cov-report html:build/coverage-rest-api \
+ --cov-report xml:build/coverage-rest-api/coverage.xml \
+ --cov-append \
+ --cov-fail-under 80
+
+
+## Run all Python unit tests (non-integration) without coverage
+test:
+ @echo "Running lambda tests..."
+ @pytest test/lambda --verbose
+ @echo ""
+ @echo "Running MCP Workbench tests..."
+ @pytest test/mcp-workbench --verbose
+ @echo ""
+ @echo "Running SDK tests..."
+ @pytest test/sdk --verbose
+ @echo ""
+ @echo "Running REST API tests..."
+ @pytest test/rest-api --verbose
+
+## Run lambda tests only
+test-lambda:
+ pytest test/lambda --verbose
+
+## Run MCP Workbench tests only
+test-mcp-workbench:
+ pytest test/mcp-workbench --verbose
+
+## Run LISA SDK unit tests only
+test-sdk:
+ pytest test/sdk --verbose
+
+## Run REST API unit tests only
+test-rest-api:
+ pytest test/rest-api --verbose
+
+## Run LISA SDK integration tests (requires deployed LISA environment)
+test-sdk-integ:
+ @echo "Running LISA SDK integration tests..."
+ @echo "Note: These tests require a deployed LISA environment with:"
+ @echo " - --api or --url argument for API endpoint"
+ @echo " - --region, --deployment, --profile arguments"
+ @echo " - AWS credentials configured"
+ @echo ""
+ @echo "Example: pytest test/integration/sdk --api https://your-api.com --region us-west-2"
+ @echo ""
+ pytest test/integration/sdk --verbose
+
+## Run integration tests (Python-based)
+test-integ:
+ pytest test/python --verbose
+
+## Run RAG integration tests (requires deployed LISA environment)
+test-rag-integ:
+ @echo "Running RAG integration tests..."
+ @echo "Note: These tests require a deployed LISA environment with:"
+ @echo " - LISA_API_URL environment variable set"
+ @echo " - LISA_DEPLOYMENT_NAME environment variable set"
+ @echo " - AWS credentials configured"
+ @echo ""
+ pytest test/integration --verbose
+
+## Run repository metadata preservation integration tests
+test-metadata-integ:
+ pytest test/integration/test_repository_update_metadata_preservation.py --verbose
## Regenerate all Poetry lock files
lock-poetry:
diff --git a/VERSION b/VERSION
index f3b5af39e..6abaeb2f9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-6.1.1
+6.2.0
diff --git a/assets/LisaArchitecture.png b/assets/LisaArchitecture.png
deleted file mode 100644
index 884e275bf..000000000
Binary files a/assets/LisaArchitecture.png and /dev/null differ
diff --git a/assets/LisaChat.png b/assets/LisaChat.png
deleted file mode 100644
index f9d958e0a..000000000
Binary files a/assets/LisaChat.png and /dev/null differ
diff --git a/assets/LisaModelManagement.png b/assets/LisaModelManagement.png
deleted file mode 100644
index 61d47c444..000000000
Binary files a/assets/LisaModelManagement.png and /dev/null differ
diff --git a/assets/LisaServe.png b/assets/LisaServe.png
deleted file mode 100644
index 22365e94f..000000000
Binary files a/assets/LisaServe.png and /dev/null differ
diff --git a/bin/build-images b/bin/build-images
index afece822c..e4f249cb2 100755
--- a/bin/build-images
+++ b/bin/build-images
@@ -4,8 +4,11 @@ set -e
ROOT=$(pwd)
OUTPUT_DIR=$ROOT/dist/images
-# DOCKER_CMD=${DOCKER_CMD:-$(command -v finch >/dev/null 2>&1 && echo "finch" || echo "docker")}
-DOCKER_CMD=docker
+
+# Container runtime: Use CDK_DOCKER env var (same as CDK), default to docker
+DOCKER_CMD="${CDK_DOCKER:-docker}"
+
+PLATFORM="linux/amd64"
# Parse command line arguments
UPLOAD=false
@@ -44,9 +47,10 @@ build_image() {
echo "Building image: $repository_name:$image_tag"
echo "Context: $build_context_path"
+ echo "Platform: $PLATFORM"
# Construct docker build command
- local docker_cmd="$DOCKER_CMD build"
+ local docker_cmd="$DOCKER_CMD build --platform $PLATFORM"
# Add build args
for arg in "${build_args[@]}"; do
@@ -116,7 +120,7 @@ build_all_images() {
build_image "Dockerfile" "lisa-rest-api" "$LISA_VERSION" "./lib/serve/rest-api" \
"NODE_ENV=production" \
"LITELLM_CONFIG=\"db_key: sk-a8814208-0388-480c-9fc7-fea59607ca38\"" \
- "BASE_IMAGE=python:3.13-slim"
+ "BASE_IMAGE=public.ecr.aws/docker/library/python:3.13-slim"
# lisa-batch-ingestion
RAG_DIR="./lib/rag/ingestion/ingestion-image"
@@ -130,7 +134,7 @@ build_all_images() {
MCP_DIR="./lib/serve/mcp-workbench"
build_image "Dockerfile" "lisa-mcp-workbench" "$LISA_VERSION" "$MCP_DIR" \
"NODE_ENV=production" \
- "BASE_IMAGE=python:3.13-slim"
+ "BASE_IMAGE=public.ecr.aws/docker/library/python:3.13-slim"
else
echo "deployMcpWorkbench is disabled, skipping lisa-mcp-workbench build"
echo ""
@@ -151,7 +155,7 @@ build_all_images() {
# lisa-vllm
build_image "Dockerfile" "lisa-vllm" "latest" "./lib/serve/ecs-model/vllm" \
"NODE_ENV=production" \
- "BASE_IMAGE=vllm/vllm-openai:latest" \
+ "BASE_IMAGE=public.ecr.aws/deep-learning-containers/vllm:0.13-gpu-py312" \
"MOUNTS3_DEB_URL=https://s3.amazonaws.com/mountpoint-s3-release/latest/x86_64/mount-s3.deb"
echo "All images built successfully!"
diff --git a/bin/build-lambdas b/bin/build-lambdas
index 30b982424..eb2073c73 100755
--- a/bin/build-lambdas
+++ b/bin/build-lambdas
@@ -1,77 +1,216 @@
#!/bin/bash
set -e
-ROOT=$(pwd)
-OUTPUT_DIR=$ROOT/dist/layers
-mkdir -p $OUTPUT_DIR
-
-PYPI_URL=${PYPI_URL:-https://pypi.org/simple/}
-source .venv/bin/activate
-
-build_python_layer() {
- local package_name=$1
- local source_path=$2
- local pre_build_cmd=$3
- echo "Building Python Lambda Layer $package_name from $source_path..."
-
- if [ -n "$pre_build_cmd" ]; then
- eval "$pre_build_cmd"
- fi
-
- cd $source_path
- $ROOT/bin/package-lambda-layer --src . --output "$package_name.zip" --pypi $PYPI_URL --layer
- mv ./build/"$package_name.zip" $OUTPUT_DIR/
- rm -rf ./build
- cd $ROOT
-}
+SRC=src
+OUTPUT=Lambda.zip
+EXCLUDE_PACKAGES=""
+BUILD_DIR=$PWD/build
+IS_LAYER=0
+TMP_DIR=$BUILD_DIR/tmp/
+PYPI_URL=
+USE_DOCKER=0
+PLATFORM="linux/amd64"
+PYTHON_VERSION="3.13"
-build_python_lambda() {
- local package_name=$1
- local source_path=$2
- echo "Building Python Lambda $package_name from $source_path..."
- cd "$source_path"
- $ROOT/bin/package-lambda-layer --src . --output "$package_name.zip" --pypi $PYPI_URL
- mv ./build/"$package_name.zip" $OUTPUT_DIR/
- rm -rf ./build
- cd $ROOT
-}
+# 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
+ ;;
+ --docker)
+ USE_DOCKER=1
+ ;;
+ --python-version)
+ PYTHON_VERSION="$value"
+ ;;
+ *)
+ echo "Unknown parameter: $param"
+ echo "Usage: $0 --src --output --exclude --layer --docker --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
+ ;;
+ --docker)
+ USE_DOCKER=1
+ ;;
+ --python-version)
+ shift
+ PYTHON_VERSION="$1"
+ ;;
+ *)
+ echo "Unknown parameter: $1"
+ echo "Usage: $0 --src --output --exclude --docker --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
-build_node_layer() {
- local package_name=$1
- local source_path=$2
- echo "Building Node.js Lambda Layer $package_name from $source_path..."
+# Extract IP from PYPI_URL for trusted host
+TRUSTED_HOST=$(echo $PYPI_URL | sed 's|http://||' | sed 's|/.*||')
- cd "$source_path"
+# Print parameters for debugging
+echo "Source directory: $SRC"
+echo "Output file: $OUTPUT"
+echo "Build directory: $BUILD_DIR"
+echo "Temp directory: $TMP_DIR"
+echo "Platform: $PLATFORM"
+echo "Use Docker: $USE_DOCKER"
+echo "Python version: $PYTHON_VERSION"
- # Clean previous build
- rm -rf build node_modules
- # Create layer structure: nodejs/node_modules
- mkdir -p build/nodejs
+install_requirements() {
+ echo "Installing requirements"
+ rm -rf "$TMP_DIR"
+ mkdir -p "$TMP_DIR"
+ if [ -f "$SRC/requirements.txt" ]; then
+ echo "Installing requirements from $SRC/requirements.txt"
- # Copy package.json and install production dependencies
- cp package.json build/nodejs/
- cd build/nodejs
- npm install --omit=dev --production
- cd ../..
+ if [ $USE_DOCKER -eq 1 ]; then
+ # Use Docker to install dependencies for correct platform (linux/amd64)
+ # This handles packages that need compilation from source
+ echo "Using Docker with platform $PLATFORM for cross-platform compatibility"
+ $DOCKER_CMD run --rm --platform $PLATFORM \
+ -v "$PWD:/workspace" \
+ -v "$TMP_DIR:/output" \
+ -w /workspace \
+ public.ecr.aws/docker/library/python:${PYTHON_VERSION}-slim \
+ pip install -r "$SRC/requirements.txt" --force-reinstall --no-cache-dir --target /output --index-url $PYPI_URL --trusted-host $TRUSTED_HOST
+ else
+ # Try pip with --platform flag first (faster, no Docker needed)
+ # This only works for packages with pre-built wheels
+ echo "Attempting cross-platform install with pip --platform flag"
+ echo "Target: manylinux2014_x86_64, Python $PYTHON_VERSION"
- # Create zip
- cd build
- zip -r "$package_name.zip" nodejs
- mv "$package_name.zip" $OUTPUT_DIR/
- cd ..
- rm -rf build
- cd $ROOT
+ # Extract major.minor version for ABI tag (e.g., 3.13 -> cp313)
+ PYTHON_ABI="cp${PYTHON_VERSION//./}"
+
+ if python3 -m pip install -r "$SRC/requirements.txt" \
+ --platform manylinux2014_x86_64 \
+ --implementation cp \
+ --python-version "$PYTHON_VERSION" \
+ --abi "$PYTHON_ABI" \
+ --only-binary=:all: \
+ --force-reinstall \
+ --no-cache-dir \
+ --target "$TMP_DIR" \
+ --index-url "$PYPI_URL" \
+ --trusted-host "$TRUSTED_HOST" 2>/dev/null; then
+ echo "Successfully installed all packages using pip --platform"
+ else
+ echo "Some packages need compilation, falling back to Docker..."
+ rm -rf "$TMP_DIR"
+ mkdir -p "$TMP_DIR"
+
+ if [ -n "$DOCKER_CMD" ]; then
+ $DOCKER_CMD run --rm --platform $PLATFORM \
+ -v "$PWD:/workspace" \
+ -v "$TMP_DIR:/output" \
+ -w /workspace \
+ public.ecr.aws/docker/library/python:${PYTHON_VERSION}-slim \
+ pip install -r "$SRC/requirements.txt" --force-reinstall --no-cache-dir --target /output --index-url $PYPI_URL --trusted-host $TRUSTED_HOST
+ else
+ echo "ERROR: No container runtime available and pip --platform failed (some packages need compilation)"
+ echo "Install Docker, Finch, or set CDK_DOCKER environment variable"
+ echo "Alternatively, ensure all packages have pre-built wheels for manylinux2014_x86_64"
+ exit 1
+ fi
+ fi
+ fi
+ 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' "$SRC/" "$TMP_DIR/"
+ fi
}
-echo "Building Python Lambda Layers..."
-build_python_layer "AimlAdcLisaCommonLayer" "./lib/core/layers/common"
-build_python_layer "AimlAdcLisaAuthLayer" "./lib/core/layers/authorizer"
-build_python_layer "AimlAdcLisaFastApiLayer" "./lib/core/layers/fastapi"
-build_python_layer "AimlAdcLisaRag" "./lib/rag/layer" "python3 scripts/cache-tiktoken-for-offline.py ./lib/rag/layer/TIKTOKEN_CACHE"
+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
-echo "Building Node.js Lambda Layers..."
-build_node_layer "AimlAdcLisaCdkLayer" "./lib/core/layers/cdk"
+ # 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"
+}
-echo "Building Python Lambdas..."
-build_python_lambda "AimlAdcLisaLambda" "./lambda"
+install_requirements
+build_package
+package_artifacts
diff --git a/bin/package-lambda-layer b/bin/package-lambda-layer
index 16cc05c1a..66792f973 100755
--- a/bin/package-lambda-layer
+++ b/bin/package-lambda-layer
@@ -8,6 +8,11 @@ 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
@@ -35,9 +40,12 @@ while [ $# -gt 0 ]; do
--layer)
IS_LAYER=1
;;
+ --python-version)
+ PYTHON_VERSION="$value"
+ ;;
*)
echo "Unknown parameter: $param"
- echo "Usage: $0 --src --output --exclude --layer"
+ echo "Usage: $0 --src --output --exclude --layer --python-version "
exit 1
;;
esac
@@ -68,9 +76,13 @@ while [ $# -gt 0 ]; do
--layer)
IS_LAYER=1
;;
+ --python-version)
+ shift
+ PYTHON_VERSION="$1"
+ ;;
*)
echo "Unknown parameter: $1"
- echo "Usage: $0 --src --output --exclude "
+ echo "Usage: $0 --src --output --exclude --layer --python-version "
exit 1
;;
esac
@@ -96,26 +108,59 @@ 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"
- rm -rf "$TMP_DIR"
+ 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 python version $(python3 --version)"
- python3 -m pip install -r "$SRC/requirements.txt" --force-reinstall --no-cache-dir --target "$TMP_DIR" --index-url $PYPI_URL --trusted-host $TRUSTED_HOST
+ 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' "$SRC/" "$TMP_DIR/"
+ rsync -av --exclude='build' --exclude='.hatch' --exclude='.venv' --exclude='requirements.txt' "$SRC/" "$TMP_DIR/"
fi
}
diff --git a/cdk.json b/cdk.json
index 1153c4b38..2bd48137f 100644
--- a/cdk.json
+++ b/cdk.json
@@ -2,7 +2,9 @@
"app": "npm run deploy",
"requireApproval": "never",
"watch": {
- "include": ["**"],
+ "include": [
+ "**"
+ ],
"exclude": [
"README.md",
"cdk*.json",
@@ -18,7 +20,15 @@
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
- "@aws-cdk/core:target-partitions": ["aws", "aws-cn"],
+ "@aws-cdk/core:target-partitions": [
+ "aws",
+ "aws-cn",
+ "aws-us-gov",
+ "aws-iso",
+ "aws-iso-b",
+ "aws-iso-e",
+ "aws-iso-f"
+ ],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
diff --git a/cypress/src/e2e/support/commands.ts b/cypress/src/e2e/support/commands.ts
index cda04ef38..20201a9d5 100644
--- a/cypress/src/e2e/support/commands.ts
+++ b/cypress/src/e2e/support/commands.ts
@@ -139,7 +139,7 @@ Cypress.Commands.add('loginAs', (role = 'user') => {
});
// Wait for redirect back to app and allow configuration to load
- cy.wait(20000); // Extended wait for configuration and initial API calls, models, repositories, etc.
+ cy.wait(2000);
});
});
},
@@ -155,16 +155,57 @@ Cypress.Commands.add('loginAs', (role = 'user') => {
expect(hasOidcToken).to.equal(true);
});
},
- cacheAcrossSpecs: true,
+ cacheAcrossSpecs: false,
}
);
- // After session restore/setup, Cypress clears the page
- // We must visit again and wait for APIs
+ // After session restore/setup, Cypress clears the page which may have cancelled
+ // in-flight API requests. Selectively clear API cache reducers to ensure cancelled
+ // requests don't pollute the cache, while preserving user preferences.
+ cy.window().then((win) => {
+ const persistedState = win.localStorage.getItem('persist:lisa');
+ if (persistedState) {
+ try {
+ const state = JSON.parse(persistedState);
+ // Clear only API cache reducers that may have stale/cancelled data
+ // Preserve: user, userPreferences, notification, modal, breadcrumbGroup
+ const apiReducersToReset = [
+ 'models', // modelManagementApi.reducerPath
+ 'configuration', // configurationApi.reducerPath
+ 'sessions', // sessionApi.reducerPath
+ 'rag', // ragApi.reducerPath
+ 'promptTemplates', // promptTemplateApi.reducerPath
+ 'mcpServers', // mcpServerApi.reducerPath
+ 'mcpTools', // mcpToolsApi.reducerPath
+ 'apiTokens', // apiTokenApi.reducerPath
+ 'userPreferences', // userPreferencesApi.reducerPath
+ ];
+ apiReducersToReset.forEach((key) => {
+ if (state[key]) {
+ delete state[key];
+ }
+ });
+ win.localStorage.setItem('persist:lisa', JSON.stringify(state));
+ } catch {
+ // If parsing fails, remove the entire persisted state
+ win.localStorage.removeItem('persist:lisa');
+ }
+ }
+ });
+
+ // Set up intercepts BEFORE visiting so they catch all requests
+ // cy.session() clears all intercepts, so we must set them up fresh here
setupApiIntercepts();
+
+ // Visit the app - intercepts are now ready to catch requests
cy.visit(BASE_URL);
+
+ // Wait for app to be ready using DOM-based assertions
waitForAppReady();
- waitForCriticalApis();
+
+ // Now wait for the critical configuration API to complete
+ // This ensures the app has loaded its configuration before tests proceed
+ // waitForCriticalApis();
log.snapshot('after');
log.end();
diff --git a/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts b/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts
index f752106e4..5a5176309 100644
--- a/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts
+++ b/cypress/src/shared/specs/bedrock-model-workflow.shared.spec.ts
@@ -22,7 +22,7 @@
*/
import { navigateToAdminPage } from '../../support/adminHelpers';
-import { navigateAndVerifyChatPage } from '../../support/chatHelpers';
+import { insertChatPrompt, navigateAndVerifyChatPage, sendMessageWithButton, verifyChatResponseReceived } from '../../support/chatHelpers';
import {
BedrockModelConfig,
openCreateModelWizard,
@@ -57,7 +57,8 @@ import {
verifyPromptTemplateInList,
deletePromptTemplateIfExists,
selectPromptTemplateInChat,
- selectDirectiveAndSend,
+ promptTemplateExists,
+ PromptTemplateType,
} from '../../support/promptTemplateHelpers';
import {
CollectionConfig,
@@ -69,7 +70,6 @@ import {
waitForDocumentIngested,
selectRagRepositoryInChat,
selectCollectionInChat,
- sendMessageAndVerifyRagResponse,
} from '../../support/collectionHelpers';
@@ -102,7 +102,6 @@ export type BedrockWorkflowTestOptions = {
export function runBedrockModelWorkflowTests (options: BedrockWorkflowTestOptions = {}) {
const dateString = getTodayDateString();
- const skipCleanup = options.skipCleanup ?? false;
const testModel = options.modelConfig || DEFAULT_TEST_MODEL;
const testRepository: RepositoryConfig = options.repositoryConfig || {
repositoryId: `e2e-repo-${dateString}`,
@@ -158,13 +157,13 @@ My sources say no
Outlook not so good
Very doubtful
Respond with only one phrase per message, chosen randomly. Treat every input as a question seeking guidance from the universe.`,
- type: 'system',
+ type: PromptTemplateType.Persona,
sharePublic: true,
};
const testPromptTemplateDirective: PromptTemplateConfig = {
title: `E2E Test Directive ${dateString}`,
body: 'Is it going to rain',
- type: 'user',
+ type: PromptTemplateType.Directive,
sharePublic: true,
};
@@ -177,8 +176,8 @@ Respond with only one phrase per message, chosen randomly. Treat every input as
// Wait for models API to load and check if model already exists
cy.wait('@getModels', { timeout: 30000 }).then((interception) => {
- const models = interception.response?.body || {models:[]};
- const modelExists = models.models.some((model: any) => model.modelId === testModel.modelId);
+ const models = (interception.response?.body as { models?: any[] })?.models ?? [];
+ const modelExists = models.some((model: any) => model.modelId === testModel.modelId);
if (modelExists) {
cy.log(`Model ${testModel.modelId} already exists, skipping creation`);
@@ -279,6 +278,64 @@ Respond with only one phrase per message, chosen randomly. Treat every input as
testState.documentUploaded = true;
});
+ it('Wait for document to be ingested', function () {
+ if (!testState.documentUploaded) {
+ this.skip();
+ }
+ waitForDocumentIngested(testRepository.repositoryId, testState.collectionId, testDocumentPath, 300000);
+ testState.documentIngested = true;
+ });
+
+ it('Admin creates a persona prompt template', () => {
+ navigateToPromptTemplates();
+
+ promptTemplateExists(testPromptTemplatePersona.title).then((exists) => {
+ if (exists) {
+ cy.log(`Prompt template ${testPromptTemplatePersona.title} already exists, skipping creation`);
+ return;
+ }
+
+ openCreatePromptTemplateWizard();
+ fillPromptTemplateConfig(testPromptTemplatePersona);
+ completePromptTemplateWizard();
+ waitForPromptTemplateCreationSuccess(testPromptTemplatePersona.title);
+ });
+ });
+
+ it('Rename auto-created collection to known name', function () {
+ if (!testState.repositoryReady) {
+ this.skip();
+ }
+
+ navigateToRagManagement();
+
+ // Get the auto-created collection info (name and ID) and rename it
+ getAutoCreatedCollectionInfo(testRepository.repositoryId).then((collectionInfo) => {
+ cy.log(`Auto-created collection: ${collectionInfo.name} (ID: ${collectionInfo.id})`);
+ testState.collectionId = collectionInfo.id; // Store the collection ID
+ renameCollection(collectionInfo.name, testCollection.collectionName);
+ testState.collectionRenamed = true;
+ });
+ });
+
+ it('Upload test document to collection via chat page', function () {
+ if (!testState.collectionRenamed) {
+ this.skip();
+ }
+
+ // Navigate to chat page
+ navigateAndVerifyChatPage();
+
+ // Select model, repository, and collection
+ selectModelInChat(testModel.modelId);
+ selectRagRepositoryInChat(testRepository.repositoryId);
+ selectCollectionInChat(testCollection.collectionName);
+
+ // Upload the document
+ uploadDocument(testDocumentPath);
+ testState.documentUploaded = true;
+ });
+
it('Wait for document to be ingested', function () {
if (!testState.documentUploaded) {
this.skip();
@@ -321,21 +378,16 @@ Respond with only one phrase per message, chosen randomly. Treat every input as
it('Admin creates a directive prompt template (or uses existing)', () => {
navigateToPromptTemplates();
- // Wait for prompt templates API to load and check if template already exists
- cy.wait('@getPromptTemplates', { timeout: 30000 }).then((interception) => {
- const templates = interception.response?.body || [];
- const templateExists = templates.some((template: any) => template.title === testPromptTemplateDirective.title);
-
- if (templateExists) {
- cy.log(`Prompt template "${testPromptTemplateDirective.title}" already exists, skipping creation`);
- testState.directiveTemplateCreated = true;
- } else {
- openCreatePromptTemplateWizard();
- fillPromptTemplateConfig(testPromptTemplateDirective);
- completePromptTemplateWizard();
- waitForPromptTemplateCreationSuccess(testPromptTemplateDirective.title);
- testState.directiveTemplateCreated = true;
+ promptTemplateExists(testPromptTemplateDirective.title).then((exists) => {
+ if (exists) {
+ cy.log(`Prompt template ${testPromptTemplateDirective.title} already exists, skipping creation`);
+ return;
}
+
+ openCreatePromptTemplateWizard();
+ fillPromptTemplateConfig(testPromptTemplateDirective);
+ completePromptTemplateWizard();
+ waitForPromptTemplateCreationSuccess(testPromptTemplateDirective.title);
});
});
@@ -348,87 +400,55 @@ Respond with only one phrase per message, chosen randomly. Treat every input as
verifyPromptTemplateInList(testPromptTemplateDirective.title);
});
- it('User selects model, applies persona, inserts directive, and sends message', function () {
- if (!testState.modelCreated || !testState.personaTemplateCreated || !testState.directiveTemplateCreated) {
- this.skip();
- }
-
+ it('Send chat message with persona and directive', () => {
navigateAndVerifyChatPage();
selectModelInChat(testModel.modelId);
// Apply the Magic 8 Ball persona (system prompt)
- selectPromptTemplateInChat(testPromptTemplatePersona.title, 'system');
- // Insert directive template and send message
- selectDirectiveAndSend(testPromptTemplateDirective.title);
+ selectPromptTemplateInChat(testPromptTemplatePersona.title, PromptTemplateType.Persona);
+ selectPromptTemplateInChat(testPromptTemplateDirective.title, PromptTemplateType.Directive);
+ sendMessageWithButton();
+ verifyChatResponseReceived();
});
- it('User selects model with RAG and sends message with source references', function () {
- if (!testState.modelCreated || !testState.documentIngested) {
- this.skip();
- }
-
+ it('Send chat message with rag response', () => {
navigateAndVerifyChatPage();
selectModelInChat(testModel.modelId);
-
- // Select RAG repository and collection
selectRagRepositoryInChat(testRepository.repositoryId);
selectCollectionInChat(testCollection.collectionName);
-
- // Send a message that should retrieve from the uploaded document
- sendMessageAndVerifyRagResponse('Who is Whiskers?');
- });
-
- it('Cleanup: delete all chat sessions', function () {
- if (skipCleanup) {
- cy.log('Skipping cleanup: skipCleanup option is enabled');
- this.skip();
- }
-
- navigateAndVerifyChatPage();
- deleteAllSessions();
+ insertChatPrompt('Who is Whiskers?');
+ sendMessageWithButton();
+ verifyChatResponseReceived();
});
- it('Cleanup: delete test repository', function () {
- if (skipCleanup) {
- cy.log('Skipping cleanup: skipCleanup option is enabled');
- this.skip();
- }
-
- navigateToRepositoryManagement();
- cy.wait(2000);
- deleteRepositoryIfExists(testRepository.repositoryId);
- });
-
- it('Cleanup: delete persona prompt template', function () {
- if (skipCleanup) {
- cy.log('Skipping cleanup: skipCleanup option is enabled');
- this.skip();
- }
-
- navigateToPromptTemplates();
- cy.wait(2000);
- deletePromptTemplateIfExists(testPromptTemplatePersona.title);
- });
+ if (!options.skipCleanup) {
+ it('Cleanup: delete all chat sessions', () => {
+ navigateAndVerifyChatPage();
+ deleteAllSessions();
+ });
- it('Cleanup: delete directive prompt template', function () {
- if (skipCleanup) {
- cy.log('Skipping cleanup: skipCleanup option is enabled');
- this.skip();
- }
+ it('Cleanup: delete test repository', () => {
+ navigateToRepositoryManagement();
+ cy.wait(2000);
+ deleteRepositoryIfExists(testRepository.repositoryId);
+ });
- navigateToPromptTemplates();
- cy.wait(2000);
- deletePromptTemplateIfExists(testPromptTemplateDirective.title);
- });
+ it('Cleanup: delete persona prompt template', () => {
+ navigateToPromptTemplates();
+ cy.wait(2000);
+ deletePromptTemplateIfExists(testPromptTemplatePersona.title);
+ });
- it('Cleanup: delete test model', function () {
- if (skipCleanup) {
- cy.log('Skipping cleanup: skipCleanup option is enabled');
- this.skip();
- }
+ it('Cleanup: delete directive prompt template', () => {
+ navigateToPromptTemplates();
+ cy.wait(2000);
+ deletePromptTemplateIfExists(testPromptTemplateDirective.title);
+ });
- navigateToAdminPage('Model Management');
- cy.wait(2000);
- deleteModelIfExists(testModel.modelId);
- });
+ it('Cleanup: delete test model', () => {
+ navigateToAdminPage('Model Management');
+ cy.wait(2000);
+ deleteModelIfExists(testModel.modelId);
+ });
+ }
}
diff --git a/cypress/src/shared/specs/user.shared.spec.ts b/cypress/src/shared/specs/user.shared.spec.ts
index 109bef61f..adbfc890e 100644
--- a/cypress/src/shared/specs/user.shared.spec.ts
+++ b/cypress/src/shared/specs/user.shared.spec.ts
@@ -28,7 +28,7 @@ import { checkNoAdminButton } from '../../support/adminHelpers';
export function runUserTests () {
it('Non-admin does not see the Administration button', () => {
// Wait for configuration to load before checking UI
- cy.wait('@getConfiguration', { timeout: 30000 });
+ // cy.wait('@getConfiguration', { timeout: 30000 });
checkNoAdminButton();
});
diff --git a/cypress/src/smoke/fixtures/env.json b/cypress/src/smoke/fixtures/env.json
index f46c22504..4f1115b9b 100644
--- a/cypress/src/smoke/fixtures/env.json
+++ b/cypress/src/smoke/fixtures/env.json
@@ -9,5 +9,7 @@
"RESTAPI_VERSION": "v2",
"RAG_ENABLED": true,
"HOSTED_MCP_ENABLED": true,
- "API_BASE_URL": "/dev/"
+ "API_BASE_URL": "/dev/",
+ "USE_CUSTOM_BRANDING": false,
+ "CUSTOM_DISPLAY_NAME": "LISA"
}
diff --git a/cypress/src/support/chatHelpers.ts b/cypress/src/support/chatHelpers.ts
index 32c2cd3ac..2a71854c6 100644
--- a/cypress/src/support/chatHelpers.ts
+++ b/cypress/src/support/chatHelpers.ts
@@ -181,10 +181,30 @@ export function sendMessageWithButton () {
}
/**
- * Verify that a chat response was received
+ * Insert text into the chat prompt input
+ * @param text - The text to insert into the chat input
+ */
+export function insertChatPrompt (text: string) {
+ cy.get(CHAT_SELECTORS.MESSAGE_INPUT)
+ .should('be.visible')
+ .and('not.be.disabled')
+ .clear()
+ .type(text, { delay: 0 });
+}
+
+/**
+ * Verify that a chat response was received and is complete
* @param minMessages - Minimum number of messages expected (default: 2 for user + assistant)
*/
export function verifyChatResponseReceived (minMessages: number = 2) {
- cy.get('[data-testid="chat-message"]', { timeout: 30000 })
+ // Wait for "Generating response" box to disappear, indicating the response is complete
+ cy.get('[data-testid="generating-response-box"]', { timeout: 60000 }).should('not.exist');
+
+ // Wait for AI response message
+ cy.get('[data-testid="chat-message-ai"]', { timeout: 30000 })
+ .should('have.length.at.least', 1);
+
+ // Verify total message count (user + assistant messages)
+ cy.get('[data-testid^="chat-message-"]', { timeout: 30000 })
.should('have.length.at.least', minMessages);
}
diff --git a/cypress/src/support/collectionHelpers.ts b/cypress/src/support/collectionHelpers.ts
index fce8f815b..e118b00eb 100644
--- a/cypress/src/support/collectionHelpers.ts
+++ b/cypress/src/support/collectionHelpers.ts
@@ -374,14 +374,14 @@ export function getAutoCreatedCollectionInfo (repositoryId: string): Cypress.Cha
if (autoCreatedCollection) {
const collectionName = autoCreatedCollection.name;
const collectionId = autoCreatedCollection.collectionId;
- cy.log(`Found auto-created collection: ${collectionName} (ID: ${collectionId})`);
- return { name: collectionName, id: collectionId };
+ Cypress.log({ name: 'getAutoCreatedCollectionInfo', message: `Found auto-created collection: ${collectionName} (ID: ${collectionId})` });
+ return cy.wrap({ name: collectionName, id: collectionId });
}
// Fallback to first collection if no default found
const firstCollection = collections[0];
- cy.log(`Using first collection: ${firstCollection.name} (ID: ${firstCollection.collectionId})`);
- return { name: firstCollection.name, id: firstCollection.collectionId };
+ Cypress.log({ name: 'getAutoCreatedCollectionInfo', message: `Using first collection: ${firstCollection.name} (ID: ${firstCollection.collectionId})` });
+ return cy.wrap({ name: firstCollection.name, id: firstCollection.collectionId });
}
throw new Error(`Failed to fetch collections for repository ${repositoryId}`);
});
diff --git a/cypress/src/support/modelFormHelpers.ts b/cypress/src/support/modelFormHelpers.ts
index 3df8e952d..6f6768df3 100644
--- a/cypress/src/support/modelFormHelpers.ts
+++ b/cypress/src/support/modelFormHelpers.ts
@@ -26,6 +26,16 @@ export type BedrockModelConfig = {
streaming?: boolean;
};
+/**
+ * Check if a model exists in the model management list
+ * @returns Cypress.Chainable
+ */
+export function modelExists (modelId: string): Cypress.Chainable {
+ return cy.get('body').then(($body) => {
+ return $body.text().includes(modelId);
+ });
+}
+
/**
* Open the Create Model wizard modal
*/
diff --git a/cypress/src/support/promptTemplateHelpers.ts b/cypress/src/support/promptTemplateHelpers.ts
index 3e2aed7cf..ec4a580c5 100644
--- a/cypress/src/support/promptTemplateHelpers.ts
+++ b/cypress/src/support/promptTemplateHelpers.ts
@@ -19,13 +19,31 @@
* Contains reusable helpers for prompt template creation and management.
*/
+/**
+ * Prompt template types - mirrors PromptTemplateType from React app
+ */
+export enum PromptTemplateType {
+ Persona = 'persona',
+ Directive = 'directive'
+}
+
export type PromptTemplateConfig = {
title: string;
body: string;
- type?: 'system' | 'user';
+ type?: PromptTemplateType;
sharePublic?: boolean;
};
+/**
+ * Check if a prompt template exists in the prompt templates list
+ * @returns Cypress.Chainable
+ */
+export function promptTemplateExists (templateTitle: string): Cypress.Chainable {
+ return cy.get('body').then(($body) => {
+ return $body.text().includes(templateTitle);
+ });
+}
+
/**
* Navigate to Prompt Templates Library page
*/
@@ -77,7 +95,7 @@ export function fillPromptTemplateConfig (config: PromptTemplateConfig) {
.should('be.visible')
.click();
- const typeLabel = config.type === 'system' ? 'Persona' : 'Directive';
+ const typeLabel = config.type === PromptTemplateType.Persona ? 'Persona' : 'Directive';
cy.get('[role="listbox"]')
.should('be.visible')
.contains('[role="option"]', typeLabel)
@@ -168,60 +186,6 @@ export function deletePromptTemplateIfExists (templateTitle: string) {
});
}
-/**
- * Select a prompt template in chat
- * @param templateTitle - The title of the template to select
- * @param templateType - The type of template ('system' for Persona, 'user' for Directive)
- */
-export function selectPromptTemplateInChat (templateTitle: string, templateType: 'system' | 'user' = 'user') {
- if (templateType === 'system') {
- // For Persona templates, use the "Select Persona" button on the welcome screen
- cy.get('[data-testid="select-persona-button"]')
- .should('be.visible')
- .click();
- } else {
- // For Directive templates, use the "Insert Prompt Template" button
- cy.get('button[aria-label="Insert Prompt Template"]')
- .should('be.visible')
- .click();
- }
-
- // Wait for modal to open and get the visible one
- cy.get('[role="dialog"]')
- .filter(':visible')
- .first()
- .should('be.visible');
-
- // Search for the template
- cy.get('input[placeholder="Search by title"]')
- .should('be.visible')
- .clear()
- .type(templateTitle);
-
- // Wait for dropdown options to appear
- cy.get('[role="listbox"]')
- .should('be.visible');
-
- // Select the matching option from the dropdown (skip the "Use..." option)
- // Find the option with data-value attribute matching the template title
- cy.get('[role="option"]')
- .find(`span[data-value="${templateTitle}"]`)
- .first()
- .click();
-
- // Wait a moment for the selection to populate
- cy.wait(500);
-
- // Click the Use button using data-testid
- cy.get('[data-testid="use-prompt-button"]')
- .should('be.visible')
- .and('not.be.disabled')
- .click();
-
- // Wait for modal to close
- cy.get('[role="dialog"]').filter(':visible').should('not.exist');
-}
-
/**
* Send a message that's already in the input field by clicking the send button
*/
@@ -239,60 +203,46 @@ export function sendMessageWithButton () {
}
/**
- * Verify that a chat response was received
- * @param minMessages - Minimum number of messages expected (default: 2 for user + assistant)
- */
-export function verifyChatResponseReceived (minMessages: number = 2) {
- cy.get('[data-testid="chat-message"]', { timeout: 30000 })
- .should('have.length.at.least', minMessages);
-}
-
-/**
- * Select a directive prompt template, which inserts text into the message input, then send it
- * @param templateTitle - The title of the directive template to select
+ * Select a prompt template in chat using the Welcome Screen buttons
+ * @param templateTitle - The title of the template to select
+ * @param templateType - The type of template (Persona or Directive)
*/
-export function selectDirectiveAndSend (templateTitle: string) {
- // Click the "Select Directive" button on the welcome screen
- cy.get('[data-testid="select-directive-button"]')
- .should('be.visible')
+export function selectPromptTemplateInChat (templateTitle: string, templateType: PromptTemplateType = PromptTemplateType.Directive) {
+ // Use the Welcome Screen buttons (Select Persona / Select Directive)
+ // These are visible when there's no chat history
+ const isPersona = templateType === PromptTemplateType.Persona;
+ const selectButtonTestId = isPersona ? 'select-persona-button' : 'select-directive-button';
+ const useButtonTestId = '[data-testid="use-prompt-button"]';
+ const modalSelector = '[data-testid="prompt-template-modal"]';
+
+ // Click the Select Persona/Directive button using data-testid
+ cy.get(`[data-testid="${selectButtonTestId}"]`, { timeout: 10000 })
.click();
- // Wait for modal to open
- cy.get('[role="dialog"]')
- .filter(':visible')
- .first()
- .should('be.visible');
+ // Wait for the modal to open
+ cy.get(modalSelector).should('be.visible');
- // Search for the template
- cy.get('input[placeholder="Search by title"]')
+ // Type in the autosuggest input to search for the template
+ cy.get(`${modalSelector} input[placeholder="Search by title"]`)
.should('be.visible')
- .clear()
.type(templateTitle);
- // Wait for dropdown options to appear
- cy.get('[role="listbox"]')
- .should('be.visible');
-
- // Select the matching option from the dropdown (skip the "Use..." option)
- // Find the option with data-value attribute matching the template title
+ // Wait for dropdown options to appear and select the correct one
+ // Avoid the first entry prefixed with "Use" by selecting the option that matches the exact title
cy.get('[role="option"]')
- .find(`span[data-value="${templateTitle}"]`)
+ .filter(`:contains("${templateTitle}")`)
+ .not(':contains("Use")')
.first()
+ .should('be.visible')
.click();
- // Wait a moment for the selection to populate the textarea
- cy.wait(500);
-
- // Click the Use Prompt button using data-testid
- cy.get('[data-testid="use-prompt-button"]')
+ // Click the Use Persona/Directive button using data-testid
+ cy.get(`${modalSelector} ${useButtonTestId}`)
.should('be.visible')
.and('not.be.disabled')
.click();
- // Wait for modal to close
- cy.get('[role="dialog"]').filter(':visible').should('not.exist');
-
- // Send the message
- sendMessageWithButton();
- verifyChatResponseReceived();
+ // Wait for modal to close and UI to stabilize
+ cy.get(modalSelector).should('not.be.visible');
+ cy.wait(500);
}
diff --git a/cypress/src/support/repositoryHelpers.ts b/cypress/src/support/repositoryHelpers.ts
index 9e16a1589..dba1143b9 100644
--- a/cypress/src/support/repositoryHelpers.ts
+++ b/cypress/src/support/repositoryHelpers.ts
@@ -25,6 +25,16 @@ export type RepositoryConfig = {
dataSourceIndex?: number;
};
+/**
+ * Check if a repository exists in the repository management list
+ * @returns Cypress.Chainable
+ */
+export function repositoryExists (repositoryId: string): Cypress.Chainable {
+ return cy.get('body').then(($body) => {
+ return $body.text().includes(repositoryId);
+ });
+}
+
/**
* Navigate to the repository management page
*/
diff --git a/ecs_model_deployer/src/lib/ecs-model.ts b/ecs_model_deployer/src/lib/ecs-model.ts
index f77511499..0d4b98330 100644
--- a/ecs_model_deployer/src/lib/ecs-model.ts
+++ b/ecs_model_deployer/src/lib/ecs-model.ts
@@ -24,9 +24,8 @@ import { getModelIdentifier } from './utils';
import { APP_MANAGEMENT_KEY, Ec2Metadata, EcsClusterConfig, EcsSourceType, PartialConfig } from '../../../lib/schema';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
-// This is the amount of memory to buffer (or subtract off) from the total instance memory, if we don't include this,
-// the container can have a hard time finding available RAM resources to start and the tasks will fail deployment
-const CONTAINER_MEMORY_BUFFER = 1024 * 5;
+// Default memory buffer if not specified in config (2GB)
+const DEFAULT_CONTAINER_MEMORY_BUFFER = 1024 * 2;
/**
* Properties for the EcsModel Construct.
@@ -72,7 +71,7 @@ export class EcsModel extends Construct {
amiHardwareType: AmiHardwareType.GPU,
autoScalingConfig: modelConfig.autoScalingConfig,
buildArgs: this.getBuildArguments(config, modelConfig),
- containerMemoryBuffer: CONTAINER_MEMORY_BUFFER,
+ containerMemoryBuffer: modelConfig.containerMemoryBuffer ?? DEFAULT_CONTAINER_MEMORY_BUFFER,
instanceType: modelConfig.instanceType,
internetFacing: false,
loadBalancerConfig: modelConfig.loadBalancerConfig,
diff --git a/ecs_model_deployer/src/lib/ecsCluster.ts b/ecs_model_deployer/src/lib/ecsCluster.ts
index 1618d38dd..a8451bd2e 100644
--- a/ecs_model_deployer/src/lib/ecsCluster.ts
+++ b/ecs_model_deployer/src/lib/ecsCluster.ts
@@ -19,6 +19,7 @@ import { CfnOutput, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { BlockDeviceVolume, GroupMetrics, Monitoring } from 'aws-cdk-lib/aws-autoscaling';
import { Metric, Stats } from 'aws-cdk-lib/aws-cloudwatch';
import { InstanceType, ISecurityGroup, IVpc, SubnetSelection } from 'aws-cdk-lib/aws-ec2';
+import { Alias } from 'aws-cdk-lib/aws-kms';
import {
Cluster,
ContainerDefinition,
@@ -32,6 +33,7 @@ import {
LinuxParameters,
LogDriver,
MountPoint,
+ NetworkMode,
Protocol,
Volume,
} from 'aws-cdk-lib/aws-ecs';
@@ -94,14 +96,21 @@ export class ECSCluster extends Construct {
containerInsightsV2: !config.region?.includes('iso') ? ContainerInsights.ENABLED : ContainerInsights.DISABLED,
});
- // Create auto scaling group
+ // SNS encryption key for ECS lifecycle hooks (AppSec Finding #5)
+ const snsEncryptionKey = Alias.fromAliasName(
+ this,
+ createCdkId([identifier, 'SnsKey']),
+ 'alias/aws/sns'
+ );
+
+ // Create auto scaling group with SNS topic encryption for lifecycle hooks
+ // Note: cooldown is not set here because we use target tracking scaling which manages its own cooldown.
const autoScalingGroup = cluster.addCapacity(createCdkId([identifier, 'ASG']), {
vpcSubnets: subnetSelection,
instanceType: new InstanceType(ecsConfig.instanceType),
- machineImage: EcsOptimizedImage.amazonLinux2(ecsConfig.amiHardwareType),
+ machineImage: EcsOptimizedImage.amazonLinux2023(ecsConfig.amiHardwareType),
minCapacity: ecsConfig.autoScalingConfig.minCapacity,
maxCapacity: ecsConfig.autoScalingConfig.maxCapacity,
- cooldown: Duration.seconds(ecsConfig.autoScalingConfig.cooldown),
groupMetrics: [GroupMetrics.all()],
instanceMonitoring: Monitoring.DETAILED,
newInstancesProtectedFromScaleIn: false,
@@ -114,6 +123,7 @@ export class ECSCluster extends Construct {
}),
},
],
+ topicEncryptionKey: snsEncryptionKey,
});
new CfnOutput(this, 'autoScalingGroup', {
@@ -208,6 +218,7 @@ export class ECSCluster extends Construct {
// Create ECS task definition
const ec2TaskDefinition = new Ec2TaskDefinition(this, createCdkId([roleId, 'Ec2TaskDefinition']), {
family: createCdkId([config.deploymentName, roleId], 32, 2),
+ networkMode: NetworkMode.BRIDGE,
volumes: volumes,
taskRole,
...(executionRoleName && { executionRole: Role.fromRoleName(this, createCdkId([config.deploymentName, roleId, 'EX']), executionRoleName) }),
@@ -273,6 +284,10 @@ export class ECSCluster extends Construct {
});
// Add listener
+ // Note: This ALB is internal (internetFacing: false) and uses HTTP only.
+ // If HTTPS is enabled in the future, use SslPolicy.TLS13_RES for AppSec compliance.
+ // SslPolicy.TLS13_RES maps to ELBSecurityPolicy-TLS13-1-2-2021-06
+ // This policy provides forward secrecy with ECDHE cipher suites and excludes RSA key exchange.
const listenerProps: BaseApplicationListenerProps = {
port: 80,
open: false,
@@ -288,7 +303,8 @@ export class ECSCluster extends Construct {
// Add targets
const loadBalancerHealthCheckConfig = ecsConfig.loadBalancerConfig.healthCheckConfig;
const targetGroup = listener.addTargets(createCdkId([identifier, 'TgtGrp']), {
- targetGroupName: createCdkId([config.deploymentName, identifier], 32, 2).toLowerCase(),
+ // Note: targetGroupName intentionally omitted to allow CloudFormation to generate unique names.
+ // This enables seamless replacement when immutable properties (like TargetType) change.
healthCheck: {
path: loadBalancerHealthCheckConfig.path,
interval: Duration.seconds(loadBalancerHealthCheckConfig.interval),
@@ -298,8 +314,14 @@ export class ECSCluster extends Construct {
},
port: 80,
targets: [service],
+ // Slow start gives new targets time to warm up before receiving full traffic
+ slowStart: Duration.seconds(60),
});
+ // Configure target group for LLM workloads which may have long response times
+ // This prevents 504 Gateway Timeout errors during model inference
+ targetGroup.setAttribute('deregistration_delay.timeout_seconds', '30');
+
// ALB metric for ASG to use for auto scaling EC2 instances
// TODO: Update this to step scaling for embedding models??
const requestCountPerTargetMetric = new Metric({
diff --git a/example_config.yaml b/example_config.yaml
index 173db24b2..70484107d 100644
--- a/example_config.yaml
+++ b/example_config.yaml
@@ -67,15 +67,17 @@ ragRepositories: []
# If adding an existing PGVector database, this configurations assumes:
# 1. The database has been configured to have pgvector installed and enabled: https://aws.amazon.com/about-aws/whats-new/2023/05/amazon-rds-postgresql-pgvector-ml-model-integration/
# 2. The database is accessible by RAG-related lambda functions (add inbound PostgreSQL access on the database's security group for all Lambda RAG security groups)
-# 3. A secret ID exists in SecretsManager holding the database password within a json block of '{"password":"your_password_here"}'. This is the same format that RDS natively provides a password in SecretsManager.
-# If the passwordSecretId or dbHost are not provided, then a sample database will be created for you. Only the username is required.
+# 3. If using password auth (iamRdsAuth: false), a secret ID exists in SecretsManager holding the database password within a json block of '{"password":"your_password_here"}'.
+# If using IAM auth (default), the database must have IAM authentication enabled.
+# If the dbHost is not provided, then a sample database will be created for you.
# - repositoryId: pgvector-rag
# type: pgvector
# rdsConfig:
# username: postgres
-# passwordSecretId: # password ID as stored in SecretsManager. Example: "rds!db-aa88493d-be8d-4a3f-96dc-c668165f7826"
+# passwordSecretId: # password ID as stored in SecretsManager (only needed if iamRdsAuth: false). Example: "rds!db-aa88493d-be8d-4a3f-96dc-c668165f7826"
# dbHost: # Host name of database. Example hostname from RDS: "my-db-name.291b2f03.us-east-1.rds.amazonaws.com"
# dbName: postgres
+# 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
diff --git a/lambda/api_tokens/handler.py b/lambda/api_tokens/handler.py
index 3478989c7..66768b39f 100644
--- a/lambda/api_tokens/handler.py
+++ b/lambda/api_tokens/handler.py
@@ -13,7 +13,7 @@
# limitations under the License.
import logging
-from typing import Optional
+from typing import Any
from uuid import uuid4
from boto3.dynamodb.conditions import Key
@@ -37,10 +37,10 @@
class CreateTokenAdminHandler:
"""Admin creates token for any user or system"""
- def __init__(self, token_table):
+ def __init__(self, token_table: Any) -> None:
self.token_table = token_table
- def _get_user_token(self, username: str) -> Optional[dict]:
+ def _get_user_token(self, username: str) -> dict | None:
"""Query for existing token by username using GSI"""
response = self.token_table.query(
IndexName="username-index", KeyConditionExpression=Key("username").eq(username), Limit=1
@@ -48,7 +48,9 @@ def _get_user_token(self, username: str) -> Optional[dict]:
items = response.get("Items", [])
return items[0] if items else None
- def __call__(self, username: str, request: CreateTokenAdminRequest, created_by: str, is_admin: bool):
+ def __call__(
+ self, username: str, request: CreateTokenAdminRequest, created_by: str, is_admin: bool
+ ) -> CreateTokenResponse:
# Authorization: Only admins can create tokens for other users
if not is_admin:
raise UnauthorizedError("Only admins can create tokens for other users")
@@ -97,10 +99,10 @@ def __call__(self, username: str, request: CreateTokenAdminRequest, created_by:
class CreateTokenUserHandler:
"""User creates their own token"""
- def __init__(self, token_table):
+ def __init__(self, token_table: Any) -> None:
self.token_table = token_table
- def _get_user_token(self, username: str) -> Optional[dict]:
+ def _get_user_token(self, username: str) -> dict | None:
"""Query for existing token by username using GSI"""
response = self.token_table.query(
IndexName="username-index", KeyConditionExpression=Key("username").eq(username), Limit=1
@@ -110,7 +112,7 @@ def _get_user_token(self, username: str) -> Optional[dict]:
def __call__(
self, request: CreateTokenUserRequest, username: str, user_groups: list[str], is_admin: bool, is_api_user: bool
- ):
+ ) -> CreateTokenResponse:
# Authorization: User must be admin or in apiGroup
if not is_admin and not is_api_user:
raise ForbiddenError("User must be in the API group to create tokens")
@@ -156,7 +158,7 @@ def __call__(
class ListTokensHandler:
"""List tokens - admins see all, users see only their own"""
- def __init__(self, token_table):
+ def __init__(self, token_table: Any) -> None:
self.token_table = token_table
def __call__(self, username: str, is_admin: bool) -> ListTokensResponse:
@@ -203,7 +205,7 @@ def __call__(self, username: str, is_admin: bool) -> ListTokensResponse:
class GetTokenHandler:
"""Get specific token details"""
- def __init__(self, token_table):
+ def __init__(self, token_table: Any) -> None:
self.token_table = token_table
def __call__(self, token_uuid: str, username: str, is_admin: bool) -> TokenInfo:
@@ -263,7 +265,7 @@ def __call__(self, token_uuid: str, username: str, is_admin: bool) -> TokenInfo:
class DeleteTokenHandler:
"""Delete token - handles both modern and legacy tokens"""
- def __init__(self, token_table):
+ def __init__(self, token_table: Any) -> None:
self.token_table = token_table
def __call__(self, token_uuid: str, username: str, is_admin: bool) -> DeleteTokenResponse:
diff --git a/lambda/api_tokens/lambda_functions.py b/lambda/api_tokens/lambda_functions.py
index 9e686e353..94c90343b 100644
--- a/lambda/api_tokens/lambda_functions.py
+++ b/lambda/api_tokens/lambda_functions.py
@@ -13,19 +13,17 @@
# limitations under the License.
"""APIGW endpoints for managing API tokens."""
+import logging
import os
-from typing import Annotated, Union
+from typing import Annotated
import boto3
-from fastapi import FastAPI, HTTPException, Path, Request
-from fastapi.encoders import jsonable_encoder
-from fastapi.exceptions import RequestValidationError
-from fastapi.middleware.cors import CORSMiddleware
+from fastapi import HTTPException, Path, Request
from fastapi.responses import JSONResponse
from mangum import Mangum
from utilities.auth import get_user_context, is_api_user
from utilities.common_functions import retry_config
-from utilities.fastapi_middleware.aws_api_gateway_middleware import AWSAPIGatewayMiddleware
+from utilities.fastapi_factory import create_fastapi_app
from .domain_objects import (
CreateTokenAdminRequest,
@@ -35,7 +33,7 @@
ListTokensResponse,
TokenInfo,
)
-from .exception import ForbiddenError, TokenAlreadyExistsError, TokenNotFoundError, UnauthorizedError
+from .exception import TokenAlreadyExistsError, TokenNotFoundError
from .handler import (
CreateTokenAdminHandler,
CreateTokenUserHandler,
@@ -44,17 +42,9 @@
ListTokensHandler,
)
-app = FastAPI(redirect_slashes=False, lifespan="off", docs_url="/docs", openapi_url="/openapi.json")
-app.add_middleware(AWSAPIGatewayMiddleware)
+logger = logging.getLogger(__name__)
-# Enable CORS
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
-)
+app = create_fastapi_app()
# Initialize boto3 resources
dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config)
@@ -67,29 +57,9 @@ async def token_not_found_handler(request: Request, exc: TokenNotFoundError) ->
return JSONResponse(status_code=404, content={"message": str(exc)})
-@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, content={"detail": jsonable_encoder(exc.errors()), "type": "RequestValidationError"}
- )
-
-
-@app.exception_handler(UnauthorizedError)
-async def unauthorized_handler(request: Request, exc: UnauthorizedError) -> JSONResponse:
- """Handle unauthorized access attempts and translate to a 401 error."""
- return JSONResponse(status_code=401, content={"message": str(exc)})
-
-
-@app.exception_handler(ForbiddenError)
-async def forbidden_handler(request: Request, exc: ForbiddenError) -> JSONResponse:
- """Handle forbidden access attempts and translate to a 403 error."""
- return JSONResponse(status_code=403, content={"message": str(exc)})
-
-
@app.exception_handler(TokenAlreadyExistsError)
@app.exception_handler(ValueError)
-async def user_error_handler(request: Request, exc: Union[TokenAlreadyExistsError, ValueError]) -> JSONResponse:
+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)})
diff --git a/lambda/authorizer/lambda_functions.py b/lambda/authorizer/lambda_functions.py
index 243048a99..729f49650 100644
--- a/lambda/authorizer/lambda_functions.py
+++ b/lambda/authorizer/lambda_functions.py
@@ -18,7 +18,7 @@
import logging
import os
import ssl
-from typing import Any, Dict
+from typing import Any
import boto3
import create_env_variables # noqa: F401
@@ -26,6 +26,7 @@
import requests
from botocore.exceptions import ClientError
from cachetools import cached, TTLCache
+from utilities.auth_provider import get_authorization_provider
from utilities.common_functions import authorization_wrapper, get_id_token, get_property_path, retry_config
from utilities.time import now_seconds
@@ -38,13 +39,10 @@
@authorization_wrapper
-def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: ignore [no-untyped-def]
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Handle authorization for REST API."""
logger.info("REST API authorization handler started")
- requested_resource = event["resource"]
- request_method = event["httpMethod"]
-
id_token = get_id_token(event)
if not id_token:
@@ -52,18 +50,16 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i
logger.info(f"REST API authorization handler completed with 'Deny' for resource {event['methodArn']}")
return generate_policy(effect="Deny", resource=event["methodArn"])
- # TODO: investigate authority case sensitivity
client_id = os.environ.get("CLIENT_ID", "")
authority = os.environ.get("AUTHORITY", "")
admin_group = os.environ.get("ADMIN_GROUP", "")
- user_group = os.environ.get("USER_GROUP", "")
jwt_groups_property = os.environ.get("JWT_GROUPS_PROP", "")
deny_policy = generate_policy(effect="Deny", resource=event["methodArn"])
groups: str
+
if id_token in get_management_tokens():
username = "lisa-management-token"
- # Add management token to Admin groups
groups = json.dumps([admin_group])
allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username)
allow_policy["context"] = {"username": username, "groups": groups, "authType": "management"}
@@ -73,33 +69,30 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i
if os.environ.get("TOKEN_TABLE_NAME", None):
token_info = is_valid_api_token(id_token)
if token_info:
-
username = token_info.get("username", "api-token")
groups = json.dumps(token_info.get("groups", []))
-
allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username)
allow_policy["context"] = {"username": username, "groups": groups, "authType": "api_token"}
logger.debug(f"Generated policy: {allow_policy}")
return allow_policy
if jwt_data := id_token_is_valid(id_token=id_token, client_id=client_id, authority=authority):
- is_admin_user = is_admin(jwt_data, admin_group, jwt_groups_property)
- is_in_user_group = is_user(jwt_data, user_group, jwt_groups_property) if user_group != "" else True
- groups = json.dumps(get_property_path(jwt_data, jwt_groups_property) or [])
username = find_jwt_username(jwt_data)
+ user_groups = get_property_path(jwt_data, jwt_groups_property) or []
+
+ # Use auth provider for access checks (consistent with auth.py)
+ auth_provider = get_authorization_provider()
+ is_admin_user = auth_provider.check_admin_access(username, user_groups)
+ has_app_access = auth_provider.check_app_access(username, user_groups)
+
+ if not is_admin_user and not has_app_access:
+ logger.info(f"User {username} denied access - no valid authorization found")
+ return deny_policy
+
+ groups = json.dumps(user_groups)
allow_policy = generate_policy(effect="Allow", resource=event["methodArn"], username=username)
allow_policy["context"] = {"username": username, "groups": groups, "authType": "jwt"}
- if not is_in_user_group:
- return deny_policy
- if requested_resource.startswith("/models") and not is_admin_user:
- # non-admin users can still list models
- if event["path"].rstrip("/") != "/models":
- logger.info(f"Deny access to {username} due to non-admin accessing /models api.")
- return deny_policy
- if requested_resource.startswith("/configuration") and request_method == "PUT" and not is_admin_user:
- logger.info(f"Deny access to {username} due to non-admin trying to update configuration.")
- return deny_policy
logger.debug(f"Generated policy: {allow_policy}")
logger.info(f"REST API authorization handler completed with 'Allow' for resource {event['methodArn']}")
return allow_policy
@@ -108,7 +101,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i
return deny_policy
-def generate_policy(*, effect: str, resource: str, username: str = "username") -> Dict[str, Any]:
+def generate_policy(*, effect: str, resource: str, username: str = "username") -> dict[str, Any]:
"""Generate IAM policy."""
policy = {
"principalId": username,
@@ -127,28 +120,20 @@ def _get_token_info(token: str) -> Any:
def is_valid_api_token(token: str) -> dict | None:
- """
- Validate API token and return token info if valid.
- Returns: token_info
- """
+ """Validate API token and return token info if valid."""
if not token:
return None
- # Hash the provided token
token_hash = hashlib.sha256(token.encode()).hexdigest()
-
- # Look up hashed token in DynamoDB
token_info = _get_token_info(token_hash)
if not token_info:
return None
- # Reject legacy tokens without tokenUUID
if not token_info.get("tokenUUID"):
logger.warning("Legacy token detected - missing tokenUUID attribute. Token must be recreated.")
return None
- # Check expiration
token_expiration = token_info.get(TOKEN_EXPIRATION_NAME)
if not token_expiration:
logger.warning("Token missing expiration field")
@@ -159,17 +144,16 @@ def is_valid_api_token(token: str) -> dict | None:
logger.info(f"Token expired at {token_expiration}")
return None
- return token_info
+ return token_info # type: ignore[no-any-return]
-def id_token_is_valid(*, id_token: str, client_id: str, authority: str) -> Dict[str, Any] | None:
+def id_token_is_valid(*, id_token: str, client_id: str, authority: str) -> dict[str, Any] | None:
"""Check whether an ID token is valid and return decoded data."""
if not jwt.algorithms.has_crypto:
logger.error("No crypto support for JWT, please install the cryptography dependency")
return None
logger.info(f"{authority}/.well-known/openid-configuration")
- # Here we will point to the sponsor bundle if available, defined in the create_env_variables import above
cert_path = os.getenv("SSL_CERT_FILE", None)
resp = requests.get(
f"{authority}/.well-known/openid-configuration",
@@ -190,7 +174,7 @@ def id_token_is_valid(*, id_token: str, client_id: str, authority: str) -> Dict[
data: dict = jwt.decode(
id_token,
signing_key.key,
- algorithms=["RS256", "RS512"],
+ algorithms=["RS256", "RS512", "ES384"],
issuer=authority,
audience=client_id,
options={
@@ -208,17 +192,8 @@ def id_token_is_valid(*, id_token: str, client_id: str, authority: str) -> Dict[
return None
-def is_admin(jwt_data: dict[str, Any], admin_group: str, jwt_groups_property: str) -> bool:
- """Check if the user is an admin."""
- return admin_group in (get_property_path(jwt_data, jwt_groups_property) or [])
-
-
-def is_user(jwt_data: dict[str, Any], user_group: str, jwt_groups_property: str) -> bool:
- return user_group in (get_property_path(jwt_data, jwt_groups_property) or [])
-
-
def find_jwt_username(jwt_data: dict[str, str]) -> str:
- """Find the username in the JWT. If the key 'username' doesn't exist, return 'sub', which will be a UUID"""
+ """Find the username in the JWT."""
username = None
if "username" in jwt_data:
username = jwt_data.get("username")
diff --git a/lambda/configuration/lambda_functions.py b/lambda/configuration/lambda_functions.py
index 86db08b6c..951392507 100644
--- a/lambda/configuration/lambda_functions.py
+++ b/lambda/configuration/lambda_functions.py
@@ -18,13 +18,15 @@
import os
import time
from decimal import Decimal
-from typing import Any, Dict
+from typing import Any
import boto3
from botocore.exceptions import ClientError
from mcp_server.models import McpServerModel, McpServerStatus
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
logger = logging.getLogger(__name__)
@@ -33,15 +35,15 @@
@api_wrapper
-def get_configuration(event: dict, context: dict) -> Dict[str, Any]:
+def get_configuration(event: dict, context: dict) -> dict[str, Any]:
"""List configuration entries by configScope from DynamoDB."""
config_scope = event["queryStringParameters"]["configScope"]
- return _get_configurations(config_scope)
+ return _get_configurations(config_scope) # type: ignore[return-value]
def _get_configurations(config_scope: str) -> list[dict[str, Any]]:
- response = {}
+ response: dict[str, Any] = {}
try:
response = table.query(
KeyConditionExpression="#s = :configScope",
@@ -55,14 +57,24 @@ def _get_configurations(config_scope: str) -> list[dict[str, Any]]:
else:
logger.exception("Error fetching session")
- return response.get("Items", []) # type: ignore [no-any-return]
+ items = response.get("Items", [])
+ return items if isinstance(items, list) else []
@api_wrapper
-def update_configuration(event: dict, context: dict) -> None:
- """Update configuration in DynamoDB."""
+def update_configuration(event: dict, context: dict) -> dict[str, str]:
+ """Update configuration in DynamoDB.
+
+ Only admins can update global configuration scope.
+ """
# from https://stackoverflow.com/a/71446846
body = json.loads(event["body"], parse_float=Decimal)
+ config_scope = body.get("configScope", "")
+
+ # 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")
+
body["created_at"] = str(Decimal(time.time()))
# check if showMcpWorkbench configuration changed
@@ -74,9 +86,12 @@ def update_configuration(event: dict, context: dict) -> None:
table.put_item(Item=body)
except ClientError:
logger.exception("Error updating session in DynamoDB")
+ raise
+
+ return {"status": "ok"}
-def check_show_mcp_workbench(body, old_configuration):
+def check_show_mcp_workbench(body: dict[str, Any], old_configuration: dict[str, Any]) -> None:
old_show_mcp_value = get_property_path(old_configuration, "configuration.enabledComponents.showMcpWorkbench")
new_show_mcp_value = get_property_path(body, "configuration.enabledComponents.showMcpWorkbench")
diff --git a/lambda/dockerimagebuilder/__init__.py b/lambda/dockerimagebuilder/__init__.py
index d0d1d4186..80b371e64 100644
--- a/lambda/dockerimagebuilder/__init__.py
+++ b/lambda/dockerimagebuilder/__init__.py
@@ -16,7 +16,7 @@
import os
import shlex
import uuid
-from typing import Any, Dict
+from typing import Any
import boto3
from botocore.exceptions import ClientError
@@ -87,7 +87,7 @@
"""
-def handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: ignore [no-untyped-def]
+def handler(event: dict[str, Any], context) -> dict[str, Any]: # type: ignore [no-untyped-def]
logger.info(f"Starting Docker image builder with event: {event}")
base_image = event["base_image"]
@@ -99,7 +99,7 @@ def handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: ignore [
ec2_resource = boto3.resource("ec2", region_name=os.environ["AWS_REGION"])
ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"])
- response = ssm_client.get_parameter(Name="/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2")
+ response = ssm_client.get_parameter(Name="/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64")
ami_id = response["Parameter"]["Value"]
image_tag = str(uuid.uuid4())
@@ -125,7 +125,13 @@ def handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: ignore [
"UserData": rendered_userdata,
"IamInstanceProfile": {"Arn": os.environ["LISA_INSTANCE_PROFILE"]},
"BlockDeviceMappings": [
- {"DeviceName": "/dev/xvda", "Ebs": {"VolumeSize": int(os.environ["LISA_IMAGEBUILDER_VOLUME_SIZE"])}}
+ {
+ "DeviceName": "/dev/xvda",
+ "Ebs": {
+ "VolumeSize": int(os.environ["LISA_IMAGEBUILDER_VOLUME_SIZE"]),
+ "Encrypted": True,
+ },
+ }
],
"TagSpecifications": [
{
diff --git a/lambda/management_key.py b/lambda/management_key.py
index c1013b810..55ba3abc8 100644
--- a/lambda/management_key.py
+++ b/lambda/management_key.py
@@ -18,7 +18,7 @@
import logging
import os
import string
-from typing import Any, Dict
+from typing import Any
import boto3
from botocore.exceptions import ClientError
@@ -33,7 +33,7 @@
events_client = boto3.client("events", region_name=os.environ["AWS_REGION"], config=retry_config)
-def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
AWS Secrets Manager rotation handler for management key.
@@ -180,7 +180,7 @@ def finish_secret(secret_arn: str, token: str) -> None:
raise
-def publish_rotation_event(secret_arn: str, new_version: str, old_version: str) -> None:
+def publish_rotation_event(secret_arn: str, new_version: str, old_version: str | None) -> None:
"""
Publish a management key rotation event to EventBridge.
"""
diff --git a/lambda/mcp_server/lambda_functions.py b/lambda/mcp_server/lambda_functions.py
index 30cf822d5..36b614b83 100644
--- a/lambda/mcp_server/lambda_functions.py
+++ b/lambda/mcp_server/lambda_functions.py
@@ -13,6 +13,8 @@
# limitations under the License.
"""Lambda functions for managing MCP Servers in AWS DynamoDB."""
+from __future__ import annotations
+
import json
import logging
import os
@@ -20,7 +22,7 @@
import uuid
from decimal import Decimal
from functools import reduce
-from typing import Any, Dict, List, Optional
+from typing import Any
import boto3
from boto3.dynamodb.conditions import Attr, Key
@@ -48,7 +50,7 @@ def _normalize_server_name(name: str) -> str:
return re.sub(r"[^a-zA-Z0-9]", "", name)
-def replace_bearer_token_header(mcp_server: dict, replacement: str):
+def replace_bearer_token_header(mcp_server: dict, replacement: str) -> None:
"""Replace {LISA_BEARER_TOKEN} placeholder with actual bearer token in custom headers."""
custom_headers = mcp_server.get("customHeaders", {})
for key, value in custom_headers.items():
@@ -56,7 +58,7 @@ def replace_bearer_token_header(mcp_server: dict, replacement: str):
custom_headers[key] = value.replace("{LISA_BEARER_TOKEN}", replacement)
-def _build_groups_condition(groups: List[str]) -> Any:
+def _build_groups_condition(groups: list[str]) -> Any:
"""Build DynamoDB condition for groups filtering."""
# Servers with no groups (groups attribute doesn't exist, is null, or is empty array) should be included
no_groups_condition = Attr("groups").not_exists() | Attr("groups").eq(None) | Attr("groups").eq([])
@@ -70,11 +72,11 @@ def _build_groups_condition(groups: List[str]) -> Any:
def _get_mcp_servers(
- user_id: Optional[str] = None,
- active: Optional[bool] = None,
- replace_bearer_token: Optional[str] = None,
- groups: Optional[List] = None,
-) -> Dict[str, Any]:
+ user_id: str | None = None,
+ active: bool | None = None,
+ replace_bearer_token: str | None = None,
+ groups: list[str] | None = None,
+) -> dict[str, Any]:
"""Helper function to retrieve mcp servers from DynamoDB."""
filter_expression = None
condition = None
@@ -123,7 +125,7 @@ def _get_mcp_servers(
condition = _build_groups_condition(groups)
filter_expression = condition if filter_expression is None else filter_expression & condition
- scan_arguments = {
+ scan_arguments: dict[str, Any] = {
"TableName": os.environ["MCP_SERVERS_TABLE_NAME"],
"IndexName": os.environ["MCP_SERVERS_BY_OWNER_INDEX_NAME"],
}
@@ -188,17 +190,17 @@ def get(event: dict, context: dict) -> Any:
raise ValueError(f"Not authorized to get {mcp_server_id}.")
-def _is_member(user_groups: List[str], prompt_groups: List[str]) -> bool:
+def _is_member(user_groups: list[str], prompt_groups: list[str]) -> bool:
return bool(set(user_groups) & set(prompt_groups))
def _set_can_use(
- connections: Dict[str, Any], user_id: Optional[str] = None, groups: Optional[List[str]] = None
-) -> Dict[str, Any]:
+ connections: dict[str, Any], user_id: str | None = None, groups: list[str] | None = None
+) -> dict[str, Any]:
if groups is None:
groups = []
items = connections.get("Items", [])
- formatted_groups = [f"group:{group}" for group in groups]
+ formatted_groups: list[str] = [f"group:{group}" for group in groups]
for item in items:
item["canUse"] = (
_is_member(formatted_groups, item.get("groups", []))
@@ -210,7 +212,7 @@ def _set_can_use(
@api_wrapper
-def list(event: dict, context: dict) -> Dict[str, Any]:
+def list_mcp_servers(event: dict, context: dict) -> dict[str, Any]:
"""List mcp servers for a user from DynamoDB."""
user_id, is_admin_user, groups = get_user_context(event)
@@ -275,7 +277,7 @@ def update(event: dict, context: dict) -> Any:
@api_wrapper
-def delete(event: dict, context: dict) -> Dict[str, str]:
+def delete(event: dict, context: dict) -> dict[str, str]:
"""Logically delete a mcp server from DynamoDB."""
user_id, is_admin_user, _ = get_user_context(event)
mcp_server_id = get_mcp_server_id(event)
@@ -322,7 +324,7 @@ def create_hosted_mcp_server(event: dict, context: dict) -> Any:
# Scan all items to check for duplicate normalized names
items = []
- scan_arguments = {}
+ scan_arguments: dict[str, Any] = {}
while True:
response = table.scan(**scan_arguments)
items.extend(response.get("Items", []))
@@ -362,7 +364,7 @@ def create_hosted_mcp_server(event: dict, context: dict) -> Any:
@api_wrapper
@admin_only
-def list_hosted_mcp_servers(event: dict, context: dict) -> Dict[str, Any]:
+def list_hosted_mcp_servers(event: dict, context: dict) -> dict[str, Any]:
"""List all hosted MCP servers from DynamoDB."""
user_id, is_admin_user, groups = get_user_context(event)
@@ -371,7 +373,7 @@ def list_hosted_mcp_servers(event: dict, context: dict) -> Dict[str, Any]:
logger.info(f"Listing all hosted MCP servers for user {user_id} (is_admin)")
# Get all items from the table
items = []
- scan_arguments = {}
+ scan_arguments: dict[str, Any] = {}
while True:
response = table.scan(**scan_arguments)
items.extend(response.get("Items", []))
diff --git a/lambda/mcp_server/models.py b/lambda/mcp_server/models.py
index d4ddb45cf..f0fb5154f 100644
--- a/lambda/mcp_server/models.py
+++ b/lambda/mcp_server/models.py
@@ -14,10 +14,9 @@
import uuid
from enum import StrEnum
-from typing import Dict, List, Optional, Union
+from typing import Self
from pydantic import BaseModel, Field, field_validator, model_validator
-from typing_extensions import Self
from utilities.time import iso_string
from utilities.validation import validate_any_fields_defined
@@ -49,10 +48,10 @@ class McpServerModel(BaseModel):
"""
# Unique identifier for the mcp server
- id: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()))
+ id: str | None = Field(default_factory=lambda: str(uuid.uuid4()))
# Timestamp of when the mcp server was created
- created: Optional[str] = Field(default_factory=iso_string)
+ created: str | None = Field(default_factory=iso_string)
# Owner of the MCP user
owner: str
@@ -64,19 +63,19 @@ class McpServerModel(BaseModel):
name: str
# Description of the MCP server
- description: Optional[str] = Field(default_factory=lambda: None)
+ description: str | None = Field(default_factory=lambda: None)
# Custom headers for the MCP client
- customHeaders: Optional[dict] = Field(default_factory=lambda: None)
+ customHeaders: dict | None = Field(default_factory=lambda: None)
# Custom client properties for the MCP client
- clientConfig: Optional[dict] = Field(default_factory=lambda: None)
+ clientConfig: dict | None = Field(default_factory=lambda: None)
# Status of the server set by admins
- status: Optional[McpServerStatus] = Field(default=McpServerStatus.ACTIVE)
+ status: McpServerStatus | None = Field(default=McpServerStatus.ACTIVE)
# Groups of the MCP server
- groups: Optional[List[str]] = Field(default_factory=lambda: None)
+ groups: list[str] | None = Field(default_factory=lambda: None)
class LoadBalancerHealthCheckConfig(BaseModel):
@@ -98,7 +97,7 @@ class LoadBalancerConfig(BaseModel):
class ContainerHealthCheckConfig(BaseModel):
"""Specifies container health check parameters."""
- command: Union[str, List[str]]
+ command: str | list[str]
interval: int = Field(gt=0)
startPeriod: int = Field(ge=0)
timeout: int = Field(gt=0)
@@ -110,21 +109,21 @@ class AutoScalingConfig(BaseModel):
minCapacity: int
maxCapacity: int
- targetValue: Optional[int] = Field(default=None)
- metricName: Optional[str] = Field(default=None)
- duration: Optional[int] = Field(default=None)
- cooldown: Optional[int] = Field(default=None)
+ targetValue: int | None = Field(default=None)
+ metricName: str | None = Field(default=None)
+ duration: int | None = Field(default=None)
+ cooldown: int | None = Field(default=None)
class AutoScalingConfigUpdate(BaseModel):
"""Updatable auto-scaling configuration for hosted MCP servers (all fields optional)."""
- minCapacity: Optional[int] = Field(default=None)
- maxCapacity: Optional[int] = Field(default=None)
- targetValue: Optional[int] = Field(default=None)
- metricName: Optional[str] = Field(default=None)
- duration: Optional[int] = Field(default=None)
- cooldown: Optional[int] = Field(default=None)
+ minCapacity: int | None = Field(default=None)
+ maxCapacity: int | None = Field(default=None)
+ targetValue: int | None = Field(default=None)
+ metricName: str | None = Field(default=None)
+ duration: int | None = Field(default=None)
+ cooldown: int | None = Field(default=None)
class HostedMcpServerModel(BaseModel):
@@ -134,10 +133,10 @@ class HostedMcpServerModel(BaseModel):
"""
# Unique identifier for the mcp server
- id: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()))
+ id: str | None = Field(default_factory=lambda: str(uuid.uuid4()))
# Timestamp of when the mcp server was created
- created: Optional[str] = Field(default_factory=iso_string)
+ created: str | None = Field(default_factory=iso_string)
# Owner of the MCP server
owner: str
@@ -146,13 +145,13 @@ class HostedMcpServerModel(BaseModel):
name: str
# Description of the MCP server
- description: Optional[str] = Field(default_factory=lambda: None)
+ description: str | None = Field(default_factory=lambda: None)
# Command to start the server
startCommand: str
# Port number (optional, used for HTTP/SSE servers)
- port: Optional[int] = Field(default=None)
+ port: int | None = Field(default=None)
# Server type: 'stdio', 'http', or 'sse'
serverType: str
@@ -160,56 +159,56 @@ class HostedMcpServerModel(BaseModel):
# Container image (optional)
# If provided without s3Path: use as pre-built container image
# If provided with s3Path: use as base image for building from S3 artifacts
- image: Optional[str] = Field(default=None)
+ image: str | None = Field(default=None)
# S3 path to server artifacts (binaries, Python files, etc.)
# If provided with image: image is used as base image for building
# If provided without image: default base image is used
- s3Path: Optional[str] = Field(default=None)
+ s3Path: str | None = Field(default=None)
# Auto-scaling configuration
autoScalingConfig: AutoScalingConfig
# Load balancer configuration (optional, will use defaults if not provided)
- loadBalancerConfig: Optional[LoadBalancerConfig] = Field(default=None)
+ loadBalancerConfig: LoadBalancerConfig | None = Field(default=None)
# Container health check configuration (optional, will use defaults if not provided)
- containerHealthCheckConfig: Optional[ContainerHealthCheckConfig] = Field(default=None)
+ containerHealthCheckConfig: ContainerHealthCheckConfig | None = Field(default=None)
# Environment variables for the container
- environment: Optional[Dict[str, str]] = Field(default_factory=lambda: None)
+ environment: dict[str, str] | None = Field(default_factory=lambda: None)
# IAM role ARN for task execution (optional, will be auto-created if not provided)
- taskExecutionRoleArn: Optional[str] = Field(default=None)
+ taskExecutionRoleArn: str | None = Field(default=None)
# IAM role ARN for running tasks (optional, will be auto-created if not provided)
- taskRoleArn: Optional[str] = Field(default=None)
+ taskRoleArn: str | None = Field(default=None)
# Fargate CPU units (defaults to 256 which equals 0.25 vCPU)
- cpu: Optional[int] = Field(default=256)
+ cpu: int | None = Field(default=256)
# Fargate memory limit in MiB (defaults to 512 MiB)
- memoryLimitMiB: Optional[int] = Field(default=512)
+ memoryLimitMiB: int | None = Field(default=512)
# Groups of the MCP server (for authorization)
- groups: Optional[List[str]] = Field(default_factory=lambda: None)
+ groups: list[str] | None = Field(default_factory=lambda: None)
# Status of the server
- status: Optional[HostedMcpServerStatus] = Field(default=HostedMcpServerStatus.CREATING)
+ status: HostedMcpServerStatus | None = Field(default=HostedMcpServerStatus.CREATING)
class UpdateHostedMcpServerRequest(BaseModel):
"""Specifies parameters for hosted MCP server update requests."""
- enabled: Optional[bool] = None
- autoScalingConfig: Optional[AutoScalingConfigUpdate] = None
- environment: Optional[Dict[str, str]] = None
- containerHealthCheckConfig: Optional[ContainerHealthCheckConfig] = None
- loadBalancerConfig: Optional[LoadBalancerConfig] = None
- cpu: Optional[int] = None
- memoryLimitMiB: Optional[int] = None
- description: Optional[str] = None
- groups: Optional[List[str]] = None
+ enabled: bool | None = None
+ autoScalingConfig: AutoScalingConfigUpdate | None = None
+ environment: dict[str, str] | None = None
+ containerHealthCheckConfig: ContainerHealthCheckConfig | None = None
+ loadBalancerConfig: LoadBalancerConfig | None = None
+ cpu: int | None = None
+ memoryLimitMiB: int | None = None
+ description: str | None = None
+ groups: list[str] | None = None
@model_validator(mode="after")
def validate_update_request(self) -> Self:
@@ -235,7 +234,7 @@ def validate_update_request(self) -> Self:
@field_validator("autoScalingConfig")
@classmethod
- def validate_autoscaling_config(cls, config: Optional[AutoScalingConfig]) -> Optional[AutoScalingConfig]:
+ def validate_autoscaling_config(cls, config: AutoScalingConfig | None) -> AutoScalingConfig | None:
"""Validates auto-scaling configuration."""
if config is not None and not config:
raise ValueError("The autoScalingConfig must not be null if defined in request payload.")
@@ -244,8 +243,8 @@ def validate_autoscaling_config(cls, config: Optional[AutoScalingConfig]) -> Opt
@field_validator("containerHealthCheckConfig")
@classmethod
def validate_container_health_check_config(
- cls, config: Optional[ContainerHealthCheckConfig]
- ) -> Optional[ContainerHealthCheckConfig]:
+ cls, config: ContainerHealthCheckConfig | None
+ ) -> ContainerHealthCheckConfig | None:
"""Validates container health check configuration."""
if config is not None and not config:
raise ValueError("The containerHealthCheckConfig must not be null if defined in request payload.")
@@ -253,7 +252,7 @@ def validate_container_health_check_config(
@field_validator("loadBalancerConfig")
@classmethod
- def validate_load_balancer_config(cls, config: Optional[LoadBalancerConfig]) -> Optional[LoadBalancerConfig]:
+ def validate_load_balancer_config(cls, config: LoadBalancerConfig | None) -> LoadBalancerConfig | None:
"""Validates load balancer configuration."""
if config is not None and not config:
raise ValueError("The loadBalancerConfig must not be null if defined in request payload.")
@@ -261,7 +260,7 @@ def validate_load_balancer_config(cls, config: Optional[LoadBalancerConfig]) ->
@field_validator("cpu")
@classmethod
- def validate_cpu(cls, cpu: Optional[int]) -> Optional[int]:
+ def validate_cpu(cls, cpu: int | None) -> int | None:
"""Validates CPU units."""
if cpu is not None:
# Fargate CPU must be in valid units: 256, 512, 1024, 2048, 4096
@@ -272,7 +271,7 @@ def validate_cpu(cls, cpu: Optional[int]) -> Optional[int]:
@field_validator("memoryLimitMiB")
@classmethod
- def validate_memory(cls, memory: Optional[int]) -> Optional[int]:
+ def validate_memory(cls, memory: int | None) -> int | None:
"""Validates memory limit."""
if memory is not None:
if memory < 512:
diff --git a/lambda/mcp_server/state_machine/create_mcp_server.py b/lambda/mcp_server/state_machine/create_mcp_server.py
index 0eb5a1cf7..18bebcd69 100644
--- a/lambda/mcp_server/state_machine/create_mcp_server.py
+++ b/lambda/mcp_server/state_machine/create_mcp_server.py
@@ -19,12 +19,12 @@
import os
import re
from copy import deepcopy
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
from botocore.config import Config
from mcp_server.models import HostedMcpServerModel, HostedMcpServerStatus, McpServerStatus
-from utilities.time import now
+from utilities.time import iso_string, now
logger = logging.getLogger()
logger.setLevel(logging.INFO)
@@ -39,7 +39,7 @@
MAX_POLLS = 60
-def handle_set_server_to_creating(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_set_server_to_creating(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Set DDB entry to CREATING status."""
logger.info(f"Setting MCP server to CREATING status: {event.get('id')}")
output_dict = deepcopy(event)
@@ -63,7 +63,7 @@ def handle_set_server_to_creating(event: Dict[str, Any], context: Any) -> Dict[s
return output_dict
-def handle_deploy_server(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_deploy_server(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Invoke MCP server deployer to create infrastructure."""
logger.info(f"Deploying MCP server: {event.get('id')}")
output_dict = deepcopy(event)
@@ -125,7 +125,7 @@ def handle_deploy_server(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_poll_deployment(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_deployment(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Poll CloudFormation stack status."""
logger.info(f"Polling deployment status for stack: {event.get('stack_name')}")
output_dict = deepcopy(event)
@@ -174,11 +174,11 @@ def handle_poll_deployment(event: Dict[str, Any], context: Any) -> Dict[str, Any
return output_dict
-def _get_mcp_connections_table_name(deployment_prefix: str) -> Optional[str]:
+def _get_mcp_connections_table_name(deployment_prefix: str) -> str | None:
"""Get MCP connections table name from SSM parameter if chat is deployed."""
try:
response = ssmClient.get_parameter(Name=f"{deployment_prefix}/table/mcpServersTable")
- return response["Parameter"]["Value"]
+ return response["Parameter"]["Value"] # type: ignore[no-any-return]
except ssmClient.exceptions.ParameterNotFound:
logger.info("MCP connections table SSM parameter not found, chat may not be deployed")
return None
@@ -187,11 +187,11 @@ def _get_mcp_connections_table_name(deployment_prefix: str) -> Optional[str]:
return None
-def _get_api_gateway_url(deployment_prefix: str) -> Optional[str]:
+def _get_api_gateway_url(deployment_prefix: str) -> str | None:
"""Get API Gateway base URL from SSM parameter."""
try:
response = ssmClient.get_parameter(Name=f"{deployment_prefix}/LisaApiUrl")
- return response["Parameter"]["Value"]
+ return response["Parameter"]["Value"] # type: ignore[no-any-return]
except Exception as e:
logger.warning(f"Error getting API Gateway URL: {str(e)}")
return None
@@ -202,7 +202,7 @@ def _normalize_server_identifier(server_id: str) -> str:
return re.sub(r"[^a-zA-Z0-9]", "", server_id)
-def handle_add_server_to_active(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_add_server_to_active(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Set server status to IN_SERVICE after successful deployment."""
logger.info(f"Setting MCP server to IN_SERVICE: {event.get('id')}")
output_dict = deepcopy(event)
@@ -233,7 +233,7 @@ def handle_add_server_to_active(event: Dict[str, Any], context: Any) -> Dict[str
if mcp_connections_table_name:
try:
api_gateway_url = _get_api_gateway_url(deployment_prefix)
- if api_gateway_url:
+ if api_gateway_url and name:
# Normalize server ID to match what CDK uses for resource naming
normalized_id = _normalize_server_identifier(name)
# Construct API Gateway URL for the hosted server
@@ -254,7 +254,7 @@ def handle_add_server_to_active(event: Dict[str, Any], context: Any) -> Dict[str
"owner": owner,
"url": server_url,
"name": name,
- "created": now(),
+ "created": iso_string(),
"customHeaders": {"Authorization": "Bearer {LISA_BEARER_TOKEN}"},
"status": McpServerStatus.ACTIVE,
}
@@ -281,7 +281,7 @@ def handle_add_server_to_active(event: Dict[str, Any], context: Any) -> Dict[str
return output_dict
-def handle_failure(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_failure(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Handle failure in the state machine."""
logger.error(f"Handling MCP server creation failure: {event}")
diff --git a/lambda/mcp_server/state_machine/delete_mcp_server.py b/lambda/mcp_server/state_machine/delete_mcp_server.py
index 4ac074173..3cfdd0a99 100644
--- a/lambda/mcp_server/state_machine/delete_mcp_server.py
+++ b/lambda/mcp_server/state_machine/delete_mcp_server.py
@@ -17,7 +17,7 @@
import logging
import os
from copy import deepcopy
-from typing import Any, Dict, Optional
+from typing import Any
from uuid import uuid4
import boto3
@@ -41,11 +41,11 @@
STACK_ARN = "cloudformation_stack_arn"
-def _get_mcp_connections_table_name(deployment_prefix: str) -> Optional[str]:
+def _get_mcp_connections_table_name(deployment_prefix: str) -> str | None:
"""Get MCP connections table name from SSM parameter if chat is deployed."""
try:
response = ssmClient.get_parameter(Name=f"{deployment_prefix}/table/mcpServersTable")
- return response["Parameter"]["Value"]
+ return response["Parameter"]["Value"] # type: ignore[no-any-return]
except ssmClient.exceptions.ParameterNotFound:
logger.info("MCP connections table SSM parameter not found, chat may not be deployed")
return None
@@ -54,7 +54,7 @@ def _get_mcp_connections_table_name(deployment_prefix: str) -> Optional[str]:
return None
-def handle_set_server_to_deleting(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_set_server_to_deleting(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Start deletion workflow based on user-specified server input."""
output_dict = deepcopy(event)
server_id = event["id"]
@@ -88,7 +88,7 @@ def handle_set_server_to_deleting(event: Dict[str, Any], context: Any) -> Dict[s
return output_dict
-def handle_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_delete_stack(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Initialize stack deletion."""
output_dict = deepcopy(event)
stack_arn = event.get(STACK_ARN)
@@ -106,7 +106,7 @@ def handle_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_monitor_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_monitor_delete_stack(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Get stack status while it is being deleted and evaluate if state machine should continue polling."""
output_dict = deepcopy(event)
# Prefer ARN if available, fall back to stack name
@@ -144,7 +144,7 @@ def handle_monitor_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str
return output_dict
-def handle_delete_from_ddb(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_delete_from_ddb(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Delete item from DDB after successful deletion workflow and remove from connections table."""
server_id = event["id"]
server_key = {"id": server_id}
diff --git a/lambda/mcp_server/state_machine/update_mcp_server.py b/lambda/mcp_server/state_machine/update_mcp_server.py
index 6c77213ab..11a60461f 100644
--- a/lambda/mcp_server/state_machine/update_mcp_server.py
+++ b/lambda/mcp_server/state_machine/update_mcp_server.py
@@ -17,8 +17,9 @@
import logging
import os
import re
+from collections.abc import Callable
from copy import deepcopy
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any
import boto3
from boto3.dynamodb.conditions import Attr
@@ -42,11 +43,11 @@
MAX_POLLS = 30
-def _get_mcp_connections_table_name(deployment_prefix: str) -> Optional[str]:
+def _get_mcp_connections_table_name(deployment_prefix: str) -> str | None:
"""Get MCP connections table name from SSM parameter if chat is deployed."""
try:
response = ssm_client.get_parameter(Name=f"{deployment_prefix}/table/mcpServersTable")
- return response["Parameter"]["Value"]
+ return response["Parameter"]["Value"] # type: ignore[no-any-return]
except ssm_client.exceptions.ParameterNotFound:
logger.info("MCP connections table SSM parameter not found, chat may not be deployed")
return None
@@ -60,15 +61,15 @@ def _normalize_server_identifier(server_id: str) -> str:
return re.sub(r"[^a-zA-Z0-9]", "", server_id)
-def _update_simple_field(server_config: Dict[str, Any], field_name: str, value: Any, server_id: str) -> None:
+def _update_simple_field(server_config: dict[str, Any], field_name: str, value: Any, server_id: str) -> None:
"""Update a simple field in server_config."""
logger.info(f"Setting {field_name} to '{value}' for server '{server_id}'")
server_config[field_name] = value
def _update_container_config(
- server_config: Dict[str, Any], container_config: Dict[str, Any], server_id: str
-) -> Dict[str, Any]:
+ server_config: dict[str, Any], container_config: dict[str, Any], server_id: str
+) -> dict[str, Any]:
"""Handle container config update.
Returns:
@@ -137,7 +138,7 @@ def _update_container_config(
return container_metadata
-def _get_metadata_update_handlers(server_config: Dict[str, Any], server_id: str) -> Dict[str, Callable[..., Any]]:
+def _get_metadata_update_handlers(server_config: dict[str, Any], server_id: str) -> dict[str, Callable[..., Any]]:
"""Return a dictionary mapping field names to their update handlers."""
return {
"description": lambda value: _update_simple_field(server_config, "description", value, server_id),
@@ -155,8 +156,8 @@ def _get_metadata_update_handlers(server_config: Dict[str, Any], server_id: str)
def _process_metadata_updates(
- server_config: Dict[str, Any], update_payload: Dict[str, Any], server_id: str
-) -> tuple[bool, Dict[str, Any]]:
+ server_config: dict[str, Any], update_payload: dict[str, Any], server_id: str
+) -> tuple[bool, dict[str, Any]]:
"""
Process metadata updates.
@@ -234,7 +235,7 @@ def _update_mcp_connections_table_status(server_id: str, status: str) -> None:
def _update_mcp_connections_table_metadata(
- server_id: str, description: Optional[str] = None, groups: Optional[List[str]] = None
+ server_id: str, description: str | None = None, groups: list[str] | None = None
) -> None:
"""Update MCP Connections table metadata (description, groups) for a server."""
deployment_prefix = os.environ.get("DEPLOYMENT_PREFIX", "")
@@ -252,7 +253,7 @@ def _update_mcp_connections_table_metadata(
return
# Format groups with "group:" prefix if not already present
- formatted_groups: Optional[List[str]] = None
+ formatted_groups: list[str] | None = None
if groups is not None:
formatted_groups = []
for group in groups:
@@ -268,8 +269,8 @@ def _update_mcp_connections_table_metadata(
for item in response.get("Items", []):
update_expression_parts = []
- expr_attr_names: Dict[str, str] = {}
- expr_attr_values: Dict[str, Any] = {}
+ expr_attr_names: dict[str, str] = {}
+ expr_attr_values: dict[str, Any] = {}
if description is not None:
update_expression_parts.append("#d = :desc")
@@ -299,7 +300,7 @@ def _update_mcp_connections_table_metadata(
# Don't fail the update if connection table update fails
-def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_job_intake(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Handle initial UpdateMcpServer job submission.
@@ -484,12 +485,12 @@ def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
# Use updated values if provided, otherwise use current values from server_config
if updated_min_capacity is not None:
- update_params["MinCapacity"] = updated_min_capacity
+ update_params["MinCapacity"] = updated_min_capacity # type: ignore[assignment]
else:
update_params["MinCapacity"] = server_config["autoScalingConfig"].get("minCapacity", 1)
if updated_max_capacity is not None:
- update_params["MaxCapacity"] = updated_max_capacity
+ update_params["MaxCapacity"] = updated_max_capacity # type: ignore[assignment]
else:
update_params["MaxCapacity"] = server_config["autoScalingConfig"].get("maxCapacity", 1)
@@ -615,11 +616,11 @@ def get_ecs_resources_from_stack(stack_name: str) -> tuple[str, str, str]:
def create_updated_task_definition(
task_definition_arn: str,
- updated_env_vars: Optional[Dict[str, str]] = None,
- env_vars_to_delete: Optional[List[str]] = None,
- updated_cpu: Optional[int] = None,
- updated_memory: Optional[int] = None,
- updated_health_check: Optional[Dict[str, Any]] = None,
+ updated_env_vars: dict[str, str] | None = None,
+ env_vars_to_delete: list[str] | None = None,
+ updated_cpu: int | None = None,
+ updated_memory: int | None = None,
+ updated_health_check: dict[str, Any] | None = None,
) -> str:
"""Create new task definition revision with updated configuration.
@@ -741,7 +742,7 @@ def update_ecs_service(cluster_arn: str, service_arn: str, task_definition_arn:
raise RuntimeError(f"Failed to update ECS service: {str(e)}")
-def handle_ecs_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_ecs_update(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Update ECS task definition with new environment variables and update service.
@@ -807,7 +808,7 @@ def handle_ecs_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_poll_ecs_deployment(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_ecs_deployment(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Monitor ECS service deployment progress.
@@ -901,7 +902,7 @@ def handle_poll_ecs_deployment(event: Dict[str, Any], context: Any) -> Dict[str,
return output_dict
-def handle_poll_capacity(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_capacity(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Poll ECS service to confirm if the capacity is done updating.
@@ -948,7 +949,7 @@ def handle_poll_capacity(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_finish_update(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Finalize update in DDB.
@@ -964,7 +965,7 @@ def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
stack_name = event["stack_name"]
ddb_update_expression = "SET #status = :ms, last_modified = :lm"
- ddb_update_values: Dict[str, Any] = {
+ ddb_update_values: dict[str, Any] = {
":lm": now(),
}
ExpressionAttributeNames = {"#status": "status"}
diff --git a/lambda/mcp_workbench/lambda_functions.py b/lambda/mcp_workbench/lambda_functions.py
index 1efca3966..4d6ebf3ad 100644
--- a/lambda/mcp_workbench/lambda_functions.py
+++ b/lambda/mcp_workbench/lambda_functions.py
@@ -18,7 +18,7 @@
import os
import uuid
from decimal import Decimal
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
import botocore.exceptions
@@ -49,7 +49,7 @@ class MCPToolModel(BaseModel):
contents: str
# Timestamp of when the tool was created/updated
- updated_at: Optional[str] = Field(default_factory=iso_string)
+ updated_at: str | None = Field(default_factory=iso_string)
@property
def s3_key(self) -> str:
@@ -113,7 +113,7 @@ def read(event: dict, context: dict) -> Any:
@api_wrapper
-def list(event: dict, context: dict) -> Dict[str, Any]:
+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.")
@@ -227,7 +227,7 @@ def update(event: dict, context: dict) -> Any:
@api_wrapper
-def delete(event: dict, context: dict) -> Dict[str, str]:
+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.")
@@ -260,7 +260,7 @@ def delete(event: dict, context: dict) -> Dict[str, str]:
@api_wrapper
-def validate_syntax(event: dict, context: dict) -> Dict[str, Any]:
+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.")
diff --git a/lambda/mcp_workbench/mcp_mocks.py b/lambda/mcp_workbench/mcp_mocks.py
index a500d5756..2f0f040b2 100644
--- a/lambda/mcp_workbench/mcp_mocks.py
+++ b/lambda/mcp_workbench/mcp_mocks.py
@@ -21,8 +21,9 @@
"""
from abc import ABC, abstractmethod
+from collections.abc import Callable
from functools import wraps
-from typing import Any, Callable
+from typing import Any
class BaseTool(ABC):
diff --git a/lambda/mcp_workbench/s3_event_handler.py b/lambda/mcp_workbench/s3_event_handler.py
index 3be8a5dc6..f72a2f189 100644
--- a/lambda/mcp_workbench/s3_event_handler.py
+++ b/lambda/mcp_workbench/s3_event_handler.py
@@ -17,7 +17,7 @@
import json
import logging
import os
-from typing import Any, Dict
+from typing import Any
import boto3
from botocore.exceptions import ClientError
@@ -32,7 +32,7 @@
ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], config=retry_config)
-def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Handle S3 events from EventBridge and trigger MCP Workbench service redeployment.
@@ -144,7 +144,7 @@ def get_service_name() -> str:
raise
-def force_service_deployment(cluster_name: str, service_name: str) -> Dict[str, Any]:
+def force_service_deployment(cluster_name: str, service_name: str) -> dict[str, Any]:
"""
Force a new deployment of the specified ECS service.
"""
@@ -155,7 +155,7 @@ def force_service_deployment(cluster_name: str, service_name: str) -> Dict[str,
response = ecs_client.update_service(cluster=cluster_name, service=service_name, forceNewDeployment=True)
logger.info(f"Successfully triggered new deployment for service '{service_name}'")
- return response
+ return dict(response) # Convert to dict to satisfy return type
except ClientError as e:
error_code = e.response.get("Error", {}).get("Code", "Unknown")
@@ -174,7 +174,7 @@ def force_service_deployment(cluster_name: str, service_name: str) -> Dict[str,
raise
-def validate_s3_event(event: Dict[str, Any]) -> bool:
+def validate_s3_event(event: dict[str, Any]) -> bool:
"""
Validate that the event is a proper S3 event from EventBridge.
"""
diff --git a/lambda/mcp_workbench/syntax_validator.py b/lambda/mcp_workbench/syntax_validator.py
index 46fd5362e..7ee227844 100644
--- a/lambda/mcp_workbench/syntax_validator.py
+++ b/lambda/mcp_workbench/syntax_validator.py
@@ -20,7 +20,7 @@
import sys
from dataclasses import dataclass
from types import ModuleType
-from typing import Any, Dict, List, Optional
+from typing import Any
logger = logging.getLogger(__name__)
@@ -30,8 +30,8 @@ class ValidationResult:
"""Result of Python code validation."""
is_valid: bool
- syntax_errors: List[Dict[str, Any]]
- missing_required_imports: Optional[List[str]] = None
+ syntax_errors: list[dict[str, Any]]
+ missing_required_imports: list[str] | None = None
def __post_init__(self) -> None:
"""Initialize list fields if None."""
@@ -118,7 +118,7 @@ def validate_code(self, code: str) -> ValidationResult:
is_valid=is_valid, syntax_errors=syntax_errors, missing_required_imports=missing_required_imports
)
- def _validate_module_execution(self, code: str) -> List[Dict[str, Any]]:
+ def _validate_module_execution(self, code: str) -> list[dict[str, Any]]:
"""Validate code by attempting to execute it as a module."""
errors = []
@@ -222,7 +222,7 @@ def _setup_mcp_environment(self, module: Any) -> None:
else:
logger.info("Real MCP Workbench package is already available in sys.modules")
- def _check_required_mcp_imports(self, tree: ast.AST) -> List[str]:
+ def _check_required_mcp_imports(self, tree: ast.AST) -> list[str]:
"""Check if required MCP imports are present in the AST."""
missing_required = []
@@ -248,9 +248,9 @@ def _check_required_mcp_imports(self, tree: ast.AST) -> List[str]:
return missing_required
- def _collect_imports(self, tree: ast.AST) -> Dict[str, Any]:
+ def _collect_imports(self, tree: ast.AST) -> dict[str, Any]:
"""Collect all import statements from the AST."""
- imports: Dict[str, Any] = {
+ imports: dict[str, Any] = {
"modules": set(), # Direct module imports: import os
"from_imports": {}, # From imports: from os import path -> {'os': {'path'}}
"aliases": {}, # Import aliases: import numpy as np -> {'np': 'numpy'}
@@ -290,7 +290,7 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
visitor.visit(tree)
return imports
- def _format_syntax_error(self, syntax_error: SyntaxError) -> Dict[str, Any]:
+ def _format_syntax_error(self, syntax_error: SyntaxError) -> dict[str, Any]:
"""Format a SyntaxError into a standardized error dictionary."""
return {
"type": "SyntaxError",
diff --git a/lambda/metrics/lambda_functions.py b/lambda/metrics/lambda_functions.py
index 03efb3e69..5d21e1bb4 100644
--- a/lambda/metrics/lambda_functions.py
+++ b/lambda/metrics/lambda_functions.py
@@ -16,7 +16,7 @@
import json
import logging
import os
-from typing import Any, Dict, List
+from typing import Any
import boto3
import create_env_variables # noqa: F401
@@ -75,14 +75,14 @@ def get_user_metrics_all(event: dict, context: dict) -> dict:
total_mcp_tool_calls = sum(item.get("mcpToolCallsCount", 0) for item in items)
# Collect all unique user groups
- all_user_groups: Dict[str, int] = {}
+ all_user_groups: dict[str, int] = {}
for item in items:
if item.get("userGroups"):
for group in item["userGroups"]:
all_user_groups[group] = all_user_groups.get(group, 0) + 1
# Collect all MCP tool usage across users
- all_mcp_tool_usage: Dict[str, int] = {}
+ all_mcp_tool_usage: dict[str, int] = {}
for item in items:
if item.get("mcpToolUsage"):
for tool_name, count in item["mcpToolUsage"].items():
@@ -126,14 +126,14 @@ def count_unique_users_and_publish_metric() -> Any:
raise
-def count_users_by_group_and_publish_metric() -> Dict[str, int]:
+def count_users_by_group_and_publish_metric() -> dict[str, int]:
"""Count users in each group and publish metrics to CloudWatch."""
try:
# Scan the table to get users with groups
response = usage_metrics_table.scan(ProjectionExpression="userGroups")
# Count users in each group
- group_counts: Dict[str, int] = {}
+ group_counts: dict[str, int] = {}
for item in response.get("Items", []):
if "userGroups" in item:
for group in item["userGroups"]:
@@ -234,7 +234,7 @@ def process_metrics_sqs_event(event: dict, context: dict) -> None:
logger.error(f"Error processing SQS message: {str(e)}")
-def count_rag_usage(messages: List[Dict[str, Any]]) -> int:
+def count_rag_usage(messages: list[dict[str, Any]]) -> int:
"""Count occurrences of 'File context:' in all human messages to determine RAG usage.
Parameters:
@@ -290,7 +290,7 @@ def count_rag_usage(messages: List[Dict[str, Any]]) -> int:
return file_context_count
-def calculate_session_metrics(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
+def calculate_session_metrics(messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Calculate metrics for a complete session.
Parameters:
@@ -307,7 +307,7 @@ def calculate_session_metrics(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
return {"totalPrompts": 0, "ragUsage": 0, "mcpToolCallsCount": 0, "mcpToolUsage": {}}
total_prompts = 0
- mcp_tool_usage: Dict[str, int] = {}
+ mcp_tool_usage: dict[str, int] = {}
# Count human messages for total prompts
for message in messages:
@@ -356,8 +356,8 @@ def publish_metric_deltas(
delta_prompts: int,
delta_rag: int,
delta_mcp_calls: int,
- delta_mcp_usage: Dict[str, int],
- user_groups: List[str],
+ delta_mcp_usage: dict[str, int],
+ user_groups: list[str],
) -> None:
"""Publish only metric deltas to CloudWatch to prevent double counting.
@@ -486,7 +486,7 @@ def publish_metric_deltas(
def update_user_metrics_by_session(
- user_id: str, session_id: str, session_metrics: Dict[str, Any], user_groups: List[str]
+ user_id: str, session_id: str, session_metrics: dict[str, Any], user_groups: list[str]
) -> None:
"""Update usage metrics for a given user based on session-level metrics.
@@ -562,7 +562,7 @@ def update_user_metrics_by_session(
total_mcp_calls = sum(sm.get("mcpToolCallsCount", 0) for sm in all_session_metrics.values())
# Aggregate MCP tool usage across all sessions
- aggregate_mcp_usage: Dict[str, int] = {}
+ aggregate_mcp_usage: dict[str, int] = {}
for sm in all_session_metrics.values():
for tool_name, count in sm.get("mcpToolUsage", {}).items():
aggregate_mcp_usage[tool_name] = aggregate_mcp_usage.get(tool_name, 0) + count
diff --git a/lambda/metrics/models.py b/lambda/metrics/models.py
new file mode 100644
index 000000000..6aa6eb756
--- /dev/null
+++ b/lambda/metrics/models.py
@@ -0,0 +1,29 @@
+# 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.
+
+"""Pydantic models for metrics events."""
+
+from typing import Any
+
+from pydantic import BaseModel
+
+
+class MetricsEvent(BaseModel):
+ """Event model for usage metrics published to SQS."""
+
+ userId: str
+ sessionId: str
+ messages: list[dict[str, Any]]
+ userGroups: list[str]
+ timestamp: str
diff --git a/lambda/models/clients/litellm_client.py b/lambda/models/clients/litellm_client.py
index 11fc6546c..07afea04a 100644
--- a/lambda/models/clients/litellm_client.py
+++ b/lambda/models/clients/litellm_client.py
@@ -14,9 +14,9 @@
"""Client for interfacing with the LiteLLM proxy's management options directly."""
-from typing import Any, Dict, List, Union
+from typing import Any
-import requests
+import requests # type: ignore[import-untyped,unused-ignore]
from starlette.datastructures import Headers
from ..exception import ModelNotFoundError
@@ -25,13 +25,13 @@
class LiteLLMClient:
"""Client definition for interfacing directly with LiteLLM management operations."""
- def __init__(self, base_uri: str, headers: Headers, verify: Union[str, bool], timeout: int = 30):
+ def __init__(self, base_uri: str, headers: Headers, verify: str | bool, timeout: int = 30):
self._base_uri = base_uri
self._headers = headers
self._timeout = timeout
self._verify = verify
- def list_models(self) -> List[Dict[str, Any]]:
+ def list_models(self) -> list[dict[str, Any]]:
"""
Retrieve all models from the database.
@@ -46,10 +46,10 @@ def list_models(self) -> List[Dict[str, Any]]:
verify=self._verify,
)
all_models = resp.json()
- models_list: List[Dict[str, Any]] = all_models["data"]
+ models_list: list[dict[str, Any]] = all_models["data"]
return models_list
- def add_model(self, model_name: str, litellm_params: Dict[str, str]) -> Dict[str, Any]:
+ def add_model(self, model_name: str, litellm_params: dict[str, str]) -> dict[str, Any]:
"""
Add a new model configuration to the database.
@@ -86,7 +86,7 @@ def delete_model(self, identifier: str) -> None:
verify=self._verify,
)
- def get_model(self, identifier: str) -> Dict[str, Any]:
+ def get_model(self, identifier: str) -> dict[str, Any]:
"""
Get model metadata from the database.
@@ -99,7 +99,7 @@ def get_model(self, identifier: str) -> Dict[str, Any]:
raise ModelNotFoundError("Specified model was not found.")
return filtered_models[0]
- def create_guardrail(self, guardrail_config: Dict[str, Any]) -> Dict[str, Any]:
+ def create_guardrail(self, guardrail_config: dict[str, Any]) -> dict[str, Any]:
"""
Create a new guardrail configuration in LiteLLM.
@@ -120,7 +120,7 @@ def create_guardrail(self, guardrail_config: Dict[str, Any]) -> Dict[str, Any]:
resp.raise_for_status()
return resp.json() # type: ignore [no-any-return]
- def update_guardrail(self, guardrail_id: str, guardrail_config: Dict[str, Any]) -> Dict[str, Any]:
+ def update_guardrail(self, guardrail_id: str, guardrail_config: dict[str, Any]) -> dict[str, Any]:
"""
Update an existing guardrail configuration in LiteLLM.
@@ -156,7 +156,7 @@ def delete_guardrail(self, guardrail_id: str) -> None:
)
resp.raise_for_status()
- def get_guardrail_info(self, guardrail_id: str) -> Dict[str, Any]:
+ def get_guardrail_info(self, guardrail_id: str) -> dict[str, Any]:
"""
Get information about a specific guardrail.
@@ -175,7 +175,7 @@ def get_guardrail_info(self, guardrail_id: str) -> Dict[str, Any]:
resp.raise_for_status()
return resp.json() # type: ignore [no-any-return]
- def apply_guardrail(self, guardrail_name: str, text: str) -> Dict[str, Any]:
+ def apply_guardrail(self, guardrail_name: str, text: str) -> dict[str, Any]:
"""
Apply a guardrail to text content for validation.
diff --git a/lambda/models/domain_objects.py b/lambda/models/domain_objects.py
index c8459a7e7..e19145bfd 100644
--- a/lambda/models/domain_objects.py
+++ b/lambda/models/domain_objects.py
@@ -21,17 +21,18 @@
import re
import urllib.parse
import uuid
+from collections.abc import Generator
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import auto, Enum, StrEnum
-from typing import Annotated, Any, Dict, Generator, List, Literal, Optional, TypeAlias, Union
+from typing import Annotated, Any, Literal, Self, TypeAlias, Union
from uuid import uuid4
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveInt
from pydantic.functional_validators import AfterValidator, field_validator, model_validator
-from typing_extensions import Self
from utilities.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, MIN_PAGE_SIZE
+from utilities.healthcheck_validator import validate_healthcheck_command
from utilities.time import now, utc_now
from utilities.validation import (
validate_all_fields_defined,
@@ -69,6 +70,7 @@ class ModelType(StrEnum):
TEXTGEN = auto()
IMAGEGEN = auto()
+ VIDEOGEN = auto()
EMBEDDING = auto()
@@ -87,13 +89,13 @@ class GuardrailConfig(BaseModel):
guardrailIdentifier: str = Field(min_length=1)
guardrailVersion: str = Field(default="DRAFT")
mode: GuardrailMode = Field(default=GuardrailMode.PRE_CALL)
- description: Optional[str] = None
- allowedGroups: List[str] = Field(default_factory=list)
- markedForDeletion: Optional[bool] = Field(default=False)
+ description: str | None = None
+ allowedGroups: list[str] = Field(default_factory=list)
+ markedForDeletion: bool | None = Field(default=False)
# Type alias for guardrails configuration - maps guardrail IDs to their configs
-GuardrailsConfig: TypeAlias = Dict[str, GuardrailConfig]
+GuardrailsConfig: TypeAlias = dict[str, GuardrailConfig]
class GuardrailRequest(BaseModel):
@@ -121,8 +123,8 @@ class GuardrailsTableEntry(BaseModel):
guardrailIdentifier: str
guardrailVersion: str
mode: str
- description: Optional[str]
- allowedGroups: List[str]
+ description: str | None
+ allowedGroups: list[str]
createdDate: int = Field(default_factory=lambda: now())
lastModifiedDate: int = Field(default_factory=lambda: now())
@@ -213,13 +215,13 @@ def validate_stop_after_start(self) -> Self:
class WeeklySchedule(BaseModel):
"""Defines schedule for each day of the week with one start/stop time per day"""
- monday: Optional[DaySchedule] = None
- tuesday: Optional[DaySchedule] = None
- wednesday: Optional[DaySchedule] = None
- thursday: Optional[DaySchedule] = None
- friday: Optional[DaySchedule] = None
- saturday: Optional[DaySchedule] = None
- sunday: Optional[DaySchedule] = None
+ monday: DaySchedule | None = None
+ tuesday: DaySchedule | None = None
+ wednesday: DaySchedule | None = None
+ thursday: DaySchedule | None = None
+ friday: DaySchedule | None = None
+ saturday: DaySchedule | None = None
+ sunday: DaySchedule | None = None
@model_validator(mode="after")
def validate_daily_schedules(self) -> Self:
@@ -254,18 +256,18 @@ class BaseSchedulingConfig(BaseModel):
# Schedule metadata and tracking
scheduleEnabled: bool = False
- lastScheduleUpdate: Optional[str] = None
- scheduledActionArns: Optional[List[str]] = None
+ lastScheduleUpdate: str | None = None
+ scheduledActionArns: list[str] | None = None
# Status tracking
scheduleConfigured: bool = False
lastScheduleFailed: bool = False
# Next scheduled action info (computed field)
- nextScheduledAction: Optional[NextScheduledAction] = None
+ nextScheduledAction: NextScheduledAction | None = None
# Failure tracking
- lastScheduleFailure: Optional[ScheduleFailure] = None
+ lastScheduleFailure: ScheduleFailure | None = None
@field_validator("timezone")
@classmethod
@@ -317,14 +319,14 @@ def validate_recurring_schedule_exclusivity(self) -> Self:
class AutoScalingConfig(BaseModel):
"""Specifies auto-scaling parameters for model deployment."""
- blockDeviceVolumeSize: Optional[NonNegativeInt] = 50
+ blockDeviceVolumeSize: NonNegativeInt | None = 50
minCapacity: PositiveInt
maxCapacity: PositiveInt
- desiredCapacity: Optional[PositiveInt] = None
+ desiredCapacity: PositiveInt | None = None
cooldown: PositiveInt
defaultInstanceWarmup: PositiveInt
metricConfig: MetricConfig
- scheduling: Optional[SchedulingConfig] = None
+ scheduling: SchedulingConfig | None = None
@model_validator(mode="after")
def validate_auto_scaling_config(self) -> Self:
@@ -343,11 +345,11 @@ def validate_auto_scaling_config(self) -> Self:
class AutoScalingInstanceConfig(BaseModel):
"""Defines instance count parameters for auto-scaling updates."""
- minCapacity: Optional[PositiveInt] = None
- maxCapacity: Optional[PositiveInt] = None
- desiredCapacity: Optional[PositiveInt] = None
- cooldown: Optional[PositiveInt] = None
- defaultInstanceWarmup: Optional[PositiveInt] = None
+ minCapacity: PositiveInt | None = None
+ maxCapacity: PositiveInt | None = None
+ desiredCapacity: PositiveInt | None = None
+ cooldown: PositiveInt | None = None
+ defaultInstanceWarmup: PositiveInt | None = None
@model_validator(mode="after")
def validate_auto_scaling_instance_config(self) -> Self:
@@ -371,12 +373,19 @@ def validate_auto_scaling_instance_config(self) -> Self:
class ContainerHealthCheckConfig(BaseModel):
"""Specifies container health check parameters."""
- command: Union[str, List[str]]
+ command: str | list[str]
interval: PositiveInt
startPeriod: PositiveInt
timeout: PositiveInt
retries: PositiveInt
+ @field_validator("command")
+ @classmethod
+ def validate_command(cls, command: str | list[str]) -> str | list[str]:
+ """Validates healthcheck command format for ECS compatibility."""
+ validate_healthcheck_command(command)
+ return command
+
class ContainerConfigImage(BaseModel):
"""Defines container image configuration."""
@@ -391,14 +400,14 @@ class ContainerConfig(BaseModel):
image: ContainerConfigImage
sharedMemorySize: PositiveInt
healthCheckConfig: ContainerHealthCheckConfig
- environment: Optional[Dict[str, str]] = {}
+ environment: dict[str, str] | None = {}
@field_validator("environment")
@classmethod
- def validate_environment(cls, environment: Dict[str, str]) -> Dict[str, str]:
+ def validate_environment(cls, environment: dict[str, str]) -> dict[str, str]:
"""Validates environment variable key names."""
if environment:
- if not all((key for key in environment.keys())):
+ if not all(key for key in environment.keys()):
raise ValueError("Empty strings are not allowed for environment variable key names.")
return environment
@@ -406,20 +415,20 @@ def validate_environment(cls, environment: Dict[str, str]) -> Dict[str, str]:
class ContainerConfigUpdatable(BaseModel):
"""Specifies container configuration fields that can be updated."""
- environment: Optional[Dict[str, str]] = None
- sharedMemorySize: Optional[PositiveInt] = None
- healthCheckCommand: Optional[Union[str, List[str]]] = None
- healthCheckInterval: Optional[PositiveInt] = None
- healthCheckTimeout: Optional[PositiveInt] = None
- healthCheckStartPeriod: Optional[PositiveInt] = None
- healthCheckRetries: Optional[PositiveInt] = None
+ environment: dict[str, str] | None = None
+ sharedMemorySize: PositiveInt | None = None
+ healthCheckCommand: str | list[str] | None = None
+ healthCheckInterval: PositiveInt | None = None
+ healthCheckTimeout: PositiveInt | None = None
+ healthCheckStartPeriod: PositiveInt | None = None
+ healthCheckRetries: PositiveInt | None = None
@field_validator("environment")
@classmethod
- def validate_environment(cls, environment: Dict[str, str]) -> Dict[str, str]:
+ def validate_environment(cls, environment: dict[str, str]) -> dict[str, str]:
"""Validates environment variable key names."""
if environment:
- if not all((key for key in environment.keys())):
+ if not all(key for key in environment.keys()):
raise ValueError("Empty strings are not allowed for environment variable key names.")
return environment
@@ -427,7 +436,7 @@ def validate_environment(cls, environment: Dict[str, str]) -> Dict[str, str]:
class ModelFeature(BaseModel):
"""Defines model feature attributes."""
- __exceptions: List[Any] = []
+ __exceptions: list[Any] = []
name: str
overview: str
@@ -438,21 +447,21 @@ def __init__(self, **kwargs: Any) -> None:
class LISAModel(BaseModel):
"""Defines core model attributes and configuration."""
- autoScalingConfig: Optional[AutoScalingConfig] = None
- containerConfig: Optional[ContainerConfig] = None
- inferenceContainer: Optional[InferenceContainer] = None
- instanceType: Optional[Annotated[str, AfterValidator(validate_instance_type)]] = None
- loadBalancerConfig: Optional[LoadBalancerConfig] = None
+ autoScalingConfig: AutoScalingConfig | None = None
+ containerConfig: ContainerConfig | None = None
+ inferenceContainer: InferenceContainer | None = None
+ instanceType: Annotated[str, AfterValidator(validate_instance_type)] | None = None
+ loadBalancerConfig: LoadBalancerConfig | None = None
modelId: str
modelName: str
- modelDescription: Optional[str] = None
+ modelDescription: str | None = None
modelType: ModelType
- modelUrl: Optional[str] = None
+ modelUrl: str | None = None
status: ModelStatus
streaming: bool
- features: Optional[List[ModelFeature]] = None
- allowedGroups: Optional[List[str]] = None
- guardrailsConfig: Optional[GuardrailsConfig] = None
+ features: list[ModelFeature] | None = None
+ allowedGroups: list[str] | None = None
+ guardrailsConfig: GuardrailsConfig | None = None
class ApiResponseBase(BaseModel):
@@ -464,21 +473,21 @@ class ApiResponseBase(BaseModel):
class CreateModelRequest(BaseModel):
"""Specifies parameters for model creation requests."""
- autoScalingConfig: Optional[AutoScalingConfig] = None
- containerConfig: Optional[ContainerConfig] = None
- inferenceContainer: Optional[InferenceContainer] = None
- instanceType: Optional[Annotated[str, AfterValidator(validate_instance_type)]] = None
- loadBalancerConfig: Optional[LoadBalancerConfig] = None
+ autoScalingConfig: AutoScalingConfig | None = None
+ containerConfig: ContainerConfig | None = None
+ inferenceContainer: InferenceContainer | None = None
+ instanceType: Annotated[str, AfterValidator(validate_instance_type)] | None = None
+ loadBalancerConfig: LoadBalancerConfig | None = None
modelId: str = Field(min_length=1)
modelName: str = Field(min_length=1)
- modelDescription: Optional[str] = None
+ modelDescription: str | None = None
modelType: ModelType
- modelUrl: Optional[str] = None
- streaming: Optional[bool] = False
- features: Optional[List[ModelFeature]] = None
- allowedGroups: Optional[List[str]] = None
- apiKey: Optional[str] = None
- guardrailsConfig: Optional[GuardrailsConfig] = None
+ modelUrl: str | None = None
+ streaming: bool | None = False
+ features: list[ModelFeature] | None = None
+ allowedGroups: list[str] | None = None
+ apiKey: str | None = None
+ guardrailsConfig: GuardrailsConfig | None = None
@model_validator(mode="after")
def validate_create_model_request(self) -> Self:
@@ -512,7 +521,7 @@ class CreateModelResponse(ApiResponseBase):
class ListModelsResponse(BaseModel):
"""Defines response structure for model listing."""
- models: List[LISAModel]
+ models: list[LISAModel]
class GetModelResponse(ApiResponseBase):
@@ -524,15 +533,15 @@ class GetModelResponse(ApiResponseBase):
class UpdateModelRequest(BaseModel):
"""Specifies parameters for model update requests."""
- autoScalingInstanceConfig: Optional[AutoScalingInstanceConfig] = None
- enabled: Optional[bool] = None
- modelType: Optional[ModelType] = None
- modelDescription: Optional[str] = None
- streaming: Optional[bool] = None
- allowedGroups: Optional[List[str]] = None
- features: Optional[List[ModelFeature]] = None
- containerConfig: Optional[ContainerConfigUpdatable] = None
- guardrailsConfig: Optional[GuardrailsConfig] = None
+ autoScalingInstanceConfig: AutoScalingInstanceConfig | None = None
+ enabled: bool | None = None
+ modelType: ModelType | None = None
+ modelDescription: str | None = None
+ streaming: bool | None = None
+ allowedGroups: list[str] | None = None
+ features: list[ModelFeature] | None = None
+ containerConfig: ContainerConfigUpdatable | None = None
+ guardrailsConfig: GuardrailsConfig | None = None
@model_validator(mode="after")
def validate_update_model_request(self) -> Self:
@@ -600,8 +609,8 @@ class GetScheduleResponse(BaseModel):
"""Response object for getting schedule configuration."""
modelId: str
- scheduling: Dict[str, Any]
- nextScheduledAction: Optional[Dict[str, str]] = None
+ scheduling: dict[str, Any]
+ nextScheduledAction: dict[str, str] | None = None
class DeleteScheduleResponse(BaseModel):
@@ -620,11 +629,11 @@ class GetScheduleStatusResponse(BaseModel):
scheduleConfigured: bool
lastScheduleFailed: bool
scheduleStatus: str
- scheduleType: Optional[str] = None
+ scheduleType: str | None = None
timezone: str
- nextScheduledAction: Optional[Dict[str, str]] = None
- lastScheduleUpdate: Optional[str] = None
- lastScheduleFailure: Optional[Dict[str, Any]] = None
+ nextScheduledAction: dict[str, str] | None = None
+ lastScheduleUpdate: str | None = None
+ lastScheduleFailure: dict[str, Any] | None = None
class IngestionType(StrEnum):
@@ -645,7 +654,7 @@ class JobActionType(StrEnum):
COLLECTION_DELETION = auto()
-RagDocumentDict = Dict[str, Any]
+RagDocumentDict = dict[str, Any]
class ChunkingStrategyType(StrEnum):
@@ -715,9 +724,9 @@ class RagSubDocument(BaseModel):
"""Represents a sub-document entity for DynamoDB storage."""
document_id: str
- subdocs: List[str] = Field(default_factory=lambda: [])
- index: Optional[int] = Field(default=None)
- sk: Optional[str] = None
+ subdocs: list[str] = Field(default_factory=lambda: [])
+ index: int | None = Field(default=None)
+ sk: str | None = None
def __init__(self, **data: Any) -> None:
super().__init__(**data)
@@ -727,18 +736,18 @@ def __init__(self, **data: Any) -> None:
class RagDocument(BaseModel):
"""Represents a RAG document entity for DynamoDB storage."""
- pk: Optional[str] = None
+ pk: str | None = None
document_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
repository_id: str = Field(min_length=3, max_length=20)
collection_id: str
document_name: str
source: str
username: str
- subdocs: List[str] = Field(default_factory=lambda: [], exclude=True)
+ subdocs: list[str] = Field(default_factory=lambda: [], exclude=True)
chunk_strategy: ChunkingStrategy
ingestion_type: IngestionType = Field(default_factory=lambda: IngestionType.MANUAL)
upload_date: int = Field(default_factory=lambda: now())
- chunks: Optional[int] = 0
+ chunks: int | None = 0
model_config = ConfigDict(use_enum_values=True, validate_default=True)
def __init__(self, **data: Any) -> None:
@@ -753,7 +762,7 @@ def createPartitionKey(repository_id: str, collection_id: str) -> str:
"""Generates a partition key from repository and collection IDs."""
return f"{repository_id}#{collection_id}"
- def chunk_doc(self, chunk_size: int = 1000) -> Generator[RagSubDocument, None, None]:
+ def chunk_doc(self, chunk_size: int = 1000) -> Generator[RagSubDocument]:
"""Segments document into smaller sub-documents."""
total_subdocs = len(self.subdocs)
for start_index in range(0, total_subdocs, chunk_size):
@@ -763,16 +772,16 @@ def chunk_doc(self, chunk_size: int = 1000) -> Generator[RagSubDocument, None, N
)
@staticmethod
- def join_docs(documents: List[RagDocumentDict]) -> List[RagDocumentDict]:
+ def join_docs(documents: list[RagDocumentDict]) -> list[RagDocumentDict]:
"""Combines multiple sub-documents into a single document."""
- grouped_docs: dict[str, List[RagDocumentDict]] = {}
+ grouped_docs: dict[str, list[RagDocumentDict]] = {}
for doc in documents:
doc_id = doc.get("document_id", "")
if doc_id not in grouped_docs:
grouped_docs[doc_id] = []
grouped_docs[doc_id].append(doc)
- joined_docs: List[RagDocumentDict] = []
+ joined_docs: list[RagDocumentDict] = []
for docs in grouped_docs.values():
joined_doc = docs[0]
joined_doc["subdocs"] = [sub_doc for doc in docs for sub_doc in (doc.get("subdocs", []) or [])]
@@ -786,29 +795,29 @@ class IngestionJob(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
s3_path: str
- collection_id: Optional[str] = Field(
+ collection_id: str | None = Field(
default=None, description="Collection ID for full deletion, None for default collection deletion"
)
- document_id: Optional[str] = Field(default=None)
+ document_id: str | None = Field(default=None)
repository_id: str
- chunk_strategy: Optional[ChunkingStrategy] = Field(default=None)
- embedding_model: Optional[str] = Field(
+ chunk_strategy: ChunkingStrategy | None = Field(default=None)
+ embedding_model: str | None = Field(
default=None, description="Embedding model name, used as index identifier for default collections"
)
- username: Optional[str] = Field(default=None)
+ username: str | None = Field(default=None)
ingestion_type: IngestionType = Field(
default=IngestionType.MANUAL, description="How the document was ingested (MANUAL, AUTO, or EXISTING)"
)
status: IngestionStatus = IngestionStatus.INGESTION_PENDING
created_date: str = Field(default_factory=lambda: utc_now().isoformat())
- error_message: Optional[str] = Field(default=None)
- document_name: Optional[str] = Field(default=None)
- auto: Optional[bool] = Field(default=None)
- metadata: Optional[dict] = Field(default=None)
- job_type: Optional[JobActionType] = Field(default=None, description="Type of deletion job")
+ error_message: str | None = Field(default=None)
+ document_name: str | None = Field(default=None)
+ auto: bool | None = Field(default=None)
+ metadata: dict | None = Field(default=None)
+ job_type: JobActionType | None = Field(default=None, description="Type of deletion job")
collection_deletion: bool = Field(default=False, description="Indicates this is a collection deletion job")
- s3_paths: Optional[List[str]] = Field(default=None, description="List of S3 paths for batch ingestion operations")
- document_ids: Optional[List[str]] = Field(
+ s3_paths: list[str] | None = Field(default=None, description="List of S3 paths for batch ingestion operations")
+ document_ids: list[str] | None = Field(
default=None, description="List of document IDs from completed batch operations"
)
@@ -843,15 +852,27 @@ def validate_collection_deletion_identifiers(self) -> Self:
class PaginatedResponse(BaseModel):
"""Base class for paginated API responses."""
- lastEvaluatedKey: Optional[Dict[str, str]] = None
+ lastEvaluatedKey: dict[str, str] | None = None
hasNextPage: bool = False
hasPreviousPage: bool = False
+class DeleteResponse(BaseModel):
+ """Generic response model for delete operations."""
+
+ deleted: bool = False
+
+
+class SuccessResponse(BaseModel):
+ """Generic response model for successful operations."""
+
+ message: str
+
+
class ListJobsResponse(PaginatedResponse):
"""Response structure for listing ingestion jobs with pagination."""
- jobs: List[IngestionJob]
+ jobs: list[IngestionJob]
@dataclass
@@ -862,9 +883,7 @@ class PaginationResult:
has_previous_page: bool
@classmethod
- def from_keys(
- cls, original_key: Optional[Dict[str, str]], returned_key: Optional[Dict[str, str]]
- ) -> "PaginationResult":
+ def from_keys(cls, original_key: dict[str, str] | None, returned_key: dict[str, str] | None) -> PaginationResult:
"""Create pagination result from keys."""
return cls(has_next_page=returned_key is not None, has_previous_page=original_key is not None)
@@ -874,18 +893,18 @@ class PaginationParams:
"""Shared pagination parameter handling."""
page_size: int = DEFAULT_PAGE_SIZE
- last_evaluated_key: Optional[Dict[str, str]] = None
+ last_evaluated_key: dict[str, str] | None = None
@staticmethod
def parse_page_size(
- query_params: Dict[str, str], default: int = DEFAULT_PAGE_SIZE, max_size: int = MAX_PAGE_SIZE
+ query_params: dict[str, str], default: int = DEFAULT_PAGE_SIZE, max_size: int = MAX_PAGE_SIZE
) -> int:
"""Parse and validate page size with configurable limits."""
page_size = int(query_params.get("pageSize", str(default)))
return max(MIN_PAGE_SIZE, min(page_size, max_size))
@staticmethod
- def parse_last_evaluated_key(query_params: Dict[str, str], key_fields: List[str]) -> Optional[Dict[str, str]]:
+ def parse_last_evaluated_key(query_params: dict[str, str], key_fields: list[str]) -> dict[str, str] | None:
"""Parse last evaluated key from query parameters.
Args:
@@ -918,7 +937,7 @@ def parse_last_evaluated_key(query_params: Dict[str, str], key_fields: List[str]
return last_evaluated_key if last_evaluated_key else None
@staticmethod
- def parse_last_evaluated_key_v2(query_params: Dict[str, str]) -> Optional[Dict[str, Any]]:
+ def parse_last_evaluated_key_v2(query_params: dict[str, str]) -> dict[str, Any] | None:
"""Parse v2 pagination token from query parameters.
The v2 token format supports scalable pagination with per-repository cursors.
@@ -967,11 +986,11 @@ def parse_last_evaluated_key_v2(query_params: Dict[str, str]) -> Optional[Dict[s
class FilterParams:
"""Shared filtering parameter handling for collections."""
- filter_text: Optional[str] = None
- status_filter: Optional[CollectionStatus] = None
+ filter_text: str | None = None
+ status_filter: CollectionStatus | None = None
@staticmethod
- def from_query_params(query_params: Dict[str, str]) -> FilterParams:
+ def from_query_params(query_params: dict[str, str]) -> FilterParams:
"""Parse filter parameters from query string parameters.
Args:
@@ -999,11 +1018,11 @@ def from_query_params(query_params: Dict[str, str]) -> FilterParams:
class SortParams:
"""Shared sorting parameter handling for collections."""
- sort_by: CollectionSortBy = None # Will be set to default in from_query_params
- sort_order: SortOrder = None # Will be set to default in from_query_params
+ sort_by: CollectionSortBy | None = None # Will be set to default in from_query_params
+ sort_order: SortOrder | None = None # Will be set to default in from_query_params
@staticmethod
- def from_query_params(query_params: Dict[str, str]) -> SortParams:
+ def from_query_params(query_params: dict[str, str]) -> SortParams:
"""Parse sort parameters from query string parameters.
Args:
@@ -1071,24 +1090,24 @@ class PipelineConfig(BaseModel):
"""Defines pipeline configuration for automated document ingestion."""
autoRemove: bool = Field(default=True, description="Automatically remove documents after ingestion")
- chunkOverlap: Optional[int] = Field(
+ chunkOverlap: int | None = Field(
default=None, ge=0, description="Chunk overlap for pipeline ingestion (deprecated, use chunkingStrategy)"
)
- chunkSize: Optional[int] = Field(
+ chunkSize: int | None = Field(
default=None,
ge=0,
description="Chunk size for pipeline ingestion (deprecated, use chunkingStrategy)",
)
- chunkingStrategy: Optional[ChunkingStrategy] = Field(
+ chunkingStrategy: ChunkingStrategy | None = Field(
default=None, description="Chunking strategy for documents in this pipeline"
)
- collectionId: Optional[str] = Field(
+ collectionId: str | None = Field(
default=None, description="Collection ID for this pipeline (for Bedrock KB, this is the data source ID)"
)
s3Bucket: str = Field(min_length=1, description="S3 bucket for pipeline source")
s3Prefix: str = Field(description="S3 prefix for pipeline source")
trigger: PipelineTrigger = Field(description="Pipeline trigger type")
- metadata: Optional[CollectionMetadata] = Field(
+ metadata: CollectionMetadata | None = Field(
default_factory=lambda: CollectionMetadata(tags=[]), description="Metadata for the pipeline including tags"
)
@@ -1110,6 +1129,9 @@ def validate_chunking_config(self) -> Self:
# If legacy fields provided but no chunkingStrategy, create one
if has_legacy and not has_new:
+ # At this point we know both are not None due to has_legacy check
+ if self.chunkSize is None or self.chunkOverlap is None:
+ raise ValueError("chunkSize and chunkOverlap must both be set")
self.chunkingStrategy = FixedChunkingStrategy(
type=ChunkingStrategyType.FIXED, size=self.chunkSize, overlap=self.chunkOverlap
)
@@ -1120,12 +1142,12 @@ def validate_chunking_config(self) -> Self:
class CollectionMetadata(BaseModel):
"""Defines metadata for a collection."""
- tags: List[str] = Field(default_factory=list, max_length=50, description="Metadata tags for the collection")
- customFields: Dict[str, Any] = Field(default_factory=dict, description="Custom metadata fields")
+ tags: list[str] = Field(default_factory=list, max_length=50, description="Metadata tags for the collection")
+ customFields: dict[str, Any] = Field(default_factory=dict, description="Custom metadata fields")
@field_validator("tags")
@classmethod
- def validate_tags(cls, tags: List[str]) -> List[str]:
+ def validate_tags(cls, tags: list[str]) -> list[str]:
"""Validates metadata tags."""
tag_pattern = re.compile(r"^[a-zA-Z0-9_-]+$")
for tag in tags:
@@ -1139,7 +1161,7 @@ def validate_tags(cls, tags: List[str]) -> List[str]:
return tags
@classmethod
- def merge(cls, parent: Optional[CollectionMetadata], child: Optional[CollectionMetadata]) -> CollectionMetadata:
+ def merge(cls, parent: CollectionMetadata | None, child: CollectionMetadata | None) -> CollectionMetadata:
"""Merges parent and child metadata.
Args:
@@ -1170,17 +1192,17 @@ class RagCollectionConfig(BaseModel):
collectionId: str = Field(default_factory=lambda: str(uuid4()), description="Unique collection identifier")
repositoryId: str = Field(min_length=1, description="Parent repository ID this collection belongs to")
- name: Optional[str] = Field(default=None, max_length=100, description="User-friendly collection name")
- description: Optional[str] = Field(default=None, description="Collection description")
- chunkingStrategy: Optional[ChunkingStrategy] = Field(default=None, description="Chunking strategy for documents")
+ name: str | None = Field(default=None, max_length=100, description="User-friendly collection name")
+ description: str | None = Field(default=None, description="Collection description")
+ chunkingStrategy: ChunkingStrategy | None = Field(default=None, description="Chunking strategy for documents")
allowChunkingOverride: bool = Field(
default=True, description="Allow users to override chunking strategy during ingestion"
)
- metadata: Optional[CollectionMetadata] = Field(
+ metadata: CollectionMetadata | None = Field(
default=None, description="Collection-specific metadata (merged with parent)"
)
- allowedGroups: Optional[List[str]] = Field(default=None, description="User groups with access to collection")
- embeddingModel: Optional[str] = Field(
+ allowedGroups: list[str] | None = Field(default=None, description="User groups with access to collection")
+ embeddingModel: str | None = Field(
default=None, description="Embedding model ID (can be set at creation, immutable after)"
)
createdBy: str = Field(min_length=1, description="User ID of creator")
@@ -1188,10 +1210,10 @@ class RagCollectionConfig(BaseModel):
updatedAt: datetime = Field(default_factory=utc_now, description="Last update timestamp")
status: CollectionStatus = Field(default=CollectionStatus.ACTIVE, description="Collection status")
default: bool = Field(default=False, description="Indicates if this is a default collection")
- dataSourceId: Optional[str] = Field(
+ dataSourceId: str | None = Field(
default=None, description="Bedrock KB data source ID for filtering (Bedrock KB only)"
)
- pipelines: Optional[List[PipelineConfig]] = Field(
+ pipelines: list[PipelineConfig] | None = Field(
default=None, description="Pipeline configurations for this collection"
)
@@ -1199,7 +1221,7 @@ class RagCollectionConfig(BaseModel):
@field_validator("name")
@classmethod
- def validate_name(cls, name: Optional[str]) -> Optional[str]:
+ def validate_name(cls, name: str | None) -> str | None:
"""Validates collection name."""
if name is not None:
if len(name) > 100:
@@ -1213,7 +1235,7 @@ def validate_name(cls, name: Optional[str]) -> Optional[str]:
@field_validator("allowedGroups")
@classmethod
- def validate_allowed_groups(cls, groups: Optional[List[str]]) -> Optional[List[str]]:
+ def validate_allowed_groups(cls, groups: list[str] | None) -> list[str] | None:
"""Validates allowed groups."""
if groups is not None and len(groups) == 0:
# Empty list should be treated as None (inherit from parent)
@@ -1224,20 +1246,20 @@ def validate_allowed_groups(cls, groups: Optional[List[str]]) -> Optional[List[s
class IngestDocumentRequest(BaseModel):
"""Request model for ingesting documents."""
- keys: List[str] = Field(description="S3 keys to ingest")
- collectionId: Optional[str] = Field(default=None, description="Target collection ID")
- embeddingModel: Optional[Dict[str, str]] = Field(default=None, description="Embedding model config")
- chunkingStrategy: Optional[Dict[str, Any]] = Field(default=None, description="Chunking strategy override")
- metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata")
+ keys: list[str] = Field(description="S3 keys to ingest")
+ collectionId: str | None = Field(default=None, description="Target collection ID")
+ embeddingModel: dict[str, str] | None = Field(default=None, description="Embedding model config")
+ chunkingStrategy: dict[str, Any] | None = Field(default=None, description="Chunking strategy override")
+ metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata")
class ListCollectionsResponse(PaginatedResponse):
"""Response model for listing collections."""
- collections: List[RagCollectionConfig] = Field(description="List of collections")
- totalCount: Optional[int] = Field(default=None, description="Total number of collections")
- currentPage: Optional[int] = Field(default=None, description="Current page number")
- totalPages: Optional[int] = Field(default=None, description="Total number of pages")
+ collections: list[RagCollectionConfig] = Field(description="List of collections")
+ totalCount: int | None = Field(default=None, description="Total number of collections")
+ currentPage: int | None = Field(default=None, description="Current page number")
+ totalPages: int | None = Field(default=None, description="Total number of pages")
class CollectionSortBy(StrEnum):
@@ -1258,8 +1280,8 @@ class SortOrder(StrEnum):
class RepositoryMetadata(BaseModel):
"""Defines metadata for a repository/vector store."""
- tags: List[str] = Field(default_factory=list, description="Tags for categorizing the repository")
- customFields: Optional[Dict[str, Any]] = Field(default=None, description="Custom metadata fields")
+ tags: list[str] = Field(default_factory=list, description="Tags for categorizing the repository")
+ customFields: dict[str, Any] | None = Field(default=None, description="Custom metadata fields")
class OpenSearchNewClusterConfig(BaseModel):
@@ -1306,14 +1328,16 @@ class RdsInstanceConfig(BaseModel):
"""Configuration schema for RDS Instances needed for LiteLLM scaling or PGVector RAG operations.
The optional fields can be omitted to create a new database instance, otherwise fill in all fields
- to use an existing database instance.
+ to use an existing database instance. By default, IAM authentication is used. Set iamRdsAuth
+ to false in config to use password-based authentication.
"""
username: str = Field(default="postgres", description="The username used for database connection.")
- passwordSecretId: Optional[str] = Field(
- default=None, description="The SecretsManager Secret ID that stores the existing database password."
+ passwordSecretId: str | None = Field(
+ default=None,
+ description="The SecretsManager Secret ID that stores the existing database password.",
)
- dbHost: Optional[str] = Field(default=None, description="The database hostname for the existing database instance.")
+ dbHost: str | None = Field(default=None, description="The database hostname for the existing database instance.")
dbName: str = Field(default="postgres", description="The name of the database for the database instance.")
dbPort: int = Field(
default=5432,
@@ -1344,7 +1368,7 @@ class BedrockKnowledgeBaseConfig(BaseModel):
"""
knowledgeBaseId: str = Field(min_length=1, description="The ID of the Bedrock Knowledge Base")
- dataSources: List[BedrockDataSource] = Field(
+ dataSources: list[BedrockDataSource] = Field(
min_length=1, description="Array of data sources in this Knowledge Base"
)
@@ -1353,38 +1377,38 @@ class VectorStoreConfig(BaseModel):
"""Represents a vector store/repository configuration."""
repositoryId: str = Field(description="Unique identifier for the repository")
- repositoryName: Optional[str] = Field(default=None, description="User-friendly name for the repository")
- description: Optional[str] = Field(default=None, description="Description of the repository")
- embeddingModelId: Optional[str] = Field(default=None, description="Default embedding model ID")
+ repositoryName: str | None = Field(default=None, description="User-friendly name for the repository")
+ description: str | None = Field(default=None, description="Description of the repository")
+ embeddingModelId: str | None = Field(default=None, description="Default embedding model ID")
type: str = Field(description="Type of vector store (opensearch, pgvector, bedrock_knowledge_base)")
- allowedGroups: List[str] = Field(default_factory=list, description="User groups with access to this repository")
- metadata: Optional[RepositoryMetadata] = Field(default=None, description="Repository metadata")
- pipelines: Optional[List[PipelineConfig]] = Field(default=None, description="Automated ingestion pipelines")
+ allowedGroups: list[str] = Field(default_factory=list, description="User groups with access to this repository")
+ metadata: RepositoryMetadata | None = Field(default=None, description="Repository metadata")
+ pipelines: list[PipelineConfig] | None = Field(default=None, description="Automated ingestion pipelines")
# Type-specific configurations
- opensearchConfig: Optional[Union[OpenSearchNewClusterConfig, OpenSearchExistingClusterConfig]] = Field(
+ opensearchConfig: OpenSearchNewClusterConfig | OpenSearchExistingClusterConfig | None = Field(
default=None, description="OpenSearch configuration"
)
- rdsConfig: Optional[RdsInstanceConfig] = Field(default=None, description="RDS/PGVector configuration")
- bedrockKnowledgeBaseConfig: Optional[BedrockKnowledgeBaseConfig] = Field(
+ rdsConfig: RdsInstanceConfig | None = Field(default=None, description="RDS/PGVector configuration")
+ bedrockKnowledgeBaseConfig: BedrockKnowledgeBaseConfig | None = Field(
default=None, description="Bedrock Knowledge Base configuration with data sources"
)
# Status and timestamps
- status: Optional[VectorStoreStatus] = Field(default=None, description="Repository Status")
+ status: VectorStoreStatus | None = Field(default=None, description="Repository Status")
createdBy: str = Field(description="Creation user")
createdAt: datetime = Field(default_factory=utc_now, description="Creation timestamp")
- updatedAt: Optional[datetime] = Field(default_factory=utc_now, description="Last update timestamp")
+ updatedAt: datetime | None = Field(default_factory=utc_now, description="Last update timestamp")
class UpdateVectorStoreRequest(BaseModel):
"""Request model for updating a vector store."""
- repositoryName: Optional[str] = Field(default=None, description="User-friendly name")
- description: Optional[str] = Field(default=None, description="Description of the repository")
- embeddingModelId: Optional[str] = Field(default=None, description="Default embedding model ID")
- allowedGroups: Optional[List[str]] = Field(default=None, description="User groups with access")
- metadata: Optional[RepositoryMetadata] = Field(default=None, description="Repository metadata")
- pipelines: Optional[List[PipelineConfig]] = Field(default=None, description="Automated ingestion pipelines")
- bedrockKnowledgeBaseConfig: Optional[BedrockKnowledgeBaseConfig] = Field(
+ repositoryName: str | None = Field(default=None, description="User-friendly name")
+ description: str | None = Field(default=None, description="Description of the repository")
+ embeddingModelId: str | None = Field(default=None, description="Default embedding model ID")
+ allowedGroups: list[str] | None = Field(default=None, description="User groups with access")
+ metadata: RepositoryMetadata | None = Field(default=None, description="Repository metadata")
+ pipelines: list[PipelineConfig] | None = Field(default=None, description="Automated ingestion pipelines")
+ bedrockKnowledgeBaseConfig: BedrockKnowledgeBaseConfig | None = Field(
default=None, description="Bedrock Knowledge Base configuration"
)
@@ -1394,10 +1418,10 @@ class KnowledgeBaseMetadata(BaseModel):
knowledgeBaseId: str = Field(description="Knowledge Base ID")
name: str = Field(description="Knowledge Base name")
- description: Optional[str] = Field(default="", description="Knowledge Base description")
+ description: str | None = Field(default="", description="Knowledge Base description")
status: str = Field(description="Knowledge Base status (ACTIVE, CREATING, DELETING, etc.)")
createdAt: datetime = Field(default_factory=utc_now, description="Creation timestamp")
- updatedAt: Optional[datetime] = Field(default=None, description="Last update timestamp")
+ updatedAt: datetime | None = Field(default=None, description="Last update timestamp")
class DataSourceMetadata(BaseModel):
@@ -1405,14 +1429,14 @@ class DataSourceMetadata(BaseModel):
dataSourceId: str = Field(description="Data Source ID")
name: str = Field(description="Data Source name")
- description: Optional[str] = Field(default="", description="Data Source description")
+ description: str | None = Field(default="", description="Data Source description")
status: str = Field(description="Data Source status (AVAILABLE, CREATING, DELETING, etc.)")
s3Bucket: str = Field(description="S3 bucket for the data source")
s3Prefix: str = Field(default="", description="S3 prefix for the data source")
createdAt: datetime = Field(default_factory=utc_now, description="Creation timestamp")
- updatedAt: Optional[datetime] = Field(default=None, description="Last update timestamp")
- managed: Optional[bool] = Field(default=False, description="Whether this data source is managed by a collection")
- collectionId: Optional[str] = Field(default=None, description="Collection ID if managed")
+ updatedAt: datetime | None = Field(default=None, description="Last update timestamp")
+ managed: bool | None = Field(default=False, description="Whether this data source is managed by a collection")
+ collectionId: str | None = Field(default=None, description="Collection ID if managed")
@field_validator("s3Bucket")
@classmethod
diff --git a/lambda/models/handler/create_model_handler.py b/lambda/models/handler/create_model_handler.py
index a9ffbc1d9..0da28fc1d 100644
--- a/lambda/models/handler/create_model_handler.py
+++ b/lambda/models/handler/create_model_handler.py
@@ -17,6 +17,7 @@
import os
from models.exception import ModelAlreadyExistsError
+from utilities.time import now
from ..domain_objects import CreateModelRequest, CreateModelResponse, ModelStatus
from .base_handler import BaseApiHandler
@@ -26,7 +27,7 @@
class CreateModelHandler(BaseApiHandler):
"""Handler class for CreateModel requests."""
- def __call__(self, create_request: CreateModelRequest) -> CreateModelResponse: # type: ignore
+ def __call__(self, create_request: CreateModelRequest) -> CreateModelResponse:
"""Create model infrastructure and add model data to LiteLLM database."""
model_id = create_request.modelId
@@ -37,6 +38,21 @@ def __call__(self, create_request: CreateModelRequest) -> CreateModelResponse:
self.validate(create_request)
+ # Create initial DynamoDB record before starting state machine
+ # This ensures model_config exists even if state machine fails
+ model_config_data = create_request.model_dump()
+ model_config_data.pop("guardrailsConfig", None)
+
+ self._model_table.put_item(
+ Item={
+ "model_id": model_id,
+ "model_status": ModelStatus.CREATING,
+ "model_config": model_config_data,
+ "model_description": create_request.modelDescription,
+ "last_modified_date": now(),
+ }
+ )
+
self._stepfunctions.start_execution(
stateMachineArn=os.environ["CREATE_SFN_ARN"], input=create_request.model_dump_json()
)
diff --git a/lambda/models/handler/delete_model_handler.py b/lambda/models/handler/delete_model_handler.py
index e9776af07..cb48d90b2 100644
--- a/lambda/models/handler/delete_model_handler.py
+++ b/lambda/models/handler/delete_model_handler.py
@@ -35,7 +35,7 @@
class DeleteModelHandler(BaseApiHandler):
"""Handler class for DeleteModel requests."""
- def __call__(self, model_id: str) -> DeleteModelResponse: # type: ignore
+ def __call__(self, model_id: str) -> DeleteModelResponse:
"""Kick off state machine to delete infrastructure and remove model reference from LiteLLM."""
table_item = self._model_table.get_item(Key={"model_id": model_id}).get("Item", None)
if not table_item:
@@ -108,7 +108,7 @@ def _get_vector_store_table_name(self) -> str | None:
response = ssm_client.get_parameter(Name=parameter_name)
table_name = response["Parameter"]["Value"]
logger.debug(f"Retrieved RAG vector store table name from SSM: {table_name}")
- return table_name
+ return table_name # type: ignore[no-any-return]
except ClientError as e:
if e.response["Error"]["Code"] == "ParameterNotFound":
logger.debug(f"SSM parameter {parameter_name} not found - RAG not deployed")
@@ -131,7 +131,7 @@ def _get_collection_table_name(self) -> str | None:
response = ssm_client.get_parameter(Name=parameter_name)
table_name = response["Parameter"]["Value"]
logger.debug(f"Retrieved RAG collections table name from SSM: {table_name}")
- return table_name
+ return table_name # type: ignore[no-any-return]
except ClientError as e:
if e.response["Error"]["Code"] == "ParameterNotFound":
logger.debug(f"SSM parameter {parameter_name} not found - RAG not deployed")
diff --git a/lambda/models/handler/get_model_handler.py b/lambda/models/handler/get_model_handler.py
index 198f592aa..207d7687a 100644
--- a/lambda/models/handler/get_model_handler.py
+++ b/lambda/models/handler/get_model_handler.py
@@ -14,7 +14,6 @@
"""Handler for GetModel requests."""
-from typing import List, Optional
from utilities.auth import user_has_group_access
@@ -27,9 +26,7 @@
class GetModelHandler(BaseApiHandler):
"""Handler class for GetModel requests."""
- def __call__(
- self, model_id: str, user_groups: Optional[List[str]] = None, is_admin: bool = False
- ) -> GetModelResponse:
+ def __call__(self, model_id: str, user_groups: list[str] | None = None, is_admin: bool = False) -> GetModelResponse:
"""Get model metadata from LiteLLM and translate to a model management response object."""
ddb_item = self._model_table.get_item(Key={"model_id": model_id}).get("Item", None)
if not ddb_item:
diff --git a/lambda/models/handler/list_models_handler.py b/lambda/models/handler/list_models_handler.py
index fd2ae7541..5b65ff299 100644
--- a/lambda/models/handler/list_models_handler.py
+++ b/lambda/models/handler/list_models_handler.py
@@ -14,7 +14,6 @@
"""Handler for ListModels requests."""
-from typing import List, Optional
from utilities.auth import user_has_group_access
@@ -26,7 +25,7 @@
class ListModelsHandler(BaseApiHandler):
"""Handler class for ListModels requests."""
- def __call__(self, user_groups: Optional[List[str]] = None, is_admin: bool = False) -> ListModelsResponse:
+ def __call__(self, user_groups: list[str] | None = None, is_admin: bool = False) -> ListModelsResponse:
"""Call handler to get all models from DynamoDB and transform results into API response format."""
ddb_models = []
models_response = self._model_table.scan()
diff --git a/lambda/models/handler/schedule_handlers.py b/lambda/models/handler/schedule_handlers.py
index ff812622c..fda0a115f 100644
--- a/lambda/models/handler/schedule_handlers.py
+++ b/lambda/models/handler/schedule_handlers.py
@@ -13,7 +13,7 @@
# limitations under the License.
import json
-from typing import Any, List, Optional
+from typing import Any
from ..domain_objects import (
DeleteScheduleResponse,
@@ -48,7 +48,7 @@ def __call__(
self,
model_id: str,
schedule_config: SchedulingConfig,
- user_groups: Optional[List[str]] = None,
+ user_groups: list[str] | None = None,
is_admin: bool = False,
) -> UpdateScheduleResponse:
"""Create or update a schedule for a model"""
@@ -86,7 +86,7 @@ class GetScheduleHandler(ScheduleBaseHandler):
"""Handler class for GetSchedule requests"""
def __call__(
- self, model_id: str, user_groups: Optional[List[str]] = None, is_admin: bool = False
+ self, model_id: str, user_groups: list[str] | None = None, is_admin: bool = False
) -> GetScheduleResponse:
"""Get current schedule configuration for a model"""
# Validate model exists and user access
@@ -111,7 +111,7 @@ class DeleteScheduleHandler(ScheduleBaseHandler):
"""Handler class for DeleteSchedule requests"""
def __call__(
- self, model_id: str, user_groups: Optional[List[str]] = None, is_admin: bool = False
+ self, model_id: str, user_groups: list[str] | None = None, is_admin: bool = False
) -> DeleteScheduleResponse:
"""Delete a schedule for a model"""
# Validate model exists, user access, and model status
@@ -132,7 +132,7 @@ class GetScheduleStatusHandler(ScheduleBaseHandler):
"""Handler class for GetScheduleStatus requests"""
def __call__(
- self, model_id: str, user_groups: Optional[List[str]] = None, is_admin: bool = False
+ self, model_id: str, user_groups: list[str] | None = None, is_admin: bool = False
) -> GetScheduleStatusResponse:
"""Get current schedule status and next scheduled action for a model"""
# Validate model exists and user access
diff --git a/lambda/models/handler/update_model_handler.py b/lambda/models/handler/update_model_handler.py
index 4d21d631f..b338d1884 100644
--- a/lambda/models/handler/update_model_handler.py
+++ b/lambda/models/handler/update_model_handler.py
@@ -26,7 +26,7 @@
class UpdateModelHandler(BaseApiHandler):
"""Handler class for UpdateModel requests."""
- def __call__(self, model_id: str, update_request: UpdateModelRequest) -> UpdateModelResponse: # type: ignore
+ def __call__(self, model_id: str, update_request: UpdateModelRequest) -> UpdateModelResponse:
"""Call handler to update model metadata or scaling config based on user request."""
ddb_item = self._model_table.get_item(Key={"model_id": model_id}).get("Item", None)
if not ddb_item:
diff --git a/lambda/models/handler/utils.py b/lambda/models/handler/utils.py
index 00486a276..334650926 100644
--- a/lambda/models/handler/utils.py
+++ b/lambda/models/handler/utils.py
@@ -14,7 +14,8 @@
"""Common utility functions across all API handlers."""
-from typing import Any, Dict, List, Optional
+import logging
+from typing import Any
from utilities.auth import user_has_group_access
from utilities.validation import ValidationError
@@ -22,19 +23,22 @@
from ..domain_objects import GuardrailConfig, LISAModel
from ..exception import InvalidStateTransitionError, ModelNotFoundError
+logger = logging.getLogger(__name__)
-def to_lisa_model(model_dict: Dict[str, Any]) -> LISAModel:
+
+def to_lisa_model(model_dict: dict[str, Any]) -> LISAModel:
"""Convert DDB model entry dictionary to a LISAModel object."""
- model_dict["model_config"]["status"] = model_dict["model_status"]
+ model_config = model_dict.get("model_config", {})
+ model_config["status"] = model_dict.get("model_status", "Unknown")
if "model_url" in model_dict:
- model_dict["model_config"]["modelUrl"] = model_dict["model_url"]
- lisa_model: LISAModel = LISAModel.model_validate(model_dict["model_config"])
+ model_config["modelUrl"] = model_dict["model_url"]
+ lisa_model: LISAModel = LISAModel.model_validate(model_config)
return lisa_model
def get_model_and_validate_access(
- model_table, model_id: str, user_groups: Optional[List[str]] = None, is_admin: bool = False
-) -> Dict[str, Any]:
+ model_table: Any, model_id: str, user_groups: list[str] | None = None, is_admin: bool = False
+) -> dict[str, Any]:
"""
Get model from DynamoDB and validate user access
@@ -66,16 +70,16 @@ def get_model_and_validate_access(
if not user_has_group_access(user_groups, allowed_groups):
raise ValidationError(f"Access denied to access model {model_id}")
- return model_item
+ return model_item # type: ignore[no-any-return]
def get_model_and_validate_status(
- model_table,
+ model_table: Any,
model_id: str,
- allowed_statuses: List[str] = None,
- user_groups: Optional[List[str]] = None,
+ allowed_statuses: list[str] | None = None,
+ user_groups: list[str] | None = None,
is_admin: bool = False,
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""
Get model from DynamoDB, validate user access, and check model status
@@ -111,12 +115,12 @@ def get_model_and_validate_status(
return model_item
-def create_guardrail_config(item: Dict[str, Any]) -> GuardrailConfig:
+def create_guardrail_config(item: dict[str, Any]) -> GuardrailConfig:
"""Create a GuardrailConfig object from a DynamoDB guardrail item."""
return GuardrailConfig(**item)
-def attach_guardrails_to_model(model: LISAModel, guardrail_items: List[Dict[str, Any]]) -> None:
+def attach_guardrails_to_model(model: LISAModel, guardrail_items: list[dict[str, Any]]) -> None:
"""Build guardrails config from DDB items and attach to model."""
if not guardrail_items:
return
@@ -126,17 +130,17 @@ def attach_guardrails_to_model(model: LISAModel, guardrail_items: List[Dict[str,
}
-def fetch_guardrails_for_model(guardrails_table, model_id: str) -> List[Dict[str, Any]]:
+def fetch_guardrails_for_model(guardrails_table: Any, model_id: str) -> list[dict[str, Any]]:
"""Query guardrails table for a specific model ID."""
guardrails_response = guardrails_table.query(
IndexName="ModelIdIndex",
KeyConditionExpression="modelId = :modelId",
ExpressionAttributeValues={":modelId": model_id},
)
- return guardrails_response.get("Items", [])
+ return guardrails_response.get("Items", []) # type: ignore[no-any-return]
-def fetch_all_guardrails(guardrails_table) -> List[Dict[str, Any]]:
+def fetch_all_guardrails(guardrails_table: Any) -> list[dict[str, Any]]:
"""Scan all guardrails from the table with pagination."""
all_guardrails = []
guardrails_response = guardrails_table.scan()
@@ -151,9 +155,9 @@ def fetch_all_guardrails(guardrails_table) -> List[Dict[str, Any]]:
return all_guardrails
-def group_guardrails_by_model(guardrail_items: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
+def group_guardrails_by_model(guardrail_items: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
"""Group guardrail items by modelId."""
- guardrails_by_model: Dict[str, List[Dict[str, Any]]] = {}
+ guardrails_by_model: dict[str, list[dict[str, Any]]] = {}
for item in guardrail_items:
model_id = item["modelId"]
if model_id not in guardrails_by_model:
diff --git a/lambda/models/lambda_functions.py b/lambda/models/lambda_functions.py
index 549933c2f..8721ab9ef 100644
--- a/lambda/models/lambda_functions.py
+++ b/lambda/models/lambda_functions.py
@@ -13,20 +13,19 @@
# limitations under the License.
"""APIGW endpoints for managing models."""
+import logging
import os
-from typing import Annotated, Union
+from typing import Annotated
+from urllib.parse import urlparse
import boto3
import botocore.session
-from fastapi import FastAPI, HTTPException, Path, Request
-from fastapi.encoders import jsonable_encoder
-from fastapi.exceptions import RequestValidationError
-from fastapi.middleware.cors import CORSMiddleware
+from fastapi import HTTPException, Path, Request
from fastapi.responses import JSONResponse
from mangum import Mangum
-from utilities.auth import get_groups, is_admin
+from utilities.auth import get_groups, get_username, is_admin, require_admin
from utilities.common_functions import retry_config
-from utilities.fastapi_middleware.aws_api_gateway_middleware import AWSAPIGatewayMiddleware
+from utilities.fastapi_factory import create_fastapi_app
from .domain_objects import (
CreateModelRequest,
@@ -55,18 +54,10 @@
UpdateScheduleHandler,
)
+logger = logging.getLogger(__name__)
+
sess = botocore.session.Session()
-app = FastAPI(redirect_slashes=False, lifespan="off", docs_url="/docs", openapi_url="/openapi.json")
-app.add_middleware(AWSAPIGatewayMiddleware)
-
-# Enable CORS
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
-)
+app = create_fastapi_app()
autoscaling = boto3.client("autoscaling", region_name=os.environ["AWS_REGION"], config=retry_config)
dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config)
@@ -97,31 +88,93 @@ async def model_not_found_handler(request: Request, exc: ModelNotFoundError) ->
return JSONResponse(status_code=404, content={"detail": str(exc)})
-@app.exception_handler(RequestValidationError)
-async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
- """Handle exception when request fails validation and and translate to a 422 error."""
- return JSONResponse(
- status_code=422, content={"detail": jsonable_encoder(exc.errors()), "type": "RequestValidationError"}
- )
-
-
@app.exception_handler(InvalidStateTransitionError)
@app.exception_handler(ModelAlreadyExistsError)
@app.exception_handler(ValueError)
async def user_error_handler(
- request: Request, exc: Union[InvalidStateTransitionError, ModelAlreadyExistsError, ValueError]
+ 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)})
+@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)})
+
+
@app.post(path="", include_in_schema=False)
@app.post(path="/")
+@require_admin("User does not have permission to create models.")
async def create_model(create_request: CreateModelRequest, request: Request) -> CreateModelResponse:
"""Endpoint to create a model."""
- admin_status, _ = get_admin_status_and_groups(request)
- if not admin_status:
- raise HTTPException(status_code=403, detail="User does not have permission to create models.")
+ # Extract user context for audit logging
+ event = request.scope.get("aws.event", {})
+ username = get_username(event) if event else "unknown"
+ auth_type = event.get("requestContext", {}).get("authorizer", {}).get("authType", "unknown")
+ source_ip = event.get("requestContext", {}).get("identity", {}).get("sourceIp", "unknown")
+
+ # Extract container image and healthcheck details for audit logging
+ container_image = None
+ registry_domain = None
+ healthcheck_command = None
+
+ if create_request.containerConfig:
+ container_image = create_request.containerConfig.image.baseImage
+ # Extract registry domain from image URL
+ try:
+ if "://" in container_image:
+ registry_domain = urlparse(container_image).netloc
+ elif "/" in container_image:
+ registry_domain = container_image.split("/")[0]
+ else:
+ registry_domain = "unknown"
+ except Exception:
+ registry_domain = "parse_error"
+
+ healthcheck_command = create_request.containerConfig.healthCheckConfig.command
+
+ # Log CreateModel request for security audit
+ logger.info(
+ "CreateModel request",
+ extra={
+ "event_type": "CREATE_MODEL_REQUEST",
+ "user": {
+ "username": username,
+ "auth_type": auth_type,
+ "source_ip": source_ip,
+ },
+ "model": {
+ "model_id": create_request.modelId,
+ "model_name": create_request.modelName,
+ "instance_type": create_request.instanceType if hasattr(create_request, "instanceType") else None,
+ "auto_scaling": (
+ {
+ "min_capacity": (
+ create_request.autoScalingConfig.minCapacity if create_request.autoScalingConfig else None
+ ),
+ "max_capacity": (
+ create_request.autoScalingConfig.maxCapacity if create_request.autoScalingConfig else None
+ ),
+ }
+ if create_request.autoScalingConfig
+ else None
+ ),
+ },
+ "container": (
+ {
+ "base_image": container_image,
+ "registry_domain": registry_domain,
+ "image_type": create_request.containerConfig.image.type if create_request.containerConfig else None,
+ "healthcheck_command": healthcheck_command,
+ }
+ if create_request.containerConfig
+ else None
+ ),
+ },
+ )
+
create_handler = CreateModelHandler(
autoscaling_client=autoscaling,
stepfunctions_client=stepfunctions,
@@ -129,9 +182,44 @@ async def create_model(create_request: CreateModelRequest, request: Request) ->
guardrails_table_resource=guardrails_table,
)
try:
- return create_handler(create_request=create_request)
+ response = create_handler(create_request=create_request)
+
+ # Log successful creation
+ logger.info(
+ "CreateModel request successful",
+ extra={
+ "event_type": "CREATE_MODEL_SUCCESS",
+ "model_id": create_request.modelId,
+ "username": username,
+ },
+ )
+
+ return response
except ModelAlreadyExistsError as e:
+ # Log failure
+ logger.warning(
+ "CreateModel request failed - model already exists",
+ extra={
+ "event_type": "CREATE_MODEL_FAILURE",
+ "model_id": create_request.modelId,
+ "username": username,
+ "error": str(e),
+ },
+ )
raise HTTPException(status_code=409, detail=str(e))
+ except Exception as e:
+ # Log unexpected failure
+ logger.error(
+ "CreateModel request failed with unexpected error",
+ extra={
+ "event_type": "CREATE_MODEL_ERROR",
+ "model_id": create_request.modelId,
+ "username": username,
+ "error": str(e),
+ },
+ exc_info=True,
+ )
+ raise
@app.get(path="", include_in_schema=False)
@@ -169,15 +257,13 @@ async def get_model(
@app.put(path="/{model_id}")
+@require_admin("User does not have permission to update models")
async def update_model(
model_id: Annotated[str, Path(title="The unique model ID of the model to update")],
update_request: UpdateModelRequest,
request: Request,
) -> UpdateModelResponse:
"""Endpoint to update a model."""
- admin_status, _ = get_admin_status_and_groups(request)
- if not admin_status:
- raise HTTPException(status_code=403, detail="User does not have permission to update models")
update_handler = UpdateModelHandler(
autoscaling_client=autoscaling,
stepfunctions_client=stepfunctions,
@@ -193,13 +279,11 @@ async def update_model(
@app.delete(path="/{model_id}")
+@require_admin("User does not have permission to delete models")
async def delete_model(
model_id: Annotated[str, Path(title="The unique model ID of the model to delete")], request: Request
) -> DeleteModelResponse:
"""Endpoint to delete a model."""
- admin_status, _ = get_admin_status_and_groups(request)
- if not admin_status:
- raise HTTPException(status_code=403, detail="User does not have permission to delete models")
delete_handler = DeleteModelHandler(
autoscaling_client=autoscaling,
stepfunctions_client=stepfunctions,
@@ -222,12 +306,15 @@ async def get_instances() -> list[str]:
@app.post(path="/{model_id}/schedule")
@app.put(path="/{model_id}/schedule")
+@require_admin("User does not have permission to update model schedules")
async def update_schedule(
model_id: Annotated[str, Path(title="The unique model ID of the model to schedule")],
schedule_config: SchedulingConfig,
request: Request,
) -> UpdateScheduleResponse:
"""Endpoint to create or update a schedule for a model"""
+ admin_status, user_groups = get_admin_status_and_groups(request)
+
update_schedule_handler = UpdateScheduleHandler(
autoscaling_client=autoscaling,
stepfunctions_client=stepfunctions,
@@ -235,18 +322,6 @@ async def update_schedule(
guardrails_table_resource=guardrails_table,
)
- user_groups = []
- admin_status = False
-
- if "aws.event" in request.scope:
- event = request.scope["aws.event"]
- try:
- user_groups = get_groups(event)
- admin_status = is_admin(event)
- except Exception:
- user_groups = []
- admin_status = False
-
return update_schedule_handler(
model_id=model_id, schedule_config=schedule_config, user_groups=user_groups, is_admin=admin_status
)
@@ -280,10 +355,13 @@ async def get_schedule(
@app.delete(path="/{model_id}/schedule")
+@require_admin("User does not have permission to delete model schedules")
async def delete_schedule(
model_id: Annotated[str, Path(title="The unique model ID of the model to delete schedule for")], request: Request
) -> DeleteScheduleResponse:
"""Endpoint to delete a schedule for a model"""
+ admin_status, user_groups = get_admin_status_and_groups(request)
+
delete_schedule_handler = DeleteScheduleHandler(
autoscaling_client=autoscaling,
stepfunctions_client=stepfunctions,
@@ -291,18 +369,6 @@ async def delete_schedule(
guardrails_table_resource=guardrails_table,
)
- user_groups = []
- admin_status = False
-
- if "aws.event" in request.scope:
- event = request.scope["aws.event"]
- try:
- user_groups = get_groups(event)
- admin_status = is_admin(event)
- except Exception:
- user_groups = []
- admin_status = False
-
return delete_schedule_handler(model_id=model_id, user_groups=user_groups, is_admin=admin_status)
diff --git a/lambda/models/model_api_key_cleanup.py b/lambda/models/model_api_key_cleanup.py
index f4b6afa14..0555f7d6f 100644
--- a/lambda/models/model_api_key_cleanup.py
+++ b/lambda/models/model_api_key_cleanup.py
@@ -28,18 +28,19 @@
import os
import sys
import traceback
-from typing import Any, Dict, List
+from typing import Any
import boto3
import psycopg2
-from utilities.common_functions import retry_config
+from utilities.common_functions import get_lambda_role_name, retry_config
+from utilities.rds_auth import generate_auth_token
# Add the lambda directory to the Python path
sys.path.append("/opt/python")
sys.path.append("/var/task")
-def get_all_dynamodb_models() -> List[Dict[str, str]]:
+def get_all_dynamodb_models() -> list[dict[str, str]]:
"""Get all models from DynamoDB table with their IDs and names."""
try:
dynamodb = boto3.client("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config)
@@ -87,8 +88,8 @@ def get_all_dynamodb_models() -> List[Dict[str, str]]:
return []
-def get_database_connection():
- """Get database connection using connection info from SSM."""
+def get_database_connection() -> Any:
+ """Get database connection using password auth or IAM auth based on config."""
ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], config=retry_config)
# Get database connection info from SSM using environment variable
@@ -103,38 +104,47 @@ def get_database_connection():
except Exception as e:
raise ValueError(f"Failed to get database connection info from SSM: {e}")
- # Get database credentials from Secrets Manager
- try:
- secrets_client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"], config=retry_config)
- secret_response = secrets_client.get_secret_value(SecretId=db_params["passwordSecretId"])
- secret = json.loads(secret_response["SecretString"])
- except Exception as e:
- raise ValueError(f"Failed to get database credentials from Secrets Manager: {e}")
-
# Validate required parameters
- required_params = ["dbHost", "dbPort", "dbName", "username"]
+ required_params = ["dbHost", "dbPort", "dbName"]
for param in required_params:
if param not in db_params:
raise ValueError(f"Missing required database parameter: {param}")
- if "password" not in secret:
- raise ValueError("Missing password in secret")
+ # Check if using password auth (passwordSecretId present) or IAM auth
+ if "passwordSecretId" in db_params:
+ # Password auth: get credentials from Secrets Manager
+ try:
+ secrets_client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"], config=retry_config)
+ secret_response = secrets_client.get_secret_value(SecretId=db_params["passwordSecretId"])
+ secret = json.loads(secret_response["SecretString"])
+ except Exception as e:
+ raise ValueError(f"Failed to get database credentials from Secrets Manager: {e}")
+
+ if "password" not in secret:
+ raise ValueError("Missing password in secret")
- # Create connection with proper error handling
+ user = db_params.get("username", "postgres")
+ password = secret["password"]
+ else:
+ # IAM auth: generate auth token
+ user = get_lambda_role_name()
+ password = generate_auth_token(db_params["dbHost"], db_params["dbPort"], user)
+
+ # Create connection
try:
conn = psycopg2.connect(
host=db_params["dbHost"],
port=db_params["dbPort"],
database=db_params["dbName"],
- user=db_params["username"],
- password=secret["password"],
+ user=user,
+ password=password,
)
return conn
except Exception as e:
raise ValueError(f"Failed to connect to database: {e}")
-def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Lambda handler for Bedrock model API key cleanup.
@@ -189,9 +199,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
# Use psycopg2's identifier quoting to prevent SQL injection
cursor.execute(
- psycopg2.sql.SQL("SELECT * FROM {} LIMIT 1").format( # noqa: S608, P103
- psycopg2.sql.Identifier(litellm_table)
- )
+ psycopg2.sql.SQL("SELECT * FROM {} LIMIT 1").format(psycopg2.sql.Identifier(litellm_table)) # noqa: S608
)
columns = [desc[0] for desc in cursor.description]
print(f"Table {litellm_table} columns: {columns}")
@@ -210,7 +218,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
# Query all models from the LiteLLM database
cursor.execute(
- psycopg2.sql.SQL("SELECT {}, {}, {} FROM {}").format( # noqa: S608, P103
+ psycopg2.sql.SQL("SELECT {}, {}, {} FROM {}").format( # noqa: S608
psycopg2.sql.Identifier(model_id_col),
psycopg2.sql.Identifier(model_name_col),
psycopg2.sql.Identifier(litellm_params_col),
@@ -276,7 +284,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
# Update the model in the database
clean_params_json = json.dumps(clean_params)
cursor.execute(
- psycopg2.sql.SQL("UPDATE {} SET {} = %s WHERE {} = %s").format( # noqa: S608, P103
+ psycopg2.sql.SQL("UPDATE {} SET {} = %s WHERE {} = %s").format( # noqa: S608
psycopg2.sql.Identifier(litellm_table),
psycopg2.sql.Identifier(litellm_params_col),
psycopg2.sql.Identifier(model_id_col),
diff --git a/lambda/models/scheduling/schedule_management.py b/lambda/models/scheduling/schedule_management.py
index 79275ac3b..4ecbc2aa2 100644
--- a/lambda/models/scheduling/schedule_management.py
+++ b/lambda/models/scheduling/schedule_management.py
@@ -16,7 +16,7 @@
import logging
import os
from datetime import datetime, timedelta
-from typing import Any, Dict, List, Optional
+from typing import Any
from zoneinfo import ZoneInfo
import boto3
@@ -43,7 +43,7 @@
model_table = dynamodb.Table(os.environ.get("MODEL_TABLE_NAME"))
-def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Main Lambda handler for schedule management operations"""
try:
logger.info(f"Processing schedule management request: {json.dumps(event, default=str)}")
@@ -70,7 +70,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return {"statusCode": 500, "body": json.dumps({"error": "ScheduleManagementError", "message": str(e)})}
-def update_schedule(event: Dict[str, Any]) -> Dict[str, Any]:
+def update_schedule(event: dict[str, Any]) -> dict[str, Any]:
"""Update an existing schedule for a model"""
model_id = event["modelId"]
schedule_config = event.get("scheduleConfig")
@@ -126,7 +126,7 @@ def update_schedule(event: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError(f"Failed to update schedule: {str(e)}")
-def delete_schedule(event: Dict[str, Any]) -> Dict[str, Any]:
+def delete_schedule(event: dict[str, Any]) -> dict[str, Any]:
"""Delete a schedule for a model"""
model_id = event["modelId"]
@@ -166,7 +166,7 @@ def delete_schedule(event: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError(f"Failed to delete schedule: {str(e)}")
-def get_schedule(event: Dict[str, Any]) -> Dict[str, Any]:
+def get_schedule(event: dict[str, Any]) -> dict[str, Any]:
"""Get current schedule configuration for a model"""
model_id = event["modelId"]
@@ -199,7 +199,7 @@ def get_schedule(event: Dict[str, Any]) -> Dict[str, Any]:
raise RuntimeError(f"Failed to get schedule: {str(e)}")
-def create_scheduled_actions(model_id: str, auto_scaling_group: str, schedule_config: SchedulingConfig) -> List[str]:
+def create_scheduled_actions(model_id: str, auto_scaling_group: str, schedule_config: SchedulingConfig) -> list[str]:
"""Create Auto Scaling scheduled actions based on schedule configuration"""
scheduled_action_arns = []
@@ -222,7 +222,7 @@ def create_scheduled_actions(model_id: str, auto_scaling_group: str, schedule_co
return scheduled_action_arns
-def create_scheduling_config(schedule_data: Dict[str, Any]) -> SchedulingConfig:
+def create_scheduling_config(schedule_data: dict[str, Any]) -> SchedulingConfig:
"""Create the appropriate scheduling config instance based on scheduleType"""
schedule_type = schedule_data.get("scheduleType")
@@ -234,7 +234,7 @@ def create_scheduling_config(schedule_data: Dict[str, Any]) -> SchedulingConfig:
raise ValueError(f"Unknown schedule type: {schedule_type}")
-def get_existing_asg_capacity(auto_scaling_group: str) -> Dict[str, int]:
+def get_existing_asg_capacity(auto_scaling_group: str) -> dict[str, int]:
"""Get the existing Auto Scaling Group's current capacity configuration"""
try:
response = autoscaling_client.describe_auto_scaling_groups(AutoScalingGroupNames=[auto_scaling_group])
@@ -255,7 +255,7 @@ def get_existing_asg_capacity(auto_scaling_group: str) -> Dict[str, int]:
raise RuntimeError(f"Failed to get ASG capacity: {str(e)}")
-def get_model_baseline_capacity(model_id: str) -> Dict[str, int]:
+def get_model_baseline_capacity(model_id: str) -> dict[str, int]:
"""Get the baseline capacity configuration from the model's DynamoDB record"""
try:
response = model_table.get_item(Key={"model_id": model_id})
@@ -392,7 +392,7 @@ def scale_immediately(auto_scaling_group: str, day_schedule: DaySchedule, timezo
def create_recurring_scheduled_actions(
model_id: str, auto_scaling_group: str, day_schedule: DaySchedule, timezone_name: str
-) -> List[str]:
+) -> list[str]:
"""Create scheduled actions for recurring schedule"""
scheduled_action_arns = []
@@ -460,7 +460,7 @@ def create_recurring_scheduled_actions(
def create_daily_scheduled_actions(
model_id: str, auto_scaling_group: str, daily_schedule: WeeklySchedule, timezone_name: str
-) -> List[str]:
+) -> list[str]:
"""Create scheduled actions for daily schedule (different times each day with one start/stop time per day)"""
scheduled_action_arns = []
@@ -566,7 +566,7 @@ def construct_scheduled_action_arn(auto_scaling_group: str, action_name: str) ->
)
-def delete_scheduled_actions(scheduled_action_arns: List[str]) -> None:
+def delete_scheduled_actions(scheduled_action_arns: list[str]) -> None:
"""Delete Auto Scaling scheduled actions by ARN"""
for arn in scheduled_action_arns:
try:
@@ -588,7 +588,7 @@ def delete_scheduled_actions(scheduled_action_arns: List[str]) -> None:
raise
-def cleanup_scheduled_actions(scheduled_action_arns: List[str]) -> None:
+def cleanup_scheduled_actions(scheduled_action_arns: list[str]) -> None:
"""Clean up scheduled actions (used for error recovery)"""
for arn in scheduled_action_arns:
try:
@@ -654,7 +654,7 @@ def cleanup_scheduled_actions_by_name_pattern(auto_scaling_group: str, model_id:
logger.error(f"Failed to cleanup scheduled actions by pattern for model {model_id}: {e}")
-def calculate_next_scheduled_action(schedule_config: SchedulingConfig, timezone_name: str) -> Optional[Dict[str, str]]:
+def calculate_next_scheduled_action(schedule_config: SchedulingConfig, timezone_name: str) -> dict[str, str] | None:
"""Calculate the next scheduled action (START or STOP) based on the schedule configuration"""
try:
tz = ZoneInfo(timezone_name)
@@ -671,7 +671,7 @@ def calculate_next_scheduled_action(schedule_config: SchedulingConfig, timezone_
return None
-def _calculate_next_recurring_action(day_schedule: DaySchedule, now: datetime, tz: ZoneInfo) -> Dict[str, str]:
+def _calculate_next_recurring_action(day_schedule: DaySchedule, now: datetime, tz: ZoneInfo) -> dict[str, str]:
"""Calculate next action for recurring schedule"""
# Parse schedule times
start_hour, start_minute = map(int, day_schedule.startTime.split(":"))
@@ -699,9 +699,7 @@ def _calculate_next_recurring_action(day_schedule: DaySchedule, now: datetime, t
return {"action": "START", "scheduledTime": tomorrow_start.isoformat()}
-def _calculate_next_daily_action(
- daily_schedule: WeeklySchedule, now: datetime, tz: ZoneInfo
-) -> Optional[Dict[str, str]]:
+def _calculate_next_daily_action(daily_schedule: WeeklySchedule, now: datetime, tz: ZoneInfo) -> dict[str, str] | None:
"""Calculate next action for daily schedule"""
current_weekday = now.weekday()
@@ -739,7 +737,7 @@ def _calculate_next_daily_action(
return None
-def _get_next_action_for_today(day_schedule: DaySchedule, now: datetime, tz: ZoneInfo) -> Optional[Dict[str, str]]:
+def _get_next_action_for_today(day_schedule: DaySchedule, now: datetime, tz: ZoneInfo) -> dict[str, str] | None:
"""Get next action for today's schedule only"""
today = now.date()
@@ -765,7 +763,7 @@ def _get_next_action_for_today(day_schedule: DaySchedule, now: datetime, tz: Zon
return None
-def merge_schedule_data(model_id: str, partial_update: Dict[str, Any]) -> Dict[str, Any]:
+def merge_schedule_data(model_id: str, partial_update: dict[str, Any]) -> dict[str, Any]:
"""Merge partial schedule update with existing schedule data"""
# Get existing schedule data from model_config.autoScalingConfig.scheduling
existing_data = {}
@@ -804,7 +802,7 @@ def merge_schedule_data(model_id: str, partial_update: Dict[str, Any]) -> Dict[s
return merged_data
-def get_existing_scheduled_action_arns(model_id: str) -> List[str]:
+def get_existing_scheduled_action_arns(model_id: str) -> list[str]:
"""Get existing scheduled action ARNs for a model"""
try:
response = model_table.get_item(Key={"model_id": model_id})
@@ -817,7 +815,7 @@ def get_existing_scheduled_action_arns(model_id: str) -> List[str]:
auto_scaling_config = model_config.get("autoScalingConfig", {})
scheduling_config = auto_scaling_config.get("scheduling", {})
- return scheduling_config.get("scheduledActionArns", [])
+ return scheduling_config.get("scheduledActionArns", []) # type: ignore[no-any-return]
except Exception as e:
logger.error(f"Failed to get existing scheduled actions for model {model_id}: {e}")
@@ -825,7 +823,7 @@ def get_existing_scheduled_action_arns(model_id: str) -> List[str]:
def update_model_schedule_record(
- model_id: str, scheduling_config: Optional[SchedulingConfig], scheduled_action_arns: List[str], enabled: bool
+ model_id: str, scheduling_config: SchedulingConfig | None, scheduled_action_arns: list[str], enabled: bool
) -> None:
"""Update model record in DynamoDB with schedule information"""
try:
diff --git a/lambda/models/scheduling/schedule_monitoring.py b/lambda/models/scheduling/schedule_monitoring.py
index fad7de176..666e0beaa 100644
--- a/lambda/models/scheduling/schedule_monitoring.py
+++ b/lambda/models/scheduling/schedule_monitoring.py
@@ -15,7 +15,7 @@
import json
import logging
import os
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
from botocore.config import Config
@@ -35,7 +35,7 @@
model_table = dynamodb.Table(os.environ.get("MODEL_TABLE_NAME"))
-def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Main Lambda handler for CloudWatch Events from Auto Scaling Groups"""
logger.info(f"Processing event - RequestId: {context.aws_request_id}")
@@ -58,7 +58,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return {"statusCode": 500, "body": json.dumps({"error": "ScheduleMonitoringError", "message": str(e)})}
-def handle_autoscaling_event(event: Dict[str, Any]) -> Dict[str, Any]:
+def handle_autoscaling_event(event: dict[str, Any]) -> dict[str, Any]:
"""Handle Auto Scaling Group CloudWatch events"""
try:
detail = event.get("detail", {})
@@ -85,7 +85,7 @@ def handle_autoscaling_event(event: Dict[str, Any]) -> Dict[str, Any]:
raise ValueError(f"Failed to handle Auto Scaling event: {str(e)}")
-def handle_successful_scaling(model_id: str, auto_scaling_group: str, detail: Dict[str, Any]) -> Dict[str, Any]:
+def handle_successful_scaling(model_id: str, auto_scaling_group: str, detail: dict[str, Any]) -> dict[str, Any]:
"""Handle successful Auto Scaling actions using ASG state"""
try:
# Check ASG state to determine model status
@@ -137,7 +137,7 @@ def handle_successful_scaling(model_id: str, auto_scaling_group: str, detail: Di
raise
-def sync_model_status(event: Dict[str, Any]) -> Dict[str, Any]:
+def sync_model_status(event: dict[str, Any]) -> dict[str, Any]:
"""Manually sync model status using ASG state"""
model_id = event.get("modelId")
if not model_id:
@@ -207,7 +207,7 @@ def sync_model_status(event: Dict[str, Any]) -> Dict[str, Any]:
raise ValueError(f"Failed to sync status: {str(e)}")
-def find_model_by_asg_name(asg_name: str) -> Optional[str]:
+def find_model_by_asg_name(asg_name: str) -> str | None:
"""Find model ID by looking up which model uses the given Auto Scaling Group"""
try:
response = model_table.scan(
@@ -217,7 +217,7 @@ def find_model_by_asg_name(asg_name: str) -> Optional[str]:
)
if response["Items"]:
- return response["Items"][0]["model_id"]
+ return response["Items"][0]["model_id"] # type: ignore[no-any-return]
return None
@@ -249,7 +249,7 @@ def update_model_status(model_id: str, new_status: ModelStatus, reason: str) ->
raise
-def get_model_info(model_id: str) -> Optional[Dict[str, Any]]:
+def get_model_info(model_id: str) -> dict[str, Any] | None:
"""Get model information from DynamoDB"""
try:
response = model_table.get_item(Key={"model_id": model_id})
@@ -257,7 +257,7 @@ def get_model_info(model_id: str) -> Optional[Dict[str, Any]]:
if "Item" not in response:
return None
- return response["Item"]
+ return response["Item"] # type: ignore[no-any-return]
except Exception as e:
logger.error(f"Failed to get model info for {model_id}: {e}")
diff --git a/lambda/models/state_machine/create_model.py b/lambda/models/state_machine/create_model.py
index 539ca8c8e..0be5b750e 100644
--- a/lambda/models/state_machine/create_model.py
+++ b/lambda/models/state_machine/create_model.py
@@ -19,13 +19,13 @@
import os
from copy import deepcopy
from datetime import datetime
-from typing import Any, Dict
+from typing import Any
from zoneinfo import ZoneInfo
import boto3
from botocore.config import Config
from models.clients.litellm_client import LiteLLMClient
-from models.domain_objects import CreateModelRequest, GuardrailsTableEntry, InferenceContainer, ModelStatus
+from models.domain_objects import CreateModelRequest, GuardrailsTableEntry, InferenceContainer, ModelStatus, ModelType
from models.exception import (
MaxPollsExceededException,
StackFailedToCreateException,
@@ -81,11 +81,11 @@ def get_container_path(inference_container_type: InferenceContainer) -> str:
return path_mapping[inference_container_type]
-def adjust_initial_capacity_for_schedule(prepared_event: Dict[str, Any]) -> None:
+def adjust_initial_capacity_for_schedule(prepared_event: dict[str, Any]) -> None:
"""Adjust Auto Scaling Group initial capacity based on schedule configuration"""
try:
# Check if scheduling is configured
- auto_scaling_config = prepared_event.get("autoScalingConfig", {})
+ auto_scaling_config = prepared_event.get("autoScalingConfig", {}) or {}
scheduling_config = auto_scaling_config.get("scheduling")
if (
@@ -172,22 +172,20 @@ def adjust_initial_capacity_for_schedule(prepared_event: Dict[str, Any]) -> None
logger.info("Using original capacity settings due to scheduling error")
-def handle_set_model_to_creating(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_set_model_to_creating(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Set DDB entry to CREATING status."""
logger.info(f"Setting model to CREATING status: {event.get('modelId')}")
output_dict = deepcopy(event)
request = CreateModelRequest.model_validate(event)
is_lisa_managed = all(
- (
- bool(request_param)
- for request_param in (
- request.autoScalingConfig,
- request.containerConfig,
- request.inferenceContainer,
- request.instanceType,
- request.loadBalancerConfig,
- )
+ bool(request_param)
+ for request_param in (
+ request.autoScalingConfig,
+ request.containerConfig,
+ request.inferenceContainer,
+ request.instanceType,
+ request.loadBalancerConfig,
)
)
@@ -213,7 +211,7 @@ def handle_set_model_to_creating(event: Dict[str, Any], context: Any) -> Dict[st
return output_dict
-def handle_start_copy_docker_image(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_start_copy_docker_image(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Start process for copying Docker image into local AWS account."""
logger.info(f"Starting Docker image copy for model: {event.get('modelId')}")
output_dict = deepcopy(event)
@@ -282,7 +280,7 @@ def handle_start_copy_docker_image(event: Dict[str, Any], context: Any) -> Dict[
return output_dict
-def handle_poll_docker_image_available(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_docker_image_available(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Check that Docker image is available in account or not."""
output_dict = deepcopy(event)
@@ -320,7 +318,7 @@ def handle_poll_docker_image_available(event: Dict[str, Any], context: Any) -> D
return output_dict
-def handle_start_create_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_start_create_stack(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Start model infrastructure creation."""
output_dict = deepcopy(event)
request = CreateModelRequest.model_validate(event)
@@ -363,7 +361,7 @@ def camelize_object(o): # type: ignore[no-untyped-def]
}
# Remove scheduling configuration from autoScalingConfig before sending to ECS deployer
- if "autoScalingConfig" in prepared_event and "scheduling" in prepared_event["autoScalingConfig"]:
+ if prepared_event.get("autoScalingConfig") and "scheduling" in prepared_event["autoScalingConfig"]:
del prepared_event["autoScalingConfig"]["scheduling"]
# Log the complete payload being sent (excluding large environment variables)
@@ -448,7 +446,7 @@ def camelize_object(o): # type: ignore[no-untyped-def]
return output_dict
-def handle_poll_create_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_create_stack(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Check that model infrastructure creation has completed or not."""
output_dict = deepcopy(event)
stack = cfnClient.describe_stacks(StackName=event["stack_name"])["Stacks"][0]
@@ -486,11 +484,93 @@ def handle_poll_create_stack(event: Dict[str, Any], context: Any) -> Dict[str, A
)
-def handle_add_model_to_litellm(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+autoscaling_client = boto3.client("autoscaling", region_name=os.environ["AWS_REGION"], config=retry_config)
+
+
+def handle_poll_model_ready(event: dict[str, Any], context: Any) -> dict[str, Any]:
+ """
+ Poll ASG to confirm model instances are healthy before marking as InService.
+
+ This handler checks that the Auto Scaling Group has healthy instances running
+ before proceeding to add the model to LiteLLM. This ensures the model is actually
+ ready to serve requests, not just that the infrastructure was created.
+ """
+ output_dict = deepcopy(event)
+ model_id = event.get("modelId", "unknown")
+ asg_name = event.get("autoScalingGroup")
+
+ if not asg_name:
+ logger.warning(f"No ASG name found for model {model_id}, skipping capacity check")
+ output_dict["continue_polling_capacity"] = False
+ output_dict["remaining_capacity_polls"] = 0
+ return output_dict
+
+ logger.info(f"Polling capacity for model {model_id}, ASG: {asg_name}")
+
+ try:
+ asg_info = autoscaling_client.describe_auto_scaling_groups(AutoScalingGroupNames=[asg_name])[
+ "AutoScalingGroups"
+ ][0]
+
+ desired_capacity = asg_info["DesiredCapacity"]
+ instances = asg_info.get("Instances", [])
+ num_healthy_instances = sum(
+ 1
+ for instance in instances
+ if instance.get("HealthStatus") == "Healthy" and instance.get("LifecycleState") == "InService"
+ )
+
+ logger.info(
+ f"ASG {asg_name}: desired={desired_capacity}, healthy_in_service={num_healthy_instances}, "
+ f"total_instances={len(instances)}"
+ )
+
+ # Initialize or decrement remaining polls
+ remaining_polls = event.get("remaining_capacity_polls", 60) - 1 # ~30 minutes at 30s intervals
+ output_dict["remaining_capacity_polls"] = remaining_polls
+
+ if remaining_polls <= 0:
+ logger.error(f"Model '{model_id}' did not start healthy instances in expected amount of time.")
+ # Continue anyway - the model will be added to LiteLLM but may not be ready
+ # This allows the user to see the model and troubleshoot
+ output_dict["continue_polling_capacity"] = False
+ output_dict["capacity_timeout"] = True
+ return output_dict
+
+ # Check if we have the desired number of healthy instances
+ # For scheduled models that start with 0 capacity, we consider them ready
+ if desired_capacity == 0:
+ logger.info(f"Model {model_id} has desired capacity of 0 (scheduled), marking as ready")
+ output_dict["continue_polling_capacity"] = False
+ elif num_healthy_instances >= desired_capacity:
+ logger.info(f"Model {model_id} has {num_healthy_instances}/{desired_capacity} healthy instances, ready!")
+ output_dict["continue_polling_capacity"] = False
+ else:
+ logger.info(
+ f"Model {model_id} waiting for instances: {num_healthy_instances}/{desired_capacity} healthy. "
+ f"Polls remaining: {remaining_polls}"
+ )
+ output_dict["continue_polling_capacity"] = True
+
+ except Exception as e:
+ logger.error(f"Error checking ASG status for model {model_id}: {e}")
+ # On error, continue polling if we have polls remaining
+ remaining_polls = event.get("remaining_capacity_polls", 60) - 1
+ output_dict["remaining_capacity_polls"] = remaining_polls
+ output_dict["continue_polling_capacity"] = remaining_polls > 0
+
+ return output_dict
+
+
+def handle_add_model_to_litellm(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Add model to LiteLLM once it is created."""
output_dict = deepcopy(event)
is_lisa_managed = event["create_infra"]
+ # Check if this is a video generation model
+ model_type = event.get("modelType", "").upper()
+ is_video_model = model_type == ModelType.VIDEOGEN.upper()
+
# Parse the JSON string from environment variable
litellm_config_str = os.environ.get("LITELLM_CONFIG_OBJ", json.dumps({}))
try:
@@ -503,7 +583,14 @@ def handle_add_model_to_litellm(event: Dict[str, Any], context: Any) -> Dict[str
# Only set api_key if it's present in the event
if "apiKey" in event:
litellm_params["api_key"] = event["apiKey"] # pragma: allowlist-secret
- litellm_params["drop_params"] = True # drop unrecognized param instead of failing the request on it
+
+ # For video generation models, use empty litellm_settings to avoid drop_params error
+ if is_video_model:
+ litellm_params = {}
+ if "apiKey" in event:
+ litellm_params["api_key"] = event["apiKey"] # pragma: allowlist-secret
+ else:
+ litellm_params["drop_params"] = True # drop unrecognized param instead of failing the request on it
if is_lisa_managed:
# get load balancer from cloudformation stack
@@ -547,7 +634,7 @@ def handle_add_model_to_litellm(event: Dict[str, Any], context: Any) -> Dict[str
)
# If scheduling is configured, sync model status to ensure it reflects actual ASG state
- scheduling_config = event.get("autoScalingConfig", {}).get("scheduling")
+ scheduling_config = (event.get("autoScalingConfig", {}) or {}).get("scheduling")
auto_scaling_group = event.get("autoScalingGroup")
if scheduling_config and auto_scaling_group:
@@ -562,7 +649,7 @@ def handle_add_model_to_litellm(event: Dict[str, Any], context: Any) -> Dict[str
return output_dict
-def handle_add_guardrails_to_litellm(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_add_guardrails_to_litellm(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Add guardrails to LiteLLM and store them in DynamoDB."""
logger.info(f"Adding guardrails to LiteLLM for model: {event.get('modelId')}")
output_dict = deepcopy(event)
@@ -664,7 +751,7 @@ def handle_add_guardrails_to_litellm(event: Dict[str, Any], context: Any) -> Dic
return output_dict
-def handle_failure(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_failure(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Handle failures from state machine.
diff --git a/lambda/models/state_machine/delete_model.py b/lambda/models/state_machine/delete_model.py
index 73589ed44..721f24df5 100644
--- a/lambda/models/state_machine/delete_model.py
+++ b/lambda/models/state_machine/delete_model.py
@@ -17,7 +17,7 @@
import logging
import os
from copy import deepcopy
-from typing import Any, Dict
+from typing import Any
from uuid import uuid4
import boto3
@@ -54,7 +54,7 @@
LITELLM_ID = "litellm_id"
-def handle_set_model_to_deleting(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_set_model_to_deleting(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Start deletion workflow based on user-specified model input."""
output_dict = deepcopy(event)
model_id = event["modelId"]
@@ -81,14 +81,14 @@ def handle_set_model_to_deleting(event: Dict[str, Any], context: Any) -> Dict[st
return output_dict
-def handle_delete_from_litellm(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_delete_from_litellm(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Delete model reference from LiteLLM."""
if event[LITELLM_ID]: # if non-null ID
litellm_client.delete_model(identifier=event[LITELLM_ID])
return event
-def handle_delete_guardrails(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_delete_guardrails(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Delete all guardrails associated with the model from both LiteLLM and DynamoDB."""
logger.info(f"Deleting guardrails for model: {event.get('modelId')}")
output_dict = deepcopy(event)
@@ -150,7 +150,7 @@ def handle_delete_guardrails(event: Dict[str, Any], context: Any) -> Dict[str, A
return output_dict
-def handle_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_delete_stack(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Initialize stack deletion."""
stack_arn = event[CFN_STACK_ARN]
logger.info(f"Deleting CloudFormation stack: {stack_arn}")
@@ -162,7 +162,7 @@ def handle_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return event # no payload mutations needed between this and next state
-def handle_monitor_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_monitor_delete_stack(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Get stack status while it is being deleted and evaluate if state machine should continue polling."""
output_dict = deepcopy(event)
stack_arn = event[CFN_STACK_ARN]
@@ -179,7 +179,7 @@ def handle_monitor_delete_stack(event: Dict[str, Any], context: Any) -> Dict[str
return output_dict
-def handle_delete_from_ddb(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_delete_from_ddb(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Delete item from DDB after successful deletion workflow."""
model_key = {"model_id": event["modelId"]}
ddb_table.delete_item(Key=model_key)
diff --git a/lambda/models/state_machine/schedule_handlers.py b/lambda/models/state_machine/schedule_handlers.py
index 8923b9058..8efa510ac 100644
--- a/lambda/models/state_machine/schedule_handlers.py
+++ b/lambda/models/state_machine/schedule_handlers.py
@@ -15,7 +15,7 @@
import json
import logging
import os
-from typing import Any, Dict
+from typing import Any
import boto3
from botocore.config import Config
@@ -32,7 +32,7 @@
model_table = dynamodb.Table(os.environ.get("MODEL_TABLE_NAME"))
-def handle_schedule_creation(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_schedule_creation(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Create Auto Scaling scheduled actions for the model if scheduling is configured"""
logger.info(f"Processing schedule creation for model: {event.get('modelId')}")
output_dict = event.copy()
@@ -84,7 +84,7 @@ def handle_schedule_creation(event: Dict[str, Any], context: Any) -> Dict[str, A
return output_dict
-def handle_schedule_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_schedule_update(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Update Auto Scaling scheduled actions when schedule configuration changes"""
logger.info(f"Processing schedule update for model: {event.get('modelId')}")
output_dict = event.copy()
@@ -126,7 +126,7 @@ def handle_schedule_update(event: Dict[str, Any], context: Any) -> Dict[str, Any
return output_dict
-def handle_cleanup_schedule(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_cleanup_schedule(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""Clean up scheduled actions before deleting the model"""
logger.info(f"Cleaning up schedule for model: {event.get('modelId')}")
output_dict = event.copy()
diff --git a/lambda/models/state_machine/update_model.py b/lambda/models/state_machine/update_model.py
index 029caaf4c..bda54faba 100644
--- a/lambda/models/state_machine/update_model.py
+++ b/lambda/models/state_machine/update_model.py
@@ -17,12 +17,13 @@
import json
import logging
import os
+from collections.abc import Callable
from copy import deepcopy
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any
import boto3
from models.clients.litellm_client import LiteLLMClient
-from models.domain_objects import GuardrailsTableEntry, ModelStatus
+from models.domain_objects import GuardrailsTableEntry, ModelStatus, ModelType
from utilities.common_functions import get_cert_path, get_rest_api_container_endpoint, retry_config
from utilities.time import now
@@ -50,15 +51,15 @@
logging.basicConfig(level=logging.INFO)
-def _update_simple_field(model_config: Dict[str, Any], field_name: str, value: Any, model_id: str) -> None:
+def _update_simple_field(model_config: dict[str, Any], field_name: str, value: Any, model_id: str) -> None:
"""Update a simple field in model_config."""
logger.info(f"Setting {field_name} to '{value}' for model '{model_id}'")
model_config[field_name] = value
def _update_container_config(
- model_config: Dict[str, Any], container_config: Dict[str, Any], model_id: str
-) -> Dict[str, Any]:
+ model_config: dict[str, Any], container_config: dict[str, Any], model_id: str
+) -> dict[str, Any]:
"""Handle container config update.
Returns:
@@ -119,7 +120,7 @@ def _update_container_config(
return container_metadata
-def _get_metadata_update_handlers(model_config: Dict[str, Any], model_id: str) -> Dict[str, Callable[..., Any]]:
+def _get_metadata_update_handlers(model_config: dict[str, Any], model_id: str) -> dict[str, Callable[..., Any]]:
"""Return a dictionary mapping field names to their update handlers."""
return {
"modelType": lambda value: _update_simple_field(model_config, "modelType", value, model_id),
@@ -132,8 +133,8 @@ def _get_metadata_update_handlers(model_config: Dict[str, Any], model_id: str) -
def _process_metadata_updates(
- model_config: Dict[str, Any], update_payload: Dict[str, Any], model_id: str
-) -> tuple[bool, Dict[str, Any]]:
+ model_config: dict[str, Any], update_payload: dict[str, Any], model_id: str
+) -> tuple[bool, dict[str, Any]]:
"""
Process metadata updates.
@@ -163,7 +164,7 @@ def _process_metadata_updates(
return has_updates, update_metadata
-def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_job_intake(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Handle initial UpdateModel job submission.
@@ -338,7 +339,7 @@ def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_poll_capacity(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_capacity(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Poll autoscaling and target group to confirm if the capacity is done updating.
@@ -369,7 +370,7 @@ def handle_poll_capacity(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_finish_update(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Finalize update in DDB.
@@ -388,6 +389,10 @@ def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
)["Item"]
model_url = ddb_item["model_url"]
+ # Check if this is a video generation model
+ model_type = ddb_item.get("model_config", {}).get("modelType", "").upper()
+ is_video_model = model_type == ModelType.VIDEOGEN.value.upper()
+
# Parse the JSON string from environment variable
litellm_config_str = os.environ.get("LITELLM_CONFIG_OBJ", json.dumps({}))
try:
@@ -397,11 +402,15 @@ def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
# Fallback to default if JSON parsing fails
litellm_params = {}
+ # For video generation models, use empty litellm_settings to avoid drop_params error
+ if is_video_model:
+ litellm_params = {}
+
litellm_params["model"] = f"openai/{ddb_item['model_config']['modelName']}"
litellm_params["api_base"] = model_url
ddb_update_expression = "SET model_status = :ms, last_modified_date = :lm"
- ddb_update_values: Dict[str, Any] = {
+ ddb_update_values: dict[str, Any] = {
":lm": now(),
}
@@ -441,7 +450,7 @@ def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_update_guardrails(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_update_guardrails(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Update guardrails for a model in LiteLLM and DynamoDB.
@@ -734,9 +743,9 @@ def get_ecs_resources_from_stack(stack_name: str) -> tuple[str, str, str]:
def create_updated_task_definition(
task_definition_arn: str,
- updated_env_vars: Dict[str, str],
- env_vars_to_delete: Optional[List[str]] = None,
- updated_container_config: Optional[Dict[str, Any]] = None,
+ updated_env_vars: dict[str, str],
+ env_vars_to_delete: list[str] | None = None,
+ updated_container_config: dict[str, Any] | None = None,
) -> str:
"""Create new task definition revision with updated environment variables and container config.
@@ -861,7 +870,7 @@ def update_ecs_service(cluster_arn: str, service_arn: str, task_definition_arn:
raise RuntimeError(f"Failed to update ECS service: {str(e)}")
-def handle_ecs_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_ecs_update(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Update ECS task definition with new environment variables and update service.
@@ -909,6 +918,7 @@ def handle_ecs_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
update_ecs_service(cluster_arn, service_arn, new_task_def_arn)
# Set up tracking for deployment monitoring
+ output_dict["old_task_definition_arn"] = task_definition_arn # Save old task def for cleanup
output_dict["new_task_definition_arn"] = new_task_def_arn
output_dict["ecs_service_arn"] = service_arn
output_dict["ecs_cluster_arn"] = cluster_arn
@@ -923,14 +933,15 @@ def handle_ecs_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
return output_dict
-def handle_poll_ecs_deployment(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def handle_poll_ecs_deployment(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Monitor ECS service deployment progress.
This handler will:
1. Check if ECS service deployment is complete
- 2. Return boolean for continued polling if needed
- 3. Handle deployment failures
+ 2. Verify that tasks are actually running and healthy
+ 3. Return boolean for continued polling if needed
+ 4. Handle deployment failures
"""
output_dict = deepcopy(event)
model_id = event["model_id"]
@@ -968,16 +979,49 @@ def handle_poll_ecs_deployment(event: Dict[str, Any], context: Any) -> Dict[str,
and task_def.startswith(new_task_def_arn.split(":")[0])
):
primary_deployment = deployment
+ running_count = deployment.get("runningCount", 0)
+ desired_count = deployment.get("desiredCount", 0)
+ pending_count = deployment.get("pendingCount", 0)
+ rollout_state = deployment.get("rolloutState", "N/A")
+
logger.info(
f"Found matching deployment: status={deployment['status']}, "
- f"rolloutState={deployment.get('rolloutState', 'N/A')}"
+ f"rolloutState={rollout_state}, "
+ f"running={running_count}, desired={desired_count}, pending={pending_count}"
)
- if deployment["status"] != "PRIMARY" or deployment.get("rolloutState") != "COMPLETED":
+
+ # For daemon services, desiredCount may be 0 or match the number of instances
+ # We need to check that:
+ # 1. Deployment is PRIMARY
+ # 2. rolloutState is COMPLETED (or IN_PROGRESS for daemon services)
+ # 3. There are no pending tasks
+ # 4. Running count matches desired count (or running > 0 for daemon services)
+ if deployment["status"] != "PRIMARY":
is_deployment_stable = False
- logger.info(
- f"Deployment not yet stable: status={deployment['status']}, "
- f"rolloutState={deployment.get('rolloutState', 'N/A')}"
- )
+ logger.info(f"Deployment not PRIMARY: status={deployment['status']}")
+ elif rollout_state == "FAILED":
+ logger.error(f"Deployment FAILED for model '{model_id}'")
+ output_dict["ecs_polling_error"] = f"ECS deployment failed for model '{model_id}'"
+ output_dict["should_continue_ecs_polling"] = False
+ return output_dict
+ elif pending_count > 0:
+ is_deployment_stable = False
+ logger.info(f"Deployment has pending tasks: {pending_count}")
+ elif running_count == 0:
+ is_deployment_stable = False
+ logger.info("Deployment has no running tasks yet")
+ elif rollout_state not in ["COMPLETED", None]:
+ # For daemon services, rolloutState might not be COMPLETED immediately
+ # but if we have running tasks and no pending, we can consider it stable
+ if running_count > 0 and pending_count == 0:
+ logger.info(
+ f"Deployment has running tasks ({running_count}) with no pending, "
+ f"considering stable despite rolloutState={rollout_state}"
+ )
+ is_deployment_stable = True
+ else:
+ is_deployment_stable = False
+ logger.info(f"Deployment rolloutState not COMPLETED: {rollout_state}")
else:
logger.info("Deployment is stable and completed")
break
@@ -1004,6 +1048,16 @@ def handle_poll_ecs_deployment(event: Dict[str, Any], context: Any) -> Dict[str,
if is_deployment_stable:
logger.info(f"ECS deployment completed successfully for model '{model_id}'")
+
+ # Deregister old task definition to keep things clean
+ old_task_def_arn = event.get("old_task_definition_arn")
+ if old_task_def_arn:
+ try:
+ ecs_client.deregister_task_definition(taskDefinition=old_task_def_arn)
+ logger.info(f"Deregistered old task definition: {old_task_def_arn}")
+ except Exception as deregister_error:
+ # Log but don't fail - deregistration is cleanup, not critical
+ logger.warning(f"Failed to deregister old task definition {old_task_def_arn}: {deregister_error}")
else:
logger.info(f"ECS deployment still in progress for model '{model_id}', remaining polls: {remaining_polls}")
diff --git a/lambda/prompt_templates/lambda_functions.py b/lambda/prompt_templates/lambda_functions.py
index 1e3f34ba1..62afe3057 100644
--- a/lambda/prompt_templates/lambda_functions.py
+++ b/lambda/prompt_templates/lambda_functions.py
@@ -13,12 +13,14 @@
# limitations under the License.
"""Lambda functions for managing prompt templates in AWS DynamoDB."""
+from __future__ import annotations
+
import json
import logging
import os
from decimal import Decimal
from functools import reduce
-from typing import Any, Dict, List, Optional
+from typing import Any
import boto3
from boto3.dynamodb.conditions import Attr, Key
@@ -35,10 +37,10 @@
def _get_prompt_templates(
- user_id: Optional[str] = None,
- groups: Optional[List] = None,
- latest: Optional[bool] = None,
-) -> Dict[str, Any]:
+ user_id: str | None = None,
+ groups: list[str] | None = None,
+ latest: bool | None = None,
+) -> dict[str, Any]:
"""Helper function to retrieve prompt templates from DynamoDB."""
filter_expression = None
@@ -60,7 +62,7 @@ def _get_prompt_templates(
condition = reduce(lambda a, b: a | b, conditions, condition)
filter_expression = condition if filter_expression is None else filter_expression & condition
- scan_arguments = {
+ scan_arguments: dict[str, Any] = {
"TableName": os.environ["PROMPT_TEMPLATES_TABLE_NAME"],
"IndexName": os.environ["PROMPT_TEMPLATES_BY_LATEST_INDEX_NAME"],
}
@@ -106,7 +108,7 @@ def get(event: dict, context: dict) -> Any:
raise ValueError(f"Not authorized to get {prompt_template_id}.")
-def is_member(user_groups: List[str], prompt_groups: List[str]) -> bool:
+def is_member(user_groups: list[str], prompt_groups: list[str]) -> bool:
if "lisa:public" in prompt_groups:
return True
@@ -114,7 +116,7 @@ def is_member(user_groups: List[str], prompt_groups: List[str]) -> bool:
@api_wrapper
-def list(event: dict, context: dict) -> Dict[str, Any]:
+def list_prompt(event: dict, context: dict) -> dict[str, Any]:
"""List prompt templates for a user from DynamoDB."""
query_params = event.get("queryStringParameters", {})
user_id, is_admin, groups = get_user_context(event)
@@ -186,7 +188,7 @@ def update(event: dict, context: dict) -> Any:
@api_wrapper
-def delete(event: dict, context: dict) -> Dict[str, str]:
+def delete(event: dict, context: dict) -> dict[str, str]:
"""Logically delete a prompt template from DynamoDB."""
user_id, is_admin, _ = get_user_context(event)
prompt_template_id = get_prompt_template_id(event)
diff --git a/lambda/prompt_templates/models.py b/lambda/prompt_templates/models.py
index 6fe7db806..ef0811581 100644
--- a/lambda/prompt_templates/models.py
+++ b/lambda/prompt_templates/models.py
@@ -14,7 +14,7 @@
import uuid
from enum import StrEnum
-from typing import Any, Dict, List, Optional
+from typing import Any
from pydantic import BaseModel, Field
from utilities.time import iso_string
@@ -34,32 +34,32 @@ class PromptTemplateModel(BaseModel):
"""
# Unique identifier for the prompt template
- id: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()))
+ id: str | None = Field(default_factory=lambda: str(uuid.uuid4()))
# Timestamp of when the prompt template was created
- created: Optional[str] = Field(default_factory=iso_string)
+ created: str | None = Field(default_factory=iso_string)
# Owner of the prompt template
owner: str
# List of groups that have access to the prompt template
- groups: List[str] = Field(default=[])
+ groups: list[str] = Field(default=[])
# Title of the prompt template
title: str
# Current revision number of the prompt template
- revision: Optional[int] = Field(default=1)
+ revision: int | None = Field(default=1)
# Flag indicating if this is the latest revision
- latest: Optional[bool] = Field(default=True)
+ latest: bool | None = Field(default=True)
type: PromptTemplateType = Field(default=PromptTemplateType.PERSONA)
# The main body content of the prompt template
body: str
- def new_revision(self, update: Dict[str, Any]) -> "PromptTemplateModel":
+ def new_revision(self, update: dict[str, Any]) -> "PromptTemplateModel":
"""
Create a new revision of the current prompt template.
@@ -69,6 +69,7 @@ def new_revision(self, update: Dict[str, Any]) -> "PromptTemplateModel":
Returns:
PromptTemplateModel: A new instance of PromptTemplateModel with updated attributes.
"""
- return self.model_copy( # type: ignore
+ result: PromptTemplateModel = self.model_copy(
update=update | {"created": iso_string(), "revision": (self.revision or 0) + 1}
)
+ return result
diff --git a/lambda/repository/collection_repo.py b/lambda/repository/collection_repo.py
index 2ed367d89..94ecd6374 100644
--- a/lambda/repository/collection_repo.py
+++ b/lambda/repository/collection_repo.py
@@ -16,7 +16,7 @@
import logging
import os
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any
import boto3
from boto3.dynamodb.conditions import Attr, Key
@@ -38,7 +38,7 @@ class CollectionRepositoryError(Exception):
class CollectionRepository:
"""Collection repository for DynamoDB operations."""
- def __init__(self, table_name: Optional[str] = None) -> None:
+ def __init__(self, table_name: str | None = None) -> None:
"""
Initialize the Collection Repository.
@@ -96,7 +96,7 @@ def create(self, collection: RagCollectionConfig) -> RagCollectionConfig:
logger.error(f"Unexpected error creating collection: {e}")
raise CollectionRepositoryError(f"Unexpected error creating collection: {str(e)}")
- def find_by_id(self, collection_id: str, repository_id: str) -> Optional[RagCollectionConfig]:
+ def find_by_id(self, collection_id: str, repository_id: str) -> RagCollectionConfig | None:
"""
Find a collection by its ID and repository ID.
@@ -133,8 +133,8 @@ def update(
self,
collection_id: str,
repository_id: str,
- updates: Dict[str, Any],
- expected_version: Optional[str] = None,
+ updates: dict[str, Any],
+ expected_version: str | None = None,
) -> RagCollectionConfig:
"""
Update a collection with optimistic locking.
@@ -250,12 +250,12 @@ def list_by_repository(
self,
repository_id: str,
page_size: int = 20,
- last_evaluated_key: Optional[Dict[str, str]] = None,
- filter_text: Optional[str] = None,
- status_filter: Optional[CollectionStatus] = None,
+ last_evaluated_key: dict[str, str] | None = None,
+ filter_text: str | None = None,
+ status_filter: CollectionStatus | None = None,
sort_by: CollectionSortBy = CollectionSortBy.CREATED_AT,
sort_order: SortOrder = SortOrder.DESC,
- ) -> Tuple[List[RagCollectionConfig], Optional[Dict[str, str]]]:
+ ) -> tuple[list[RagCollectionConfig], dict[str, str] | None]:
"""
List collections for a repository with pagination, filtering, and sorting.
@@ -332,7 +332,7 @@ def list_by_repository(
logger.error(f"Failed to list collections for repository {repository_id}: {e}")
raise CollectionRepositoryError(f"Failed to list collections: {str(e)}")
- def count_by_repository(self, repository_id: str, status: Optional[CollectionStatus] = None) -> int:
+ def count_by_repository(self, repository_id: str, status: CollectionStatus | None = None) -> int:
"""
Count collections in a repository.
@@ -362,13 +362,13 @@ def count_by_repository(self, repository_id: str, status: Optional[CollectionSta
count = response.get("Count", 0)
logger.info(f"Counted {count} collections for repository {repository_id}")
- return count
+ return count # type: ignore[no-any-return]
except Exception as e:
logger.error(f"Failed to count collections for repository {repository_id}: {e}")
raise CollectionRepositoryError(f"Failed to count collections: {str(e)}")
- def find_by_name(self, repository_id: str, collection_name: str) -> Optional[RagCollectionConfig]:
+ def find_by_name(self, repository_id: str, collection_name: str) -> RagCollectionConfig | None:
"""
Find a collection by repository ID and name.
@@ -402,7 +402,7 @@ def find_by_name(self, repository_id: str, collection_name: str) -> Optional[Rag
logger.error(f"Failed to find collection by name '{collection_name}': {e}")
raise CollectionRepositoryError(f"Failed to find collection by name: {str(e)}")
- def find_collections_using_model(self, model_id: str) -> List[Dict[str, str]]:
+ def find_collections_using_model(self, model_id: str) -> list[dict[str, str]]:
"""
Find all collections that use a specific embedding model.
Excludes collections with status indicating they are deleted or archived.
diff --git a/lambda/repository/collection_service.py b/lambda/repository/collection_service.py
index 4750fee6c..1debd8019 100644
--- a/lambda/repository/collection_service.py
+++ b/lambda/repository/collection_service.py
@@ -18,7 +18,7 @@
import heapq
import logging
import os
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any
import boto3
from models.domain_objects import (
@@ -52,9 +52,9 @@ class CollectionService:
def __init__(
self,
- collection_repo: Optional[CollectionRepository] = None,
- vector_store_repo: Optional[VectorStoreRepository] = None,
- document_repo: Optional[RagDocumentRepository] = None,
+ collection_repo: CollectionRepository | None = None,
+ vector_store_repo: VectorStoreRepository | None = None,
+ document_repo: RagDocumentRepository | None = None,
):
self.collection_repo = collection_repo or CollectionRepository()
self.vector_store_repo = vector_store_repo or VectorStoreRepository()
@@ -66,7 +66,7 @@ def has_access(
self,
collection: RagCollectionConfig,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
require_write: bool = False,
) -> bool:
@@ -109,7 +109,7 @@ def create_collection(
"""
# Check if collection name already exists in this repository
- existing = self.collection_repo.find_by_name(collection.repositoryId, collection.name)
+ existing = self.collection_repo.find_by_name(collection.repositoryId, collection.name) # type: ignore[arg-type]
if existing:
raise ValidationError(
f"Collection with name '{collection.name}' already exists in repository '{collection.repositoryId}'"
@@ -125,7 +125,7 @@ def get_collection(
repository_id: str,
collection_id: str,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
) -> RagCollectionConfig:
"""Get a collection with access control.
@@ -148,11 +148,11 @@ def list_collections(
self,
repository_id: str,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
page_size: int = 20,
- last_evaluated_key: Optional[Dict[str, str]] = None,
- ) -> Tuple[List[RagCollectionConfig], Optional[Dict[str, str]]]:
+ last_evaluated_key: dict[str, str] | None = None,
+ ) -> tuple[list[RagCollectionConfig], dict[str, str] | None]:
"""List collections with access control.
For Bedrock KB repositories, default collections are persisted to the database
@@ -189,7 +189,7 @@ def update_collection(
repository_id: str,
collection_data: Any,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
) -> RagCollectionConfig:
"""Update a collection with access control and name uniqueness validation.
@@ -259,12 +259,12 @@ def update_collection(
def delete_collection(
self,
repository_id: str,
- collection_id: Optional[str],
- embedding_name: Optional[str],
+ collection_id: str | None,
+ embedding_name: str | None,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""Delete a collection with access control.
Args:
@@ -293,6 +293,9 @@ def delete_collection(
# For regular collections, verify access and update status
if not is_default_collection:
+ if collection_id is None:
+ raise ValidationError("collection_id is required for non-default collections")
+
collection = self.collection_repo.find_by_id(collection_id, repository_id)
if not collection:
raise ValidationError(f"Collection {collection_id} not found")
@@ -300,7 +303,11 @@ def delete_collection(
raise ValidationError(f"Permission denied to delete collection {collection_id}")
# Update collection status to DELETE_IN_PROGRESS
- self.collection_repo.update(collection_id, repository_id, {"status": CollectionStatus.DELETE_IN_PROGRESS})
+ self.collection_repo.update(
+ collection_id,
+ repository_id,
+ {"status": CollectionStatus.DELETE_IN_PROGRESS},
+ )
embedding_model = None # Don't set embedding_model for regular collections
else:
@@ -354,7 +361,7 @@ def delete_collection(
# Add summary if counts available
if lisa_managed_count is not None and user_managed_count is not None:
- response["summary"] = {
+ response["summary"] = { # type: ignore[assignment]
"lisaManagedDocuments": lisa_managed_count,
"userManagedDocuments": user_managed_count,
"action": (
@@ -369,8 +376,12 @@ def delete_collection(
logger.error(f"Failed to submit deletion job: {e}", exc_info=True)
# Update collection status to DELETE_FAILED (only for regular collections)
- if not is_default_collection:
- self.collection_repo.update(collection_id, repository_id, {"status": CollectionStatus.DELETE_FAILED})
+ if not is_default_collection and collection_id is not None:
+ self.collection_repo.update(
+ collection_id,
+ repository_id,
+ {"status": CollectionStatus.DELETE_FAILED},
+ )
raise
@@ -379,7 +390,7 @@ def get_collection_by_name(
repository_id: str,
collection_name: str,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
) -> RagCollectionConfig:
"""Get a collection by name with access control."""
@@ -407,9 +418,9 @@ def get_collection_model(
repository_id: str,
collection_id: str,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
- ) -> Optional[str]:
+ ) -> str | None:
"""Get embedding model from collection or repository default.
Args:
@@ -439,13 +450,13 @@ def get_collection_model(
def list_all_user_collections(
self,
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
page_size: int = 20,
- pagination_token: Optional[Dict[str, Any]] = None,
- filter_text: Optional[str] = None,
- sort_params: Optional[SortParams] = None,
- ) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
+ pagination_token: dict[str, Any] | None = None,
+ filter_text: str | None = None,
+ sort_params: SortParams | None = None,
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""
List all collections user has access to across all repositories.
@@ -473,7 +484,8 @@ def list_all_user_collections(
logger.info(
f"Listing all user collections for user={username}, is_admin={is_admin}, "
- f"page_size={page_size}, filter={filter_text}, sort_by={sort_params.sort_by.value}"
+ f"page_size={page_size}, filter={filter_text}, "
+ f"sort_by={sort_params.sort_by.value}" # type: ignore[union-attr]
)
# Get repositories user can access
@@ -504,8 +516,8 @@ def list_all_user_collections(
return collections, next_token
def _get_accessible_repositories(
- self, username: str, user_groups: List[str], is_admin: bool
- ) -> List[Dict[str, Any]]:
+ self, username: str, user_groups: list[str], is_admin: bool
+ ) -> list[dict[str, Any]]:
"""
Get all repositories user has access to.
@@ -527,7 +539,7 @@ def _get_accessible_repositories(
logger.debug(f"User has access to {len(accessible)} of {len(all_repos)} repositories")
return accessible
- def _has_repository_access(self, user_groups: List[str], repository: Dict[str, Any]) -> bool:
+ def _has_repository_access(self, user_groups: list[str], repository: dict[str, Any]) -> bool:
"""
Check if user has access to repository based on groups.
@@ -553,8 +565,8 @@ def _has_repository_access(self, user_groups: List[str], repository: Dict[str, A
return has_access
def _enrich_with_repository_metadata(
- self, collections: List[RagCollectionConfig], repositories: List[Dict[str, Any]]
- ) -> List[Dict[str, Any]]:
+ self, collections: list[RagCollectionConfig], repositories: list[dict[str, Any]]
+ ) -> list[dict[str, Any]]:
"""
Enrich collections with repository metadata.
@@ -586,7 +598,7 @@ def _enrich_with_repository_metadata(
return enriched
- def _estimate_total_collections(self, repositories: List[Dict[str, Any]]) -> int:
+ def _estimate_total_collections(self, repositories: list[dict[str, Any]]) -> int:
"""
Estimate total number of collections across repositories.
@@ -609,15 +621,15 @@ def _estimate_total_collections(self, repositories: List[Dict[str, Any]]) -> int
def _paginate_collections(
self,
- repositories: List[Dict[str, Any]],
+ repositories: list[dict[str, Any]],
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
page_size: int,
- pagination_token: Optional[Dict[str, Any]],
- filter_text: Optional[str],
+ pagination_token: dict[str, Any] | None,
+ filter_text: str | None,
sort_params: SortParams,
- ) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""
Simple pagination strategy for small-to-medium deployments.
@@ -652,7 +664,7 @@ def _paginate_collections(
offset = 0
# Aggregate all collections from accessible repositories
- all_collections: List[RagCollectionConfig] = []
+ all_collections: list[RagCollectionConfig] = []
for repo in repositories:
repo_id = repo["repositoryId"]
@@ -709,8 +721,8 @@ def _paginate_collections(
"offset": end_idx,
"filters": {
"filter": filter_text,
- "sortBy": sort_params.sort_by.value,
- "sortOrder": sort_params.sort_order.value,
+ "sortBy": sort_params.sort_by.value, # type: ignore[union-attr]
+ "sortOrder": sort_params.sort_order.value, # type: ignore[union-attr]
},
}
@@ -740,8 +752,8 @@ def _matches_filter(self, collection: RagCollectionConfig, filter_text: str) ->
return False
def _sort_collections(
- self, collections: List[RagCollectionConfig], sort_params: SortParams
- ) -> List[RagCollectionConfig]:
+ self, collections: list[RagCollectionConfig], sort_params: SortParams
+ ) -> list[RagCollectionConfig]:
"""
Sort collections by specified field and order.
@@ -763,15 +775,15 @@ def _sort_collections(
def _paginate_large_collections(
self,
- repositories: List[Dict[str, Any]],
+ repositories: list[dict[str, Any]],
username: str,
- user_groups: List[str],
+ user_groups: list[str],
is_admin: bool,
page_size: int,
- pagination_token: Optional[Dict[str, Any]],
- filter_text: Optional[str],
+ pagination_token: dict[str, Any] | None,
+ filter_text: str | None,
sort_params: SortParams,
- ) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
+ ) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""
Scalable pagination strategy for large deployments.
@@ -803,8 +815,8 @@ def _paginate_large_collections(
token_filters = pagination_token.get("filters", {})
if (
token_filters.get("filter") != filter_text
- or token_filters.get("sortBy") != sort_params.sort_by.value
- or token_filters.get("sortOrder") != sort_params.sort_order.value
+ or token_filters.get("sortBy") != sort_params.sort_by.value # type: ignore[union-attr]
+ or token_filters.get("sortOrder") != sort_params.sort_order.value # type: ignore[union-attr]
):
logger.warning("Pagination token filters don't match, resetting cursors")
cursors = {}
@@ -878,7 +890,11 @@ def _paginate_large_collections(
cursors[repo_id]["exhausted"] = True
# Merge batches using heap for efficient sorting
- merged = self._merge_sorted_batches(batches, sort_params.sort_by.value, sort_params.sort_order.value)
+ merged = self._merge_sorted_batches(
+ batches,
+ sort_params.sort_by.value, # type: ignore[union-attr]
+ sort_params.sort_order.value, # type: ignore[union-attr]
+ )
# Extract requested page
start_idx = global_offset
@@ -907,16 +923,16 @@ def _paginate_large_collections(
"seenCollectionIds": serializable_seen_ids,
"filters": {
"filter": filter_text,
- "sortBy": sort_params.sort_by.value,
- "sortOrder": sort_params.sort_order.value,
+ "sortBy": sort_params.sort_by.value, # type: ignore[union-attr]
+ "sortOrder": sort_params.sort_order.value, # type: ignore[union-attr]
},
}
return enriched, next_token
def _merge_sorted_batches(
- self, batches: List[Dict[str, Any]], sort_by: str, sort_order: str
- ) -> List[RagCollectionConfig]:
+ self, batches: list[dict[str, Any]], sort_by: str, sort_order: str
+ ) -> list[RagCollectionConfig]:
"""
Merge pre-sorted batches from multiple repositories using min-heap.
@@ -935,7 +951,7 @@ def _merge_sorted_batches(
return []
# Create heap with first item from each batch
- heap: List[Tuple[Any, str, int, Dict[str, Any]]] = []
+ heap: list[tuple[Any, str, int, dict[str, Any]]] = []
for batch in batches:
if batch["collections"]:
diff --git a/lambda/repository/config/params.py b/lambda/repository/config/params.py
index 35e03ce92..4b65e4e85 100644
--- a/lambda/repository/config/params.py
+++ b/lambda/repository/config/params.py
@@ -17,7 +17,7 @@
import json
import urllib.parse
from dataclasses import dataclass
-from typing import Any, Dict, Optional
+from typing import Any
from utilities.constants import DEFAULT_PAGE_SIZE, DEFAULT_TIME_LIMIT_HOURS, MAX_PAGE_SIZE, MIN_PAGE_SIZE
from utilities.validation import ValidationError
@@ -29,11 +29,11 @@ class ListJobsParams:
repository_id: str
page_size: int = 10
- last_evaluated_key: Optional[Dict[str, Any]] = None
+ last_evaluated_key: dict[str, Any] | None = None
time_limit_hours: int = DEFAULT_TIME_LIMIT_HOURS
@classmethod
- def from_event(cls, event: Dict[str, Any]) -> "ListJobsParams":
+ def from_event(cls, event: dict[str, Any]) -> "ListJobsParams":
"""Extract and validate parameters from Lambda event."""
path_params = event.get("pathParameters", {})
query_params = event.get("queryStringParameters", {}) or {}
@@ -50,25 +50,25 @@ def from_event(cls, event: Dict[str, Any]) -> "ListJobsParams":
)
@staticmethod
- def _parse_time_limit(query_params: Dict[str, str]) -> int:
+ def _parse_time_limit(query_params: dict[str, str]) -> int:
"""Parse time limit from query parameters."""
return int(query_params.get("timeLimit", str(DEFAULT_TIME_LIMIT_HOURS)))
@staticmethod
- def _parse_page_size(query_params: Dict[str, str]) -> int:
+ def _parse_page_size(query_params: dict[str, str]) -> int:
"""Parse and validate page size from query parameters."""
page_size = int(query_params.get("pageSize", str(DEFAULT_PAGE_SIZE)))
return max(MIN_PAGE_SIZE, min(page_size, MAX_PAGE_SIZE))
@staticmethod
- def _parse_last_evaluated_key(query_params: Dict[str, str]) -> Optional[Dict[str, str]]:
+ def _parse_last_evaluated_key(query_params: dict[str, str]) -> dict[str, str] | None:
"""Parse lastEvaluatedKey with specific error handling."""
if "lastEvaluatedKey" not in query_params:
return None
try:
decoded = urllib.parse.unquote(query_params["lastEvaluatedKey"])
- return json.loads(decoded)
+ return json.loads(decoded) # type: ignore[no-any-return]
except json.JSONDecodeError as e:
raise ValidationError(f"Invalid JSON in lastEvaluatedKey: {e}")
except (TypeError, ValueError) as e:
diff --git a/lambda/repository/embeddings.py b/lambda/repository/embeddings.py
index b691783bf..666d48845 100644
--- a/lambda/repository/embeddings.py
+++ b/lambda/repository/embeddings.py
@@ -14,11 +14,13 @@
import logging
import os
-from typing import List
+from typing import Any
import boto3
import requests
-from pydantic import BaseModel, field_validator
+from pydantic import BaseModel, ConfigDict, field_validator
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
from utilities.auth import get_management_key
from utilities.common_functions import get_cert_path, get_rest_api_container_endpoint, retry_config
from utilities.validation import validate_model_name, ValidationError
@@ -30,12 +32,39 @@
lisa_api_endpoint = ""
+# Module-level session with connection pooling for better performance
+# This reuses TCP connections across multiple embedding requests
+_http_session: requests.Session | None = None
+
+
+def _get_http_session() -> requests.Session:
+ """Get or create a shared HTTP session with connection pooling."""
+ global _http_session
+ if _http_session is None:
+ _http_session = requests.Session()
+ # Configure retry strategy for transient failures
+ retry_strategy = Retry(
+ total=2,
+ backoff_factor=0.5,
+ status_forcelist=[502, 503, 504],
+ )
+ adapter = HTTPAdapter(
+ pool_connections=10, # Number of connection pools
+ pool_maxsize=20, # Max connections per pool
+ max_retries=retry_strategy,
+ )
+ _http_session.mount("http://", adapter)
+ _http_session.mount("https://", adapter)
+ return _http_session
+
class RagEmbeddings(BaseModel):
"""
Handles document embeddings through LiteLLM using management credentials.
"""
+ model_config = ConfigDict(arbitrary_types_allowed=True)
+
model_name: str
token: str
lisa_api_endpoint: str
@@ -48,7 +77,7 @@ def validate_model_name(cls, v: str) -> str:
validate_model_name(v)
return v
- def __init__(self, model_name: str, id_token: str | None = None, **data) -> None:
+ def __init__(self, model_name: str, id_token: str | None = None, **data: Any) -> None:
# Prepare initialization data
init_data = {"model_name": model_name, **data}
try:
@@ -69,10 +98,7 @@ def __init__(self, model_name: str, id_token: str | None = None, **data) -> None
logger.error("Failed to initialize pipeline embeddings", exc_info=True)
raise
- class Config:
- arbitrary_types_allowed = True
-
- def embed_documents(self, texts: List[str]) -> List[List[float]]:
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for a list of documents.
@@ -92,8 +118,12 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
logger.info(f"Embedding {len(texts)} documents using {self.model_name}")
try:
url = f"{self.base_url}/embeddings"
- request_data = {"input": texts, "model": self.model_name}
- response = requests.post(
+ # 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()
+ response = session.post(
url,
json=request_data,
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
@@ -103,6 +133,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
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()
@@ -149,7 +180,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
logger.error(f"Failed to get embeddings: {str(e)}", exc_info=True)
raise
- def embed_query(self, text: str) -> List[float]:
+ def embed_query(self, text: str) -> list[float]:
if not text or not isinstance(text, str):
raise ValidationError("Invalid query text")
diff --git a/lambda/repository/ingestion_job_repo.py b/lambda/repository/ingestion_job_repo.py
index 21f536220..bdfa6351c 100644
--- a/lambda/repository/ingestion_job_repo.py
+++ b/lambda/repository/ingestion_job_repo.py
@@ -17,7 +17,7 @@
import logging
import os
from datetime import timedelta
-from typing import Dict, Optional
+from typing import Any
import boto3
from models.domain_objects import IngestionJob, IngestionStatus
@@ -27,14 +27,14 @@
logger = logging.getLogger(__name__)
-def _get_ingestion_job_table():
+def _get_ingestion_job_table() -> Any:
"""Lazy initialization of DynamoDB table."""
dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config)
return dynamodb.Table(os.environ["LISA_INGESTION_JOB_TABLE_NAME"])
class IngestionJobListResponse:
- def __init__(self, jobs: list[IngestionJob], continuation_token: Optional[str]):
+ def __init__(self, jobs: list[IngestionJob], continuation_token: str | None):
self.jobs = jobs
self.continuation_token = continuation_token
@@ -52,25 +52,25 @@ def __init__(self, message: str):
class IngestionJobRepository:
- def __init__(self):
- self._ddb_client = None
- self._table_name = None
- self._batch_client = None
+ def __init__(self) -> None:
+ self._ddb_client: Any = None
+ self._table_name: str | None = None
+ self._batch_client: Any = None
@property
- def ddb_client(self):
+ def ddb_client(self) -> Any:
if self._ddb_client is None:
self._ddb_client = boto3.client("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config)
return self._ddb_client
@property
- def table_name(self):
+ def table_name(self) -> str:
if self._table_name is None:
self._table_name = os.environ["LISA_INGESTION_JOB_TABLE_NAME"]
return self._table_name
@property
- def batch_client(self):
+ def batch_client(self) -> Any:
if self._batch_client is None:
self._batch_client = boto3.client("batch", region_name=os.environ["AWS_REGION"], config=retry_config)
return self._batch_client
@@ -94,7 +94,7 @@ def find_by_path(self, s3_path: str) -> list[IngestionJob]:
items = response.get("Items", [])
return [IngestionJob(**item) for item in items]
- def find_by_document(self, document_id: str) -> Optional[IngestionJob]:
+ def find_by_document(self, document_id: str) -> IngestionJob | None:
response = _get_ingestion_job_table().query(
IndexName="documentId",
KeyConditionExpression="document_id = :document_id",
@@ -129,8 +129,8 @@ def list_jobs_by_repository(
is_admin: bool,
time_limit_hours: int = 1,
page_size: int = 10,
- last_evaluated_key: Optional[Dict[str, str]] = None,
- ) -> tuple[list[IngestionJob], Optional[Dict[str, str]]]:
+ last_evaluated_key: dict[str, str] | None = None,
+ ) -> tuple[list[IngestionJob], dict[str, str] | None]:
"""List ingestion jobs filtered by repository, user permissions, and time limit with pagination.
Args:
@@ -164,7 +164,9 @@ def list_jobs_by_repository(
# Add username filter for non-admin users
if not is_admin:
query_params["FilterExpression"] = "username = :username"
- query_params["ExpressionAttributeValues"].update({":username": username, ":system_username": "system"})
+ expr_attr_values = query_params["ExpressionAttributeValues"]
+ if isinstance(expr_attr_values, dict):
+ expr_attr_values.update({":username": username, ":system_username": "system"})
# Add pagination token if provided
if last_evaluated_key:
@@ -188,7 +190,7 @@ def list_jobs_by_repository(
return jobs, last_evaluated_key_response
- def get_batch_job_status(self, job_id: str) -> Optional[str]:
+ def get_batch_job_status(self, job_id: str) -> str | None:
"""Get the status of a batch job by job ID.
Args:
@@ -199,10 +201,10 @@ def get_batch_job_status(self, job_id: str) -> Optional[str]:
"""
response = self.batch_client.describe_jobs(jobs=[job_id])
if response.get("jobs"):
- return response["jobs"][0].get("status")
+ return response["jobs"][0].get("status") # type: ignore[no-any-return]
return None
- def find_batch_job_for_document(self, document_id: str, job_queue: str) -> Optional[Dict]:
+ def find_batch_job_for_document(self, document_id: str, job_queue: str) -> dict | None:
"""Find the batch job associated with a document ingestion.
Args:
diff --git a/lambda/repository/ingestion_service.py b/lambda/repository/ingestion_service.py
index b622891be..f544038c9 100644
--- a/lambda/repository/ingestion_service.py
+++ b/lambda/repository/ingestion_service.py
@@ -13,7 +13,7 @@
# limitations under the License.
import logging
import os
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
from models.domain_objects import Enum, FixedChunkingStrategy, IngestDocumentRequest, IngestionJob, IngestionType
@@ -48,12 +48,12 @@ def create_delete_job(self, job: IngestionJob) -> None:
def create_ingestion_job(
self,
repository: dict,
- collection: Optional[dict],
+ collection: dict | None,
request: IngestDocumentRequest,
query_params: dict,
s3_path: str,
username: str,
- metadata: Optional[Dict[str, Any]] = None,
+ metadata: dict[str, Any] | None = None,
ingestion_type: IngestionType = IngestionType.MANUAL,
) -> IngestionJob:
@@ -113,9 +113,9 @@ def create_ingestion_job(
def _merge_metadata_for_ingestion(
self,
repository: dict,
- collection: Optional[dict],
- document_metadata: Optional[Dict[str, Any]] = None,
- ) -> Optional[Dict[str, Any]]:
+ collection: dict | None,
+ document_metadata: dict[str, Any] | None = None,
+ ) -> dict[str, Any] | None:
"""
Merge metadata from repository, collection, and document sources for ingestion jobs.
@@ -133,7 +133,7 @@ def _merge_metadata_for_ingestion(
Returns:
Merged metadata dictionary or None if no metadata sources exist
"""
- merged_metadata: Dict[str, Any] = {}
+ merged_metadata: dict[str, Any] = {}
# 1. Merge repository metadata (lowest precedence)
repo_metadata = repository.get("metadata")
diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py
index bc095f680..c9133d05f 100644
--- a/lambda/repository/lambda_functions.py
+++ b/lambda/repository/lambda_functions.py
@@ -19,7 +19,7 @@
import os
import urllib.parse
from types import SimpleNamespace
-from typing import Any, cast, Dict, List, Optional
+from typing import Any, cast
import boto3
from boto3.dynamodb.types import TypeSerializer
@@ -89,7 +89,7 @@
@api_wrapper
-def list_all(event: dict, context: dict) -> List[Dict[str, Any]]:
+def list_all(event: dict, context: dict) -> list[dict[str, Any]]:
"""
List all available repositories that the user has access to.
@@ -122,7 +122,7 @@ def list_status(event: dict, context: dict) -> dict[str, Any]:
@api_wrapper
-def similarity_search(event: dict, context: dict) -> Dict[str, Any]:
+def similarity_search(event: dict, context: dict) -> dict[str, Any]:
"""Return documents matching the query.
Conducts similarity search against the vector store returning the top K
@@ -149,11 +149,11 @@ def similarity_search(event: dict, context: dict) -> Dict[str, Any]:
"""
query_string_params = event.get("queryStringParameters")
path_params = event.get("pathParameters")
- query = query_string_params.get("query")
- top_k = int(query_string_params.get("topK", 3))
- include_score = query_string_params.get("score", "false").lower() == "true"
- repository_id = path_params.get("repositoryId")
- collection_id = query_string_params.get("collectionId")
+ query = query_string_params.get("query") # type: ignore[union-attr]
+ top_k = int(query_string_params.get("topK", 3)) # type: ignore[union-attr]
+ include_score = query_string_params.get("score", "false").lower() == "true" # type: ignore[union-attr]
+ repository_id = path_params.get("repositoryId") # type: ignore[union-attr]
+ collection_id = query_string_params.get("collectionId") # type: ignore[union-attr]
repository = get_repository(event, repository_id=repository_id)
@@ -165,13 +165,13 @@ def similarity_search(event: dict, context: dict) -> Dict[str, Any]:
model_name = (
collection_service.get_collection_model(
repository_id=repository_id,
- collection_id=collection_id if not is_default else None,
+ collection_id=collection_id if not is_default else None, # type: ignore[arg-type]
username=username,
user_groups=groups,
is_admin=is_admin,
)
if collection_id
- else query_string_params.get("modelName")
+ else query_string_params.get("modelName") # type: ignore[union-attr]
)
if RepositoryType.is_type(repository, RepositoryType.BEDROCK_KB):
@@ -191,9 +191,9 @@ def similarity_search(event: dict, context: dict) -> Dict[str, Any]:
# Delegate to service for retrieval - service handles repository-specific logic
docs = service.retrieve_documents(
query=query,
- collection_id=search_collection_id,
+ collection_id=search_collection_id, # type: ignore[arg-type]
top_k=top_k,
- model_name=model_name,
+ model_name=model_name, # type: ignore[arg-type]
include_score=include_score,
bedrock_agent_client=bedrock_client,
)
@@ -229,7 +229,7 @@ def get_repository(event: dict[str, Any], repository_id: str) -> dict[str, Any]:
return repo
-def create_bedrock_collection(event: dict, context: dict) -> Dict[str, Any]:
+def create_bedrock_collection(event: dict, context: dict) -> dict[str, Any]:
"""
Create collections for a Bedrock Knowledge Base repository based on pipeline configurations.
This is called by the state machine during repository creation.
@@ -311,7 +311,7 @@ def create_bedrock_collection(event: dict, context: dict) -> Dict[str, Any]:
)
# Create collection using service helper
- collection = service._create_collection_for_data_source(
+ collection = service._create_collection_for_data_source( # type: ignore[attr-defined]
data_source_id=collection_id, s3_uri=s3_uri, is_default=False, collection_name=collection_name
)
@@ -366,7 +366,7 @@ def create_bedrock_collection(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
@admin_only
-def create_collection(event: dict, context: dict) -> Dict[str, Any]:
+def create_collection(event: dict, context: dict) -> dict[str, Any]:
"""
Create a new collection within a vector store.
@@ -434,7 +434,7 @@ def create_collection(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
-def get_collection(event: dict, context: dict) -> Dict[str, Any]:
+def get_collection(event: dict, context: dict) -> dict[str, Any]:
"""
Get a collection by ID within a vector store.
@@ -493,7 +493,7 @@ def get_collection(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
@admin_only
-def update_collection(event: dict, context: dict) -> Dict[str, Any]:
+def update_collection(event: dict, context: dict) -> dict[str, Any]:
"""
Update a collection within a vector store.
@@ -555,7 +555,7 @@ def update_collection(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
@admin_only
-def delete_collection(event: dict, context: dict) -> Dict[str, Any]:
+def delete_collection(event: dict, context: dict) -> dict[str, Any]:
"""
Delete a collection (regular or default) within a vector store.
@@ -598,7 +598,7 @@ def delete_collection(event: dict, context: dict) -> Dict[str, Any]:
is_default_collection = repo.get("embeddingModelId") == collection_id
# Delete collection via service
- result: Dict[str, Any] = collection_service.delete_collection(
+ result: dict[str, Any] = collection_service.delete_collection(
repository_id=repository_id,
collection_id=collection_id, # None for default collections
embedding_name=embedding_name if is_default_collection else None, # None for regular collections
@@ -611,7 +611,7 @@ def delete_collection(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
-def list_collections(event: dict, context: dict) -> Dict[str, Any]:
+def list_collections(event: dict, context: dict) -> dict[str, Any]:
"""
List collections in a repository with pagination, filtering, and sorting.
@@ -724,7 +724,7 @@ def list_collections(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
-def list_user_collections(event: dict, context: dict) -> Dict[str, Any]:
+def list_user_collections(event: dict, context: dict) -> dict[str, Any]:
"""
List all collections user has access to across all repositories.
@@ -825,7 +825,7 @@ def _ensure_document_ownership(event: dict[str, Any], docs: list[RagDocument]) -
@api_wrapper
-def delete_documents(event: dict, context: dict) -> Dict[str, Any]:
+def delete_documents(event: dict, context: dict) -> dict[str, Any]:
"""Purge all records related to the specified document from the RAG repository. If a documentId is supplied, a
single document will be removed. If a documentName is supplied, all documents with that name will be removed
@@ -864,7 +864,14 @@ def delete_documents(event: dict, context: dict) -> Dict[str, Any]:
rag_documents: list[RagDocument] = []
if document_ids:
- rag_documents = [doc_repo.find_by_id(document_id=document_id) for document_id in document_ids]
+ rag_documents = [
+ doc
+ for doc in (
+ doc_repo.find_by_id(document_id=document_id)
+ for document_id in document_ids # type: ignore[arg-type,unused-ignore]
+ )
+ if doc is not None
+ ]
if not rag_documents:
raise ValueError(f"No documents found in repository collection {repository_id}:{collection_id}")
@@ -966,7 +973,7 @@ def ingest_documents(event: dict, context: dict) -> dict:
repository = get_repository(event, repository_id=repository_id)
# Get collection if specified
- collection: Optional[dict[str, Any]] = None
+ collection: dict[str, Any] | None = None
if request.collectionId and request.collectionId != repository.get("embeddingModelId"):
collection = collection_service.get_collection(
collection_id=request.collectionId,
@@ -998,7 +1005,10 @@ def ingest_documents(event: dict, context: dict) -> dict:
# Upload metadata file
try:
s3_metadata_manager.upload_metadata_file(
- s3_client=s3, bucket=bucket, document_key=key, metadata_content=job.metadata
+ s3_client=s3,
+ bucket=bucket,
+ document_key=key,
+ metadata_content=job.metadata, # type: ignore[arg-type]
)
logger.info(f"Uploaded metadata file for {key}")
except Exception as e:
@@ -1007,7 +1017,7 @@ def ingest_documents(event: dict, context: dict) -> dict:
jobs.append({"jobId": job.id, "documentId": job.document_id, "status": job.status, "s3Path": job.s3_path})
collection_id = job.collection_id
- collection_name: Optional[str] = None
+ collection_name: str | None = None
if collection:
collection_name = collection.get("name")
if not collection_name:
@@ -1017,7 +1027,7 @@ def ingest_documents(event: dict, context: dict) -> dict:
@api_wrapper
-def get_document(event: dict, context: dict) -> Dict[str, Any]:
+def get_document(event: dict, context: dict) -> dict[str, Any]:
"""Get a document by ID.
Args:
@@ -1039,7 +1049,7 @@ def get_document(event: dict, context: dict) -> Dict[str, Any]:
_ = get_repository(event, repository_id=repository_id)
doc = doc_repo.find_by_id(document_id=document_id)
- result: dict[str, Any] = doc.model_dump()
+ result: dict[str, Any] = doc.model_dump() # type: ignore[union-attr]
return result
@@ -1065,9 +1075,9 @@ def download_document(event: dict, context: dict) -> str:
if not repository_id:
raise ValidationError("repositoryId is required")
_ = get_repository(event, repository_id=repository_id)
- doc = doc_repo.find_by_id(document_id=document_id)
+ doc = doc_repo.find_by_id(document_id=document_id) # type: ignore[arg-type]
- source = doc.source
+ source = doc.source # type: ignore[union-attr]
bucket, key = source.replace("s3://", "").split("/", 1)
url: str = s3.generate_presigned_url(
@@ -1144,7 +1154,7 @@ def list_docs(event: dict, context: dict) -> dict[str, Any]:
query_string_params = event.get("queryStringParameters", {}) or {}
collection_id = query_string_params.get("collectionId")
- last_evaluated: Optional[dict[str, Optional[str]]] = None
+ last_evaluated: dict[str, str | None] | None = None
if not repository_id:
raise ValidationError("repositoryId is required")
@@ -1186,7 +1196,7 @@ def list_docs(event: dict, context: dict) -> dict[str, Any]:
@api_wrapper
-def list_jobs(event: Dict[str, Any], context: dict) -> Dict[str, Any]:
+def list_jobs(event: dict[str, Any], context: dict) -> dict[str, Any]:
"""List ingestion jobs for a specific repository with filtering and pagination.
Args:
@@ -1291,7 +1301,7 @@ def create(event: dict, context: dict) -> Any:
"Please select at least one data source."
)
# Convert bedrockKnowledgeBaseConfig to pipelines
- vector_store_config.pipelines = build_pipeline_configs_from_kb_config(
+ vector_store_config.pipelines = build_pipeline_configs_from_kb_config( # type: ignore[assignment]
vector_store_config.bedrockKnowledgeBaseConfig
)
@@ -1317,7 +1327,7 @@ def create(event: dict, context: dict) -> Any:
@api_wrapper
-def get_repository_by_id(event: dict, context: dict) -> Dict[str, Any]:
+def get_repository_by_id(event: dict, context: dict) -> dict[str, Any]:
"""
Get a vector store configuration by ID.
@@ -1411,7 +1421,7 @@ def _validate_immutable_pipeline_fields(current_pipelines: list, new_pipelines:
@api_wrapper
@admin_only
-def update_repository(event: dict, context: dict) -> Dict[str, Any]:
+def update_repository(event: dict, context: dict) -> dict[str, Any]:
"""
Update a vector store configuration. This function is only accessible by administrators.
@@ -1519,11 +1529,13 @@ def update_repository(event: dict, context: dict) -> Dict[str, Any]:
# If metadata provided but missing tags, preserve existing tags
elif "tags" not in current_meta and "tags" in existing_meta:
pipeline["metadata"]["tags"] = existing_meta["tags"]
- logger.info(f"Preserved tags for collection {collection_id}: {existing_meta['tags']}")
+ logger.info(f"Preserved tags for collection {collection_id}: " f"{existing_meta['tags']}")
# Check if pipeline configuration has changed
# Use the converted pipelines from updates if available, otherwise use request.pipelines
- new_pipelines = updates.get("pipelines") if "pipelines" in updates else request.pipelines
+ new_pipelines = (
+ updates.get("pipelines") if "pipelines" in updates else request.pipelines # type: ignore[assignment]
+ )
# Validate immutable pipeline fields for existing repositories
if new_pipelines is not None and current_pipelines:
@@ -1550,7 +1562,7 @@ def update_repository(event: dict, context: dict) -> Dict[str, Any]:
# Check if pipelines were added or removed
if current_pipeline_keys != new_pipeline_keys:
- added = new_pipeline_keys - current_pipeline_keys
+ added = new_pipeline_keys - current_pipeline_keys # type: ignore[assignment]
removed = current_pipeline_keys - new_pipeline_keys
logger.info(f"Pipeline changes detected: added={list(added)}, removed={list(removed)}")
require_deployment = True
@@ -1692,7 +1704,7 @@ def _remove_legacy(repository_id: str) -> None:
@api_wrapper
-def list_bedrock_knowledge_bases(event: dict, context: dict) -> Dict[str, Any]:
+def list_bedrock_knowledge_bases(event: dict, context: dict) -> dict[str, Any]:
"""
List all ACTIVE Bedrock Knowledge Bases in the AWS account.
@@ -1749,7 +1761,7 @@ def list_bedrock_knowledge_bases(event: dict, context: dict) -> Dict[str, Any]:
@api_wrapper
-def list_bedrock_data_sources(event: dict, context: dict) -> Dict[str, Any]:
+def list_bedrock_data_sources(event: dict, context: dict) -> dict[str, Any]:
"""
List data sources for a specific Bedrock Knowledge Base.
diff --git a/lambda/repository/metadata_generator.py b/lambda/repository/metadata_generator.py
index 89425215e..fe3f149f2 100644
--- a/lambda/repository/metadata_generator.py
+++ b/lambda/repository/metadata_generator.py
@@ -17,7 +17,7 @@
import json
import logging
import re
-from typing import Any, Dict, Optional
+from typing import Any
from models.domain_objects import RagCollectionConfig
from utilities.validation import ValidationError
@@ -58,11 +58,11 @@ def _extract_tags_from_metadata(metadata: Any) -> set:
@staticmethod
def merge_metadata(
- repository: Dict[str, Any],
- collection: Optional[Dict[str, Any]],
- document_metadata: Optional[Dict[str, Any]] = None,
+ repository: dict[str, Any],
+ collection: dict[str, Any] | None,
+ document_metadata: dict[str, Any] | None = None,
for_bedrock_kb: bool = False,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""
Merge metadata from repository, collection, and document sources.
@@ -79,11 +79,11 @@ def merge_metadata(
Returns:
Merged metadata dictionary
"""
- merged_metadata: Dict[str, Any] = {}
+ merged_metadata: dict[str, Any] = {}
all_tags: set = set()
# Helper function to merge non-tag metadata
- def merge_non_tag_metadata(metadata_source: Dict[str, Any]) -> None:
+ def merge_non_tag_metadata(metadata_source: dict[str, Any]) -> None:
for key, value in metadata_source.items():
if key != "tags" and not isinstance(value, dict):
merged_metadata[key] = value
@@ -127,10 +127,10 @@ def merge_non_tag_metadata(metadata_source: Dict[str, Any]) -> None:
@staticmethod
def generate_metadata_json(
- repository: Dict[str, Any],
- collection: Optional[RagCollectionConfig],
- document_metadata: Optional[Dict[str, Any]] = None,
- ) -> Dict[str, Any]:
+ repository: dict[str, Any],
+ collection: RagCollectionConfig | None,
+ document_metadata: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
"""Generate metadata.json content for Bedrock KB.
Merges metadata from three sources with precedence:
@@ -174,7 +174,7 @@ def generate_metadata_json(
return {"metadataAttributes": merged_metadata}
@staticmethod
- def validate_metadata(metadata: Dict[str, Any]) -> bool:
+ def validate_metadata(metadata: dict[str, Any]) -> bool:
"""Validate metadata against Bedrock KB requirements.
Args:
diff --git a/lambda/repository/pipeline_delete_documents.py b/lambda/repository/pipeline_delete_documents.py
index f5eb30da7..e3682baf4 100644
--- a/lambda/repository/pipeline_delete_documents.py
+++ b/lambda/repository/pipeline_delete_documents.py
@@ -14,7 +14,7 @@
import logging
import os
-from typing import Any, Dict
+from typing import Any
import boto3
from boto3.dynamodb.conditions import Key
@@ -107,9 +107,9 @@ def pipeline_delete_collection(job: IngestionJob) -> None:
# Drop index for faster cleanup (OpenSearch/PGVector)
# This removes all embeddings from the vector store
if RepositoryType.is_type(repository, RepositoryType.OPENSEARCH):
- drop_opensearch_index(job.repository_id, job.collection_id)
+ drop_opensearch_index(job.repository_id, job.collection_id) # type: ignore[arg-type]
elif RepositoryType.is_type(repository, RepositoryType.PGVECTOR):
- drop_pgvector_collection(job.repository_id, job.collection_id)
+ drop_pgvector_collection(job.repository_id, job.collection_id) # type: ignore[arg-type]
elif RepositoryType.is_type(repository, RepositoryType.BEDROCK_KB):
# For Bedrock KB, use bulk delete for efficiency
# Only delete LISA-managed documents (MANUAL/AUTO), preserve EXISTING
@@ -165,13 +165,13 @@ def pipeline_delete_collection(job: IngestionJob) -> None:
# Delete all documents and subdocuments from DynamoDB
# This method handles pagination and batch deletion
logger.info(f"Deleting all documents from DynamoDB for collection {job.collection_id}")
- rag_document_repository.delete_all(job.repository_id, job.collection_id)
+ rag_document_repository.delete_all(job.repository_id, job.collection_id) # type: ignore[arg-type]
logger.info("Successfully deleted all documents from DynamoDB")
# Delete collection DB entry
is_default_collection = job.embedding_model is not None
if not is_default_collection:
- collection_repo.delete(job.collection_id, job.repository_id)
+ collection_repo.delete(job.collection_id, job.repository_id) # type: ignore[arg-type]
# Update job status
ingestion_job_repository.update_status(job, IngestionStatus.DELETE_COMPLETED)
@@ -183,7 +183,11 @@ def pipeline_delete_collection(job: IngestionJob) -> None:
# Update collection status to DELETE_FAILED
try:
- collection_repo.update(job.collection_id, job.repository_id, {"status": CollectionStatus.DELETE_FAILED})
+ collection_repo.update(
+ job.collection_id, # type: ignore[arg-type]
+ job.repository_id,
+ {"status": CollectionStatus.DELETE_FAILED},
+ )
except Exception as update_error:
logger.error(f"Failed to update collection status: {update_error}")
@@ -221,7 +225,7 @@ def pipeline_delete_document(job: IngestionJob) -> None:
logger.info(f"Deleting document {job.s3_path} for repository {job.repository_id}")
# Find associated RagDocument
- rag_document = rag_document_repository.find_by_id(job.document_id, join_docs=True)
+ rag_document = rag_document_repository.find_by_id(job.document_id, join_docs=True) # type: ignore[arg-type]
if rag_document:
# Actually remove from vector store
@@ -290,7 +294,7 @@ def pipeline_delete_documents(job: IngestionJob) -> None:
failed = 0
errors = []
# For Bedrock KB, group S3 paths by data source (collection_id)
- s3_paths_by_data_source = {}
+ s3_paths_by_data_source = {} # type: ignore[var-annotated]
for document_id in document_ids:
try:
@@ -371,7 +375,7 @@ def pipeline_delete_documents(job: IngestionJob) -> None:
raise Exception(error_msg)
-def handle_pipeline_delete_event(event: Dict[str, Any], context: Any) -> None:
+def handle_pipeline_delete_event(event: dict[str, Any], context: Any) -> None:
"""Handle pipeline document deletion for S3 ObjectRemoved events."""
# Extract and validate inputs
logger.debug(f"Received event: {event}")
diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py
index 79402a0d2..d3ba11755 100644
--- a/lambda/repository/pipeline_ingest_documents.py
+++ b/lambda/repository/pipeline_ingest_documents.py
@@ -17,7 +17,7 @@
import logging
import os
from datetime import timedelta
-from typing import Any, Dict, List
+from typing import Any
import boto3
from models.domain_objects import (
@@ -87,7 +87,7 @@ def pipeline_ingest_document(job: IngestionJob) -> None:
try:
kb_bucket = get_datasource_bucket_for_collection(
repository=repository,
- collection_id=job.collection_id,
+ collection_id=job.collection_id, # type: ignore[arg-type]
)
except ValueError as e:
error_msg = str(e)
@@ -113,7 +113,7 @@ def pipeline_ingest_document(job: IngestionJob) -> None:
# Check if document already exists (idempotent operation)
existing_docs = list(
rag_document_repository.find_by_source(
- job.repository_id, job.collection_id, kb_s3_path, join_docs=False
+ job.repository_id, job.collection_id, kb_s3_path, join_docs=False # type: ignore[arg-type]
)
)
@@ -165,7 +165,7 @@ def pipeline_ingest_document(job: IngestionJob) -> None:
collection = None
try:
collection = collection_service.get_collection(
- collection_id=job.collection_id,
+ collection_id=job.collection_id, # type: ignore[arg-type]
repository_id=job.repository_id,
username="system",
user_groups=[],
@@ -203,18 +203,23 @@ def pipeline_ingest_document(job: IngestionJob) -> None:
# Non-Bedrock KB path
documents = generate_chunks(job)
- texts, metadatas = prepare_chunks(documents, job.repository_id, job.collection_id)
+ texts, metadatas = prepare_chunks(documents, job.repository_id, job.collection_id) # type: ignore[arg-type]
all_ids = store_chunks_in_vectorstore(
texts=texts,
metadatas=metadatas,
repository_id=job.repository_id,
- collection_id=job.collection_id,
- embedding_model=job.embedding_model,
+ collection_id=job.collection_id, # type: ignore[arg-type]
+ embedding_model=job.embedding_model, # type: ignore[arg-type]
)
# remove old
for rag_document in list(
- rag_document_repository.find_by_source(job.repository_id, job.collection_id, job.s3_path, join_docs=True)
+ rag_document_repository.find_by_source(
+ job.repository_id,
+ job.collection_id, # type: ignore[arg-type]
+ job.s3_path,
+ join_docs=True,
+ )
):
prev_job = ingestion_job_repository.find_by_document(rag_document.document_id)
@@ -323,7 +328,7 @@ def pipeline_ingest_documents(job: IngestionJob) -> None:
errors.append(error_msg)
# Update job with document IDs
- job.document_ids = document_ids
+ job.document_ids = document_ids # type: ignore[assignment]
if failed == 0:
ingestion_job_repository.update_status(job, IngestionStatus.INGESTION_COMPLETED)
@@ -385,7 +390,7 @@ def _handle_s3_discovery_scan(job: IngestionJob) -> None:
# Perform discovery and ingestion
result = discovery_service.discover_and_ingest_documents(
repository_id=job.repository_id,
- collection_id=job.collection_id,
+ collection_id=job.collection_id, # type: ignore[arg-type]
s3_bucket=s3_bucket,
s3_prefix=s3_prefix,
ingestion_type=job.ingestion_type,
@@ -417,10 +422,10 @@ def remove_document_from_vectorstore(doc: RagDocument) -> None:
collection_id=doc.collection_id,
embeddings=embeddings,
)
- vector_store.delete(doc.subdocs)
+ vector_store.delete(doc.subdocs) # type: ignore[union-attr]
-def handle_pipeline_ingest_event(event: Dict[str, Any], context: Any) -> None:
+def handle_pipeline_ingest_event(event: dict[str, Any], context: Any) -> None:
"""Handle pipeline document ingestion."""
# Extract and validate inputs
logger.debug(f"Received event: {event}")
@@ -452,13 +457,20 @@ def handle_pipeline_ingest_event(event: Dict[str, Any], context: Any) -> None:
data_sources = bedrock_config.get("dataSources", [])
if data_sources:
first_data_source = data_sources[0]
- if isinstance(first_data_source, dict):
- collection_id = first_data_source.get("id")
- else:
- collection_id = getattr(first_data_source, "id", None)
+ collection_id_val: str | None = (
+ first_data_source.get("id") if isinstance(first_data_source, dict) else first_data_source.id
+ )
+ if not collection_id_val:
+ logger.error(f"Bedrock KB repository {repository_id} has invalid data source")
+ return
+ collection_id = collection_id_val
else:
# Try legacy single data source ID
- collection_id = bedrock_config.get("bedrockKnowledgeDatasourceId")
+ collection_id_val = bedrock_config.get("bedrockKnowledgeDatasourceId")
+ if not collection_id_val:
+ logger.error(f"Bedrock KB repository {repository_id} missing data source ID")
+ return
+ collection_id = collection_id_val
if not collection_id:
logger.error(f"Bedrock KB repository {repository_id} missing data source ID")
@@ -529,7 +541,7 @@ def handle_pipeline_ingest_event(event: Dict[str, Any], context: Any) -> None:
logger.info(f"Submitted ingestion job for document {s3_path} in repository {repository_id}")
-def handle_pipline_ingest_schedule(event: Dict[str, Any], context: Any) -> None:
+def handle_pipline_ingest_schedule(event: dict[str, Any], context: Any) -> None:
"""
Lists all objects in the specified S3 bucket and prefix that were modified in the last 24 hours.
@@ -623,7 +635,7 @@ 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 = 500) -> list[tuple[list[str], list[dict]]]:
"""
Split texts and metadata into batches of specified size.
@@ -642,7 +654,7 @@ def batch_texts(texts: List[str], metadatas: List[Dict], batch_size: int = 500)
return batches
-def extract_chunk_strategy(pipeline_config: Dict) -> ChunkingStrategy:
+def extract_chunk_strategy(pipeline_config: dict) -> ChunkingStrategy:
"""
Extract and validate chunking strategy from pipeline configuration.
@@ -665,7 +677,8 @@ def extract_chunk_strategy(pipeline_config: Dict) -> ChunkingStrategy:
if chunk_type == "fixed":
# Use Pydantic model validation for type safety and validation
- return FixedChunkingStrategy.model_validate(chunking_strategy)
+ result: FixedChunkingStrategy = FixedChunkingStrategy.model_validate(chunking_strategy)
+ return result
else:
# Future: Handle other chunking strategy types (semantic, recursive, etc.)
raise ValueError(f"Unsupported chunking strategy type: {chunk_type}")
@@ -683,7 +696,7 @@ def extract_chunk_strategy(pipeline_config: Dict) -> ChunkingStrategy:
return FixedChunkingStrategy(size=512, overlap=51)
-def prepare_chunks(docs: List, repository_id: str, collection_id: str) -> tuple[List[str], List[Dict]]:
+def prepare_chunks(docs: list, repository_id: str, collection_id: str) -> tuple[list[str], list[dict]]:
"""Prepare texts and metadata from document chunks."""
texts = []
metadatas = []
@@ -696,8 +709,8 @@ def prepare_chunks(docs: List, repository_id: str, collection_id: str) -> tuple[
def store_chunks_in_vectorstore(
- texts: List[str], metadatas: List[Dict], repository_id: str, collection_id: str, embedding_model: str
-) -> List[str]:
+ texts: list[str], metadatas: list[dict], repository_id: str, collection_id: str, embedding_model: str
+) -> list[str]:
"""Store document chunks in vector store using repository service."""
vs_repo = VectorStoreRepository()
repository = vs_repo.find_repository_by_id(repository_id)
@@ -717,7 +730,7 @@ def store_chunks_in_vectorstore(
for i, (text_batch, metadata_batch) in enumerate(batches, 1):
logger.info(f"Processing batch {i}/{total_batches} with {len(text_batch)} texts")
- batch_ids = vs.add_texts(texts=text_batch, metadatas=metadata_batch)
+ batch_ids = vs.add_texts(texts=text_batch, metadatas=metadata_batch) # type: ignore[union-attr]
if not batch_ids:
raise Exception(f"Failed to store documents in vector store for batch {i}")
all_ids.extend(batch_ids)
diff --git a/lambda/repository/rag_document_repo.py b/lambda/repository/rag_document_repo.py
index 8c3583179..d688da3bf 100644
--- a/lambda/repository/rag_document_repo.py
+++ b/lambda/repository/rag_document_repo.py
@@ -13,8 +13,8 @@
# limitations under the License.
import logging
import os
+from collections.abc import Generator
from concurrent.futures import as_completed, ThreadPoolExecutor
-from typing import Generator, Optional
import boto3
from boto3.dynamodb.conditions import Key
@@ -94,7 +94,7 @@ def save(self, document: RagDocument) -> None:
logging.error(f"Error saving document: {e.response['Error']['Message']}")
raise
- def find_by_id(self, document_id: str, join_docs: bool = False) -> Optional[RagDocument]:
+ def find_by_id(self, document_id: str, join_docs: bool = False) -> RagDocument | None:
"""Query documents using GSI.
Args:
@@ -167,7 +167,7 @@ def find_by_name(
def find_by_source(
self, repository_id: str, collection_id: str, document_source: str, join_docs: bool = False
- ) -> Generator[RagDocument, None, None]:
+ ) -> Generator[RagDocument]:
"""Get a list of documents from the RagDocTable by source.
Args:
@@ -199,7 +199,7 @@ def find_by_source(
yield from self._yield_documents(response["Items"], join_docs=join_docs)
- def _yield_documents(self, items: list[dict], join_docs: bool) -> Generator[RagDocument, None, None]:
+ def _yield_documents(self, items: list[dict], join_docs: bool) -> Generator[RagDocument]:
for item in items:
document = RagDocument(**item)
if join_docs:
@@ -209,11 +209,11 @@ def _yield_documents(self, items: list[dict], join_docs: bool) -> Generator[RagD
def list_all(
self,
repository_id: str,
- collection_id: Optional[str] = None,
- last_evaluated_key: Optional[dict] = None,
+ collection_id: str | None = None,
+ last_evaluated_key: dict | None = None,
limit: int = 100,
join_docs: bool = False,
- ) -> tuple[list[RagDocument], Optional[dict], int]:
+ ) -> tuple[list[RagDocument], dict | None, int]:
"""List all documents in a collection.
Args:
@@ -261,7 +261,7 @@ def list_all(
logging.error(f"Error listing documents: {e.response['Error']['Message']}")
raise
- def count_documents(self, repository_id: str, collection_id: Optional[str] = None) -> int:
+ def count_documents(self, repository_id: str, collection_id: str | None = None) -> int:
"""Count total documents in a repository/collection.
Args:
repository_id: Repository ID
@@ -269,7 +269,7 @@ def count_documents(self, repository_id: str, collection_id: Optional[str] = Non
Returns:
Total number of documents
"""
- count = 0
+ count: int = 0
# Count all rag documents using repo id only
if not collection_id:
response = self.doc_table.query(
@@ -277,11 +277,11 @@ def count_documents(self, repository_id: str, collection_id: Optional[str] = Non
KeyConditionExpression=Key("repository_id").eq(repository_id),
Select="COUNT",
)
- count = response.get("Count", 0)
+ count = int(response.get("Count", 0))
else:
pk = RagDocument.createPartitionKey(repository_id, collection_id)
response = self.doc_table.query(KeyConditionExpression=Key("pk").eq(pk), Select="COUNT")
- count = response.get("Count", 0)
+ count = int(response.get("Count", 0))
return count
def find_subdocs_by_id(self, document_id: str) -> list[RagSubDocument]:
diff --git a/lambda/repository/s3_metadata_manager.py b/lambda/repository/s3_metadata_manager.py
index 599ef176e..ac64c3177 100644
--- a/lambda/repository/s3_metadata_manager.py
+++ b/lambda/repository/s3_metadata_manager.py
@@ -16,7 +16,7 @@
import json
import logging
-from typing import Any, Dict, List, Tuple
+from typing import Any
from botocore.exceptions import ClientError
@@ -32,10 +32,10 @@ class S3MetadataManager:
def upload_metadata_file(
self,
- s3_client,
+ s3_client: Any,
bucket: str,
document_key: str,
- metadata_content: Dict[str, Any],
+ metadata_content: dict[str, Any],
) -> str:
"""Upload metadata.json file to S3.
@@ -90,10 +90,12 @@ def upload_metadata_file(
else:
logger.error(f"Failed to upload metadata file after {MAX_RETRIES} attempts: {metadata_key}")
raise
+ # This should never be reached due to the raise above, but mypy needs it
+ raise RuntimeError(f"Failed to upload metadata file: {metadata_key}") # pragma: no cover
def delete_metadata_file(
self,
- s3_client,
+ s3_client: Any,
bucket: str,
document_key: str,
) -> None:
@@ -132,7 +134,9 @@ def delete_metadata_file(
# Log other errors but don't fail
logger.warning(f"Failed to delete metadata file: {metadata_key}, error: {e}")
- def batch_upload_metadata(self, s3_client, bucket: str, documents: List[Tuple[str, Dict[str, Any]]]) -> List[str]:
+ def batch_upload_metadata(
+ self, s3_client: Any, bucket: str, documents: list[tuple[str, dict[str, Any]]]
+ ) -> list[str]:
"""Upload multiple metadata files in batch.
Args:
@@ -163,7 +167,7 @@ def batch_upload_metadata(self, s3_client, bucket: str, documents: List[Tuple[st
return uploaded_keys
- def batch_delete_metadata(self, s3_client, bucket: str, document_keys: List[str]) -> int:
+ def batch_delete_metadata(self, s3_client: Any, bucket: str, document_keys: list[str]) -> int:
"""Delete multiple metadata files in batch.
Args:
diff --git a/lambda/repository/services/bedrock_kb_repository_service.py b/lambda/repository/services/bedrock_kb_repository_service.py
index 13fbf7773..e4fe516b4 100644
--- a/lambda/repository/services/bedrock_kb_repository_service.py
+++ b/lambda/repository/services/bedrock_kb_repository_service.py
@@ -16,7 +16,7 @@
import logging
import os
-from typing import Any, Dict, List, Optional
+from typing import Any
import boto3
from boto3.dynamodb.conditions import Key
@@ -55,14 +55,14 @@ def should_create_default_collection(self) -> bool:
"""Bedrock KB does not need virtual default collections."""
return False
- def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
+ def get_collection_id_from_config(self, pipeline_config: dict[str, Any]) -> str:
"""For Bedrock KB, collection ID is the data source ID.
Extracts the data source ID from the pipeline config's collectionId field,
which should match one of the data sources in bedrockKnowledgeBaseConfig.
"""
# The pipeline config should have a collectionId that matches a data source ID
- collection_id = pipeline_config.get("collectionId")
+ collection_id: str | None = pipeline_config.get("collectionId")
if collection_id:
return collection_id
@@ -74,10 +74,11 @@ def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
data_sources = bedrock_config.get("dataSources", [])
if data_sources:
first_data_source = data_sources[0]
- data_source_id = (
+ data_source_id: str | None = (
first_data_source.get("id") if isinstance(first_data_source, dict) else first_data_source.id
)
- return data_source_id
+ if data_source_id:
+ return data_source_id
# Try legacy single data source ID
data_source_id = bedrock_config.get("bedrockKnowledgeDatasourceId")
@@ -89,8 +90,8 @@ def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
def ingest_document(
self,
job: IngestionJob,
- texts: List[str],
- metadatas: List[Dict[str, Any]],
+ texts: list[str],
+ metadatas: list[dict[str, Any]],
) -> RagDocument:
"""Track document for Bedrock KB - KB handles actual ingestion.
@@ -108,8 +109,17 @@ def ingest_document(
os.environ["RAG_DOCUMENT_TABLE"], os.environ["RAG_SUB_DOCUMENT_TABLE"]
)
+ # Ensure collection_id is not None
+ if not job.collection_id:
+ raise ValueError("collection_id is required for document ingestion")
+
existing_docs = list(
- rag_document_repository.find_by_source(job.repository_id, job.collection_id, kb_s3_path, join_docs=False)
+ rag_document_repository.find_by_source(
+ job.repository_id,
+ job.collection_id,
+ kb_s3_path,
+ join_docs=False,
+ )
)
if existing_docs:
@@ -140,7 +150,7 @@ def delete_document(
self,
document: RagDocument,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
"""Delete document from Bedrock KB."""
if not bedrock_agent_client:
@@ -166,7 +176,7 @@ def delete_collection(
self,
collection_id: str,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
"""Delete all LISA-managed documents from Bedrock KB collection.
@@ -229,8 +239,8 @@ def retrieve_documents(
top_k: int,
model_name: str,
include_score: bool = False,
- bedrock_agent_client: Optional[Any] = None,
- ) -> List[Dict[str, Any]]:
+ bedrock_agent_client: Any | None = None,
+ ) -> list[dict[str, Any]]:
"""Retrieve documents from Bedrock KB using retrieve API.
Args:
@@ -249,7 +259,7 @@ def retrieve_documents(
bedrock_config = self.repository.get("bedrockKnowledgeBaseConfig", {})
# Support both field names for backward compatibility
- kb_id = bedrock_config.get("knowledgeBaseId", bedrock_config.get("bedrockKnowledgeBaseId"))
+ kb_id: str | None = bedrock_config.get("knowledgeBaseId", bedrock_config.get("bedrockKnowledgeBaseId"))
if not kb_id:
raise ValueError(
@@ -263,7 +273,7 @@ def retrieve_documents(
logger.info(f"Retrieving from KB: kb_id={kb_id}, data_source={collection_id}, query={query[:50]}...")
# Build retrieve params with data source filter
- retrieve_params = {
+ retrieve_params: dict[str, Any] = {
"knowledgeBaseId": kb_id,
"retrievalQuery": {"text": query},
"retrievalConfiguration": {
@@ -276,7 +286,8 @@ def retrieve_documents(
# Add data source filter if collection_id is provided
# collection_id corresponds to the data source ID in Bedrock KB
if collection_id:
- retrieve_params["retrievalConfiguration"]["vectorSearchConfiguration"]["filter"] = {
+ vector_search_config = retrieve_params["retrievalConfiguration"]["vectorSearchConfiguration"]
+ vector_search_config["filter"] = {
"equals": {
"key": "x-amz-bedrock-kb-data-source-id",
"value": collection_id,
@@ -302,7 +313,9 @@ def retrieve_documents(
)
logger.error(f"Bedrock retrieve failed for KB {kb_id}: {error_message}")
- if "filter" in retrieve_params.get("retrievalConfiguration", {}).get("vectorSearchConfiguration", {}):
+ retrieval_config = retrieve_params.get("retrievalConfiguration", {})
+ vector_search = retrieval_config.get("vectorSearchConfiguration", {})
+ if "filter" in vector_search:
logger.error(
"Filter may not be supported. Ensure metadata field 'x-amz-bedrock-kb-data-source-id' "
"is configured in the Knowledge Base."
@@ -338,16 +351,19 @@ def retrieve_documents(
def validate_document_source(self, s3_path: str) -> str:
"""Validate document is from KB data source bucket."""
bedrock_config = self.repository.get("bedrockKnowledgeBaseConfig", {})
- kb_bucket = bedrock_config.get("bedrockKnowledgeDatasourceS3Bucket")
+ kb_bucket: str | None = bedrock_config.get("bedrockKnowledgeDatasourceS3Bucket")
+
+ if not kb_bucket:
+ raise ValueError("KB bucket not configured")
return self._validate_and_normalize_path(s3_path, kb_bucket)
- def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Optional[Any]:
+ def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Any | None:
"""Bedrock KB does not use external vector store clients."""
return None
def _create_collection_for_data_source(
- self, data_source_id: str, s3_uri: str = "", is_default: bool = False, collection_name: Optional[str] = None
+ self, data_source_id: str, s3_uri: str = "", is_default: bool = False, collection_name: str | None = None
) -> RagCollectionConfig:
"""Create a collection configuration for a specific data source.
@@ -396,7 +412,7 @@ def _create_collection_for_data_source(
return collection
- def create_default_collection(self, ingest_docs=False) -> Optional[RagCollectionConfig]:
+ def create_default_collection(self, ingest_docs: bool = False) -> RagCollectionConfig | None:
"""Create a default collection for Bedrock KB repository.
For Bedrock KB, the collection ID is the data source ID.
@@ -421,9 +437,13 @@ def create_default_collection(self, ingest_docs=False) -> Optional[RagCollection
# Use first data source from array, or legacy single ID
if data_sources:
first_data_source = data_sources[0]
- data_source_id = (
+ data_source_id: str | None = (
first_data_source.get("id") if isinstance(first_data_source, dict) else first_data_source.id
)
+ if not data_source_id:
+ logger.warning(f"Bedrock KB repository {self.repository_id} has invalid data source")
+ return None
+
s3_uri = (
first_data_source.get("s3Uri", "")
if isinstance(first_data_source, dict)
@@ -431,6 +451,9 @@ def create_default_collection(self, ingest_docs=False) -> Optional[RagCollection
)
else:
data_source_id = legacy_data_source_id
+ if not data_source_id:
+ logger.warning(f"Bedrock KB repository {self.repository_id} missing data source ID")
+ return None
s3_uri = ""
# Use helper method to create collection
diff --git a/lambda/repository/services/pgvector_repository_service.py b/lambda/repository/services/pgvector_repository_service.py
index f34515484..aa65d46a1 100644
--- a/lambda/repository/services/pgvector_repository_service.py
+++ b/lambda/repository/services/pgvector_repository_service.py
@@ -110,15 +110,18 @@ def _get_vector_store_client(self, collection_id: str, embeddings: Embeddings) -
if not RepositoryType.is_type(connection_info, RepositoryType.PGVECTOR):
raise ValueError(f"Repository {self.repository_id} is not a PGVector repository")
+ # Check if using password auth (passwordSecretId present) or IAM auth
if "passwordSecretId" in connection_info:
- # Provides backwards compatibility to non-IAM authenticated vector stores
+ # Password auth: get credentials from Secrets Manager
secrets_response = secretsmanager_client.get_secret_value(SecretId=connection_info.get("passwordSecretId"))
user = connection_info.get("username")
password = json.loads(secrets_response.get("SecretString")).get("password")
+ use_ssl = False
else:
- # Use IAM auth token to connect
+ # IAM auth: generate auth token
user = get_lambda_role_name()
password = generate_auth_token(connection_info.get("dbHost"), connection_info.get("dbPort"), user)
+ use_ssl = True # IAM auth requires SSL
connection_string = PGVector.connection_string_from_db_params(
driver="psycopg2",
@@ -129,6 +132,9 @@ def _get_vector_store_client(self, collection_id: str, embeddings: Embeddings) -
password=password,
)
+ if use_ssl:
+ connection_string = f"{connection_string}?sslmode=require"
+
return PGVector(
collection_name=collection_id,
connection_string=connection_string,
diff --git a/lambda/repository/services/repository_service.py b/lambda/repository/services/repository_service.py
index 31482ee95..36bc21eca 100644
--- a/lambda/repository/services/repository_service.py
+++ b/lambda/repository/services/repository_service.py
@@ -15,7 +15,7 @@
"""Base service interface for repository operations."""
from abc import ABC, abstractmethod
-from typing import Any, Dict, List, Optional
+from typing import Any
from models.domain_objects import IngestionJob, RagCollectionConfig, RagDocument
@@ -27,7 +27,7 @@ class RepositoryService(ABC):
interface to provide type-specific behavior for document management.
"""
- def __init__(self, repository: Dict[str, Any]):
+ def __init__(self, repository: dict[str, Any]):
"""Initialize service with repository configuration.
Args:
@@ -55,7 +55,7 @@ def should_create_default_collection(self) -> bool:
pass
@abstractmethod
- def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
+ def get_collection_id_from_config(self, pipeline_config: dict[str, Any]) -> str:
"""Extract collection ID from pipeline configuration.
Args:
@@ -70,8 +70,8 @@ def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
def ingest_document(
self,
job: IngestionJob,
- texts: List[str],
- metadatas: List[Dict[str, Any]],
+ texts: list[str],
+ metadatas: list[dict[str, Any]],
) -> RagDocument:
"""Ingest a document into the repository.
@@ -90,7 +90,7 @@ def delete_document(
self,
document: RagDocument,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
"""Delete a document from the repository.
@@ -106,7 +106,7 @@ def delete_collection(
self,
collection_id: str,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
"""Delete an entire collection from the repository.
@@ -125,8 +125,8 @@ def retrieve_documents(
top_k: int,
model_name: str,
include_score: bool = False,
- bedrock_agent_client: Optional[Any] = None,
- ) -> List[Dict[str, Any]]:
+ bedrock_agent_client: Any | None = None,
+ ) -> list[dict[str, Any]]:
"""Retrieve documents matching a query.
Args:
@@ -158,7 +158,7 @@ def validate_document_source(self, s3_path: str) -> str:
pass
@abstractmethod
- def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Optional[Any]:
+ def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Any | None:
"""Get vector store client for this repository.
Args:
@@ -171,7 +171,7 @@ def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Option
pass
@abstractmethod
- def create_default_collection(self) -> Optional[RagCollectionConfig]:
+ def create_default_collection(self) -> RagCollectionConfig | None:
"""Create a default collection for this repository.
Returns:
diff --git a/lambda/repository/services/repository_service_factory.py b/lambda/repository/services/repository_service_factory.py
index 88f2b9d35..a1f0e1828 100644
--- a/lambda/repository/services/repository_service_factory.py
+++ b/lambda/repository/services/repository_service_factory.py
@@ -14,7 +14,7 @@
"""Factory for creating repository service instances."""
-from typing import Any, Dict, Type
+from typing import Any
from utilities.repository_types import RepositoryType
@@ -32,14 +32,14 @@ class RepositoryServiceFactory:
"""
# Registry mapping repository types to service classes
- _services: Dict[RepositoryType, Type[RepositoryService]] = {
+ _services: dict[RepositoryType, type[RepositoryService]] = {
RepositoryType.OPENSEARCH: OpenSearchRepositoryService,
RepositoryType.PGVECTOR: PGVectorRepositoryService,
RepositoryType.BEDROCK_KB: BedrockKBRepositoryService,
}
@classmethod
- def create_service(cls, repository: Dict[str, Any]) -> RepositoryService:
+ def create_service(cls, repository: dict[str, Any]) -> RepositoryService:
"""Create appropriate service instance for repository type.
Args:
@@ -62,7 +62,7 @@ def create_service(cls, repository: Dict[str, Any]) -> RepositoryService:
return service_class(repository)
@classmethod
- def register_service(cls, repo_type: RepositoryType, service_class: Type[RepositoryService]) -> None:
+ def register_service(cls, repo_type: RepositoryType, service_class: type[RepositoryService]) -> None:
"""Register a new service class for a repository type.
Allows extending the factory with new repository types without
diff --git a/lambda/repository/services/vector_store_repository_service.py b/lambda/repository/services/vector_store_repository_service.py
index 00b4b0bc8..e84edadfa 100644
--- a/lambda/repository/services/vector_store_repository_service.py
+++ b/lambda/repository/services/vector_store_repository_service.py
@@ -21,7 +21,7 @@
import logging
import os
from abc import abstractmethod
-from typing import Any, Dict, List, Optional
+from typing import Any
import boto3
from langchain_core.embeddings import Embeddings
@@ -63,26 +63,34 @@ def should_create_default_collection(self) -> bool:
"""Vector stores create virtual default collections."""
return True
- def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
+ def get_collection_id_from_config(self, pipeline_config: dict[str, Any]) -> str:
"""Extract collection ID from pipeline config or use embedding model."""
- collection_id = pipeline_config.get("collectionId")
+ collection_id: str | None = pipeline_config.get("collectionId")
if not collection_id:
collection_id = pipeline_config.get("embeddingModel")
+ if not collection_id:
+ raise ValueError("No collection ID or embedding model found in pipeline config")
return collection_id
def ingest_document(
self,
job: IngestionJob,
- texts: List[str],
- metadatas: List[Dict[str, Any]],
+ texts: list[str],
+ metadatas: list[dict[str, Any]],
) -> RagDocument:
"""Ingest document into vector store with chunking and embedding."""
# Store chunks in vector store
+ collection_id_str: str = job.collection_id if job.collection_id else ""
+ embedding_model_str: str = job.embedding_model if job.embedding_model else ""
+
+ if not collection_id_str or not embedding_model_str:
+ raise ValueError("collection_id and embedding_model are required for ingestion")
+
all_ids = self._store_chunks(
texts=texts,
metadatas=metadatas,
- collection_id=job.collection_id,
- embedding_model=job.embedding_model,
+ collection_id=collection_id_str,
+ embedding_model=embedding_model_str,
)
# Create document record
@@ -112,7 +120,7 @@ def delete_document(
self,
document: RagDocument,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
"""Delete document from vector store."""
embeddings = RagEmbeddings(model_name=document.collection_id)
@@ -126,7 +134,7 @@ def delete_collection(
self,
collection_id: str,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
"""Delete collection from vector store.
@@ -142,8 +150,8 @@ def retrieve_documents(
top_k: int,
model_name: str,
include_score: bool = False,
- bedrock_agent_client: Optional[Any] = None,
- ) -> List[Dict[str, Any]]:
+ bedrock_agent_client: Any | None = None,
+ ) -> list[dict[str, Any]]:
"""Retrieve documents from vector store using similarity search.
Args:
@@ -233,7 +241,7 @@ def _normalize_similarity_score(self, score: float) -> float:
"""
return score
- def create_default_collection(self) -> Optional[RagCollectionConfig]:
+ def create_default_collection(self) -> RagCollectionConfig | None:
"""Create a default collection for vector store repositories.
Returns:
@@ -288,11 +296,11 @@ def create_default_collection(self) -> Optional[RagCollectionConfig]:
def _store_chunks(
self,
- texts: List[str],
- metadatas: List[Dict[str, Any]],
+ texts: list[str],
+ metadatas: list[dict[str, Any]],
collection_id: str,
embedding_model: str,
- ) -> List[str]:
+ ) -> list[str]:
"""Store document chunks in vector store."""
embeddings = RagEmbeddings(model_name=embedding_model)
vector_store = self._get_vector_store_client(
diff --git a/lambda/repository/state_machine/cleanup_repo_docs.py b/lambda/repository/state_machine/cleanup_repo_docs.py
index 9e1924137..4edcbbebb 100644
--- a/lambda/repository/state_machine/cleanup_repo_docs.py
+++ b/lambda/repository/state_machine/cleanup_repo_docs.py
@@ -14,7 +14,7 @@
import logging
import os
-from typing import Any, Dict
+from typing import Any
from models.domain_objects import IngestionType
from pydantic import BaseModel
@@ -24,7 +24,7 @@
doc_repo = RagDocumentRepository(os.environ["RAG_DOCUMENT_TABLE"], os.environ["RAG_SUB_DOCUMENT_TABLE"])
-def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any] | Any:
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any] | Any:
"""
Remove LISA-managed documents from repository.
@@ -43,7 +43,10 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any] | Any:
last_evaluated = event.get("lastEvaluated")
# Get all documents
- docs, last_evaluated, _ = doc_repo.list_all(repository_id=repository_id, last_evaluated_key=last_evaluated)
+ docs, last_evaluated, _ = doc_repo.list_all(
+ repository_id=repository_id, # type: ignore[arg-type]
+ last_evaluated_key=last_evaluated,
+ )
# Filter to LISA-managed only (MANUAL or AUTO)
lisa_managed = [d for d in docs if d.ingestion_type in [IngestionType.MANUAL, IngestionType.AUTO]]
@@ -59,7 +62,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any] | Any:
doc_repo.delete_by_id(doc.document_id)
# Delete from S3 (only LISA-managed)
- doc_repo.delete_s3_docs(repository_id=repository_id, docs=lisa_managed)
+ doc_repo.delete_s3_docs(repository_id=repository_id, docs=lisa_managed) # type: ignore[arg-type]
# Ensure JSON-serializable payload for Step Functions when Pydantic models are provided
serializable_docs = [doc.model_dump() if isinstance(doc, BaseModel) else doc for doc in lisa_managed]
diff --git a/lambda/repository/state_machine/list_modified_objects.py b/lambda/repository/state_machine/list_modified_objects.py
index 4ae8ecdd2..87748c5f5 100644
--- a/lambda/repository/state_machine/list_modified_objects.py
+++ b/lambda/repository/state_machine/list_modified_objects.py
@@ -17,7 +17,7 @@
import logging
import os
from datetime import timedelta
-from typing import Any, Dict
+from typing import Any
import boto3
from utilities.time import utc_now
@@ -76,7 +76,7 @@ def validate_bucket_prefix(bucket: str, prefix: str) -> bool:
return True
-def handle_list_modified_objects(event: Dict[str, Any], context: Any) -> Dict[str, Any] | Any:
+def handle_list_modified_objects(event: dict[str, Any], context: Any) -> dict[str, Any] | Any:
"""
Lists all objects in the specified S3 bucket and prefix that were modified in the last 24 hours.
diff --git a/lambda/repository/state_machine/wait_for_collection_deletions.py b/lambda/repository/state_machine/wait_for_collection_deletions.py
index 00f08d0e4..0c4e96f51 100644
--- a/lambda/repository/state_machine/wait_for_collection_deletions.py
+++ b/lambda/repository/state_machine/wait_for_collection_deletions.py
@@ -15,14 +15,14 @@
"""Wait for all collection deletion jobs to complete before deleting repository."""
import logging
-from typing import Any, Dict
+from typing import Any
from repository.ingestion_job_repo import IngestionJobRepository
logger = logging.getLogger(__name__)
-def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
+def lambda_handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
"""
Check if all collection deletion jobs for a repository are complete.
@@ -41,7 +41,7 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
job_repo = IngestionJobRepository()
# Query all jobs for this repository
- pending_jobs = job_repo.find_pending_collection_deletions(repository_id)
+ pending_jobs = job_repo.find_pending_collection_deletions(repository_id) # type: ignore[arg-type]
pending_count = len(pending_jobs)
all_complete = pending_count == 0
diff --git a/lambda/repository/vector_store_repo.py b/lambda/repository/vector_store_repo.py
index 25a0bc569..0eaab8d57 100644
--- a/lambda/repository/vector_store_repo.py
+++ b/lambda/repository/vector_store_repo.py
@@ -13,7 +13,7 @@
# limitations under the License.
import logging
import os
-from typing import Any, cast, List
+from typing import Any, cast
import boto3
from boto3.dynamodb.conditions import Attr
@@ -34,7 +34,7 @@ def __init__(self, table_name: str | None = None) -> None:
table_name = os.environ["LISA_RAG_VECTOR_STORE_TABLE"]
self.table = dynamodb.Table(table_name)
- def get_registered_repositories(self) -> List[dict]:
+ def get_registered_repositories(self) -> list[dict]:
"""Get a list of all registered RAG repositories with default values for new fields."""
response = self.table.scan()
items = response["Items"]
@@ -181,7 +181,7 @@ def delete(self, repository_id: str) -> bool:
except Exception as e:
raise ValueError(f"Failed to delete repository: {repository_id}", e)
- def find_repositories_using_model(self, model_id: str) -> List[dict]:
+ def find_repositories_using_model(self, model_id: str) -> list[dict]:
"""
Find all repositories that use a specific model.
Excludes repositories with status indicating they are deleted or archived.
diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py
index 9d25d5a46..b60ca4ca4 100644
--- a/lambda/session/lambda_functions.py
+++ b/lambda/session/lambda_functions.py
@@ -20,15 +20,26 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any
import boto3
import create_env_variables # noqa: F401
from botocore.exceptions import ClientError
-from cachetools import cached, TTLCache
+from cachetools import cached, TTLCache # type: ignore[import-untyped,unused-ignore]
+from metrics.models import MetricsEvent
+from models.domain_objects import DeleteResponse, SuccessResponse
+from pydantic import ValidationError
+from session.models import (
+ AttachImageRequest,
+ PutSessionRequest,
+ RenameSessionRequest,
+ Session,
+ SessionSummary,
+)
from utilities.auth import get_user_context, get_username
from utilities.common_functions import api_wrapper, get_session_id, retry_config
from utilities.encoders import convert_decimal
+from utilities.input_validation import MAX_LARGE_REQUEST_SIZE
from utilities.session_encryption import decrypt_session_fields, migrate_session_to_encrypted, SessionEncryptionError
from utilities.time import iso_string
@@ -50,7 +61,7 @@
executor = ThreadPoolExecutor(max_workers=10)
# Cache for configuration values to avoid repeated database queries
-cache = TTLCache(maxsize=1, ttl=300) # 5 minutes
+cache: TTLCache = TTLCache(maxsize=1, ttl=300) # 5 minutes
@cached(cache=cache)
@@ -81,7 +92,7 @@ def _is_session_encryption_enabled() -> bool:
enabled_components = configuration.get("enabledComponents", {})
encrypt_session = enabled_components.get("encryptSession", False) # Default to False
logger.info(f"Retrieved session encryption setting from global config: {encrypt_session}")
- return encrypt_session
+ return encrypt_session # type: ignore[no-any-return]
else:
logger.warning("No global configuration found, defaulting session encryption to disabled")
return False
@@ -119,7 +130,7 @@ 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: dict[str, Any]) -> dict[str, Any]:
"""Update session configuration with the most recent model configuration.
Parameters
@@ -176,7 +187,7 @@ def _update_session_with_current_model_config(session_config: Dict[str, Any]) ->
return updated_config
-def _get_all_user_sessions(user_id: str) -> List[Dict[str, Any]]:
+def _get_all_user_sessions(user_id: str) -> list[dict[str, Any]]:
"""Get all sessions for a user from DynamoDB.
Parameters
@@ -205,7 +216,33 @@ def _get_all_user_sessions(user_id: str) -> List[Dict[str, Any]]:
return response.get("Items", []) # type: ignore [no-any-return]
-def _delete_user_session(session_id: str, user_id: str) -> Dict[str, bool]:
+def _extract_video_s3_keys(session: dict) -> list[str]:
+ """Extract all video S3 keys from a session's history.
+
+ Parameters
+ ----------
+ session : dict
+ The session object containing history.
+
+ Returns
+ -------
+ list[str]
+ A list of S3 keys for videos in the session.
+ """
+ video_keys: list[str] = []
+ for message in session.get("history", []):
+ content = message.get("content")
+ if isinstance(content, list):
+ for item in content:
+ if isinstance(item, dict) and item.get("type") == "video_url":
+ video_url = item.get("video_url", {})
+ s3_key = video_url.get("s3_key")
+ if s3_key:
+ video_keys.append(s3_key)
+ return video_keys
+
+
+def _delete_user_session(session_id: str, user_id: str) -> DeleteResponse:
"""Delete a session from DynamoDB.
Parameters
@@ -217,21 +254,51 @@ def _delete_user_session(session_id: str, user_id: str) -> Dict[str, bool]:
Returns
-------
- Dict[str, bool]
- A dictionary containing the deleted status.
+ DeleteResponse
+ Response containing the deleted status.
"""
deleted = False
try:
+ # First, get the session to extract any video S3 keys before deleting
+ response = table.get_item(Key={"sessionId": session_id, "userId": user_id})
+ session = response.get("Item", {})
+
+ # Decrypt session if encrypted to access history for video keys
+ if session.get("is_encrypted", False):
+ try:
+ logger.info(f"Decrypting session {session_id} to extract video keys for deletion")
+ session = decrypt_session_fields(session, user_id, session_id)
+ except SessionEncryptionError as e:
+ logger.warning(f"Failed to decrypt session {session_id} for video cleanup: {e}")
+ # Continue with deletion even if decryption fails - videos may remain orphaned
+
+ # Extract video S3 keys from the session history
+ video_keys = _extract_video_s3_keys(session)
+
+ # Delete the session from DynamoDB
table.delete_item(Key={"sessionId": session_id, "userId": user_id})
+
+ # Delete associated images from S3
bucket = s3_resource.Bucket(s3_bucket_name)
bucket.objects.filter(Prefix=f"images/{session_id}").delete()
+
+ # Delete associated videos from S3
+ if video_keys:
+ logger.info(f"Deleting {len(video_keys)} videos from S3 for session {session_id}")
+ for video_key in video_keys:
+ try:
+ s3_client.delete_object(Bucket=s3_bucket_name, Key=video_key)
+ logger.debug(f"Deleted video: {video_key}")
+ except ClientError as video_error:
+ logger.warning(f"Failed to delete video {video_key}: {video_error}")
+
deleted = True
except ClientError as error:
if error.response["Error"]["Code"] == "ResourceNotFoundException":
logger.warning(f"No record found with session id: {session_id}")
else:
logger.exception("Error deleting session")
- return {"deleted": deleted}
+ return DeleteResponse(deleted=deleted)
def _generate_presigned_image_url(key: str) -> str:
@@ -248,21 +315,33 @@ def _generate_presigned_image_url(key: str) -> str:
return url
-def _map_session(session: dict, user_id: Optional[str] = None) -> Dict[str, Any]:
- return {
- "sessionId": session.get("sessionId", None),
- "name": session.get("name", None),
- "firstHumanMessage": _find_first_human_message(session, user_id),
- "startTime": session.get("startTime", None),
- "createTime": session.get("createTime", None),
- "lastUpdated": session.get(
- "lastUpdated", session.get("startTime", None)
- ), # Fallback to startTime for backward compatibility
- "isEncrypted": session.get("is_encrypted", False),
- }
+def _generate_presigned_video_url(key: str) -> str:
+ url: str = s3_client.generate_presigned_url(
+ "get_object",
+ Params={
+ "Bucket": s3_bucket_name,
+ "Key": key,
+ "ResponseContentType": "video/mp4",
+ "ResponseCacheControl": "no-cache",
+ "ResponseContentDisposition": "inline",
+ },
+ )
+ return url
+
+
+def _map_session(session: dict, user_id: str | None = None) -> SessionSummary:
+ return SessionSummary(
+ sessionId=session.get("sessionId"),
+ name=session.get("name"),
+ firstHumanMessage=_find_first_human_message(session, user_id),
+ startTime=session.get("startTime"),
+ createTime=session.get("createTime"),
+ lastUpdated=session.get("lastUpdated", session.get("startTime")),
+ isEncrypted=session.get("is_encrypted", False),
+ )
-def _find_first_human_message(session: dict, user_id: Optional[str] = None) -> str:
+def _find_first_human_message(session: dict, user_id: str | None = None) -> str:
# Check if session is encrypted
if session.get("is_encrypted", False):
# For encrypted sessions, decrypt to get the first message
@@ -300,7 +379,7 @@ def _find_first_human_message(session: dict, user_id: Optional[str] = None) -> s
@api_wrapper
-def list_sessions(event: dict, context: dict) -> List[Dict[str, Any]]:
+def list_sessions(event: dict, context: dict) -> list[SessionSummary]:
"""List sessions by user ID from DynamoDB."""
user_id = get_username(event)
@@ -310,17 +389,26 @@ def list_sessions(event: dict, context: dict) -> List[Dict[str, Any]]:
return list(executor.map(lambda session: _map_session(session, user_id), sessions))
-def _process_image(task: Tuple[dict, str]) -> None:
+def _process_image(task: tuple[dict, str]) -> None:
msg, key = task
try:
image_url = _generate_presigned_image_url(key)
msg["image_url"]["url"] = image_url
except Exception as e:
- print(f"Error uploading to S3: {e}")
+ print(f"Error generating presigned image URL: {e}")
+
+
+def _process_video(task: tuple[dict, str]) -> None:
+ msg, key = task
+ try:
+ video_url = _generate_presigned_video_url(key)
+ msg["video_url"]["url"] = video_url
+ except Exception as e:
+ print(f"Error generating presigned video URL: {e}")
@api_wrapper
-def get_session(event: dict, context: dict) -> dict:
+def get_session(event: dict, context: dict) -> Session | dict:
"""Get a session from DynamoDB."""
try:
user_id = get_username(event)
@@ -329,51 +417,55 @@ def get_session(event: dict, context: dict) -> dict:
logging.info(f"Fetching session with ID {session_id} for user {user_id}")
response = table.get_item(Key={"sessionId": session_id, "userId": user_id})
- resp = response.get("Item", {})
+ item = response.get("Item", {})
- if not resp:
+ if not item:
return {"statusCode": 404, "body": json.dumps({"error": "Session not found"})}
# Check if session data is encrypted and decrypt if necessary
try:
- if resp.get("is_encrypted", False):
+ if item.get("is_encrypted", False):
logging.info(f"Decrypting encrypted session {session_id} for user {user_id}")
- resp = decrypt_session_fields(resp, user_id, session_id)
+ item = decrypt_session_fields(item, user_id, session_id)
except SessionEncryptionError as e:
logging.error(f"Failed to decrypt session {session_id}: {e}")
return {"statusCode": 500, "body": json.dumps({"error": "Failed to decrypt session data"})}
+ # Create Session object from DynamoDB item
+ session = Session.from_dynamodb_item(item)
+
# Update configuration with current model settings before returning
- if resp and resp.get("configuration"):
- configuration = resp.get("configuration", {})
- # Update the selectedModel within the configuration with current model settings
- if 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"]
- )
- # Update the configuration in the response
- resp["configuration"] = configuration
-
- # Create a list of tasks for parallel processing
- tasks = []
- for message in resp.get("history", []):
- if isinstance(message.get("content", None), List):
- for item in message.get("content", None):
- if item.get("type", None) == "image_url":
- s3_key = item.get("image_url", {}).get("s3_key", None)
+ 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"]
+ )
+
+ # Create a list of tasks for parallel processing presigned URLs
+ image_tasks = []
+ video_tasks = []
+ for message in session.history:
+ if isinstance(message.get("content"), list):
+ for item in message.get("content", []):
+ if item.get("type") == "image_url":
+ s3_key = item.get("image_url", {}).get("s3_key")
if s3_key:
- tasks.append((item, s3_key))
+ image_tasks.append((item, s3_key))
+ elif item.get("type") == "video_url":
+ s3_key = item.get("video_url", {}).get("s3_key")
+ if s3_key:
+ video_tasks.append((item, s3_key))
- list(executor.map(_process_image, tasks))
- return resp # type: ignore [no-any-return]
+ list(executor.map(_process_image, image_tasks))
+ list(executor.map(_process_video, video_tasks))
+ return session
except ValueError as e:
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
@api_wrapper
-def delete_session(event: dict, context: dict) -> dict:
+def delete_session(event: dict, context: dict) -> DeleteResponse:
"""Delete session from DynamoDB."""
user_id = get_username(event)
session_id = get_session_id(event)
@@ -383,7 +475,7 @@ def delete_session(event: dict, context: dict) -> dict:
@api_wrapper
-def delete_user_sessions(event: dict, context: dict) -> Dict[str, bool]:
+def delete_user_sessions(event: dict, context: dict) -> DeleteResponse:
"""Delete sessions by user ID from DyanmoDB."""
user_id = get_username(event)
@@ -392,10 +484,10 @@ def delete_user_sessions(event: dict, context: dict) -> Dict[str, bool]:
logger.debug(f"Found user sessions: {sessions}")
list(executor.map(lambda session: _delete_user_session(session["sessionId"], user_id), sessions))
- return {"deleted": True}
+ return DeleteResponse(deleted=True)
-@api_wrapper
+@api_wrapper(max_request_size=MAX_LARGE_REQUEST_SIZE)
def attach_image_to_session(event: dict, context: dict) -> dict:
"""Append the message to the record in DynamoDB."""
try:
@@ -406,10 +498,12 @@ def attach_image_to_session(event: dict, context: dict) -> dict:
except json.JSONDecodeError as e:
return {"statusCode": 400, "body": json.dumps({"error": f"Invalid JSON: {str(e)}"})}
- if "message" not in body:
- return {"statusCode": 400, "body": json.dumps({"error": "Missing required fields: messages"})}
+ try:
+ request = AttachImageRequest.model_validate(body)
+ except ValidationError as e:
+ return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
- message = body["message"]
+ message = request.message
image_content = message.get("image_url", {}).get("url", None)
if (
@@ -440,7 +534,7 @@ def attach_image_to_session(event: dict, context: dict) -> dict:
@api_wrapper
-def rename_session(event: dict, context: dict) -> dict:
+def rename_session(event: dict, context: dict) -> SuccessResponse | dict:
"""Update session name in DynamoDB."""
try:
user_id = get_username(event)
@@ -451,22 +545,24 @@ def rename_session(event: dict, context: dict) -> dict:
except json.JSONDecodeError as e:
return {"statusCode": 400, "body": json.dumps({"error": f"Invalid JSON: {str(e)}"})}
- if "name" not in body:
- return {"statusCode": 400, "body": json.dumps({"error": "Missing required field: name"})}
+ try:
+ request = RenameSessionRequest.model_validate(body)
+ except ValidationError as e:
+ return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
table.update_item(
Key={"sessionId": session_id, "userId": user_id},
UpdateExpression="SET #name = :name, #lastUpdated = :lastUpdated",
ExpressionAttributeNames={"#name": "name", "#lastUpdated": "lastUpdated"},
- ExpressionAttributeValues={":name": body.get("name"), ":lastUpdated": iso_string()},
+ ExpressionAttributeValues={":name": request.name, ":lastUpdated": iso_string()},
)
- return {"statusCode": 200, "body": json.dumps({"message": "Session name updated successfully"})}
+ return SuccessResponse(message="Session name updated successfully")
except ValueError as e:
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
-@api_wrapper
-def put_session(event: dict, context: dict) -> dict:
+@api_wrapper(max_request_size=MAX_LARGE_REQUEST_SIZE)
+def put_session(event: dict, context: dict) -> SuccessResponse | dict:
"""Append the message to the record in DynamoDB."""
try:
user_id, _, groups = get_user_context(event)
@@ -477,13 +573,13 @@ def put_session(event: dict, context: dict) -> dict:
except json.JSONDecodeError as e:
return {"statusCode": 400, "body": json.dumps({"error": f"Invalid JSON: {str(e)}"})}
- if "messages" not in body:
- return {"statusCode": 400, "body": json.dumps({"error": "Missing required fields: messages"})}
-
- messages = body["messages"]
+ try:
+ request = PutSessionRequest.model_validate(body)
+ except ValidationError as e:
+ return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
# Get the configuration from the request body (what the frontend sends)
- configuration = body.get("configuration", {})
+ configuration = request.configuration or {}
# Update the selectedModel within the configuration with current model settings
if configuration and configuration.get("selectedModel"):
@@ -495,20 +591,13 @@ def put_session(event: dict, context: dict) -> dict:
encryption_enabled = _is_session_encryption_enabled()
# Prepare session data for storage
- session_data = {
- "history": messages,
- "name": body.get("name", None),
- "configuration": configuration,
- "startTime": iso_string(),
- "createTime": iso_string(),
- "lastUpdated": iso_string(),
- }
+ session_data = request.to_session_data(configuration)
# Encrypt sensitive data if encryption is enabled
if encryption_enabled:
try:
logging.info(f"Encrypting session {session_id} for user {user_id}")
- encrypted_session = migrate_session_to_encrypted(session_data, user_id, session_id)
+ encrypted_session = migrate_session_to_encrypted(session_data.model_dump(), user_id, session_id)
# Update DynamoDB with encrypted data
table.update_item(
@@ -559,12 +648,12 @@ def put_session(event: dict, context: dict) -> dict:
"#is_encrypted": "is_encrypted",
},
ExpressionAttributeValues={
- ":history": messages,
- ":name": body.get("name", None),
- ":configuration": configuration,
- ":startTime": iso_string(),
- ":createTime": iso_string(),
- ":lastUpdated": iso_string(),
+ ":history": session_data.history,
+ ":name": session_data.name,
+ ":configuration": session_data.configuration,
+ ":startTime": session_data.startTime,
+ ":createTime": session_data.createTime,
+ ":lastUpdated": session_data.lastUpdated,
":is_encrypted": False,
},
ReturnValues="UPDATED_NEW",
@@ -580,17 +669,16 @@ def put_session(event: dict, context: dict) -> dict:
# Only publish metrics for non-API-token users (JWT/UI users)
if auth_type != "api_token" and "USAGE_METRICS_QUEUE_NAME" in os.environ:
- # Create a copy of the event to send to SQS
- metrics_event = {
- "userId": user_id,
- "sessionId": session_id,
- "messages": messages,
- "userGroups": groups,
- "timestamp": iso_string(),
- }
+ metrics_event = MetricsEvent(
+ userId=user_id,
+ sessionId=session_id,
+ messages=session_data.history,
+ userGroups=groups,
+ timestamp=session_data.lastUpdated,
+ )
sqs_client.send_message(
QueueUrl=os.environ["USAGE_METRICS_QUEUE_NAME"],
- MessageBody=json.dumps(convert_decimal(metrics_event)),
+ MessageBody=json.dumps(convert_decimal(metrics_event.model_dump())),
)
logger.info(f"Published metrics event to queue for user: {user_id}")
else:
@@ -598,6 +686,6 @@ def put_session(event: dict, context: dict) -> dict:
except Exception as e:
logger.error(f"Failed to publish to metrics queue: {e}")
- return {"statusCode": 200, "body": json.dumps({"message": "Session updated successfully"})}
+ return SuccessResponse(message="Session updated successfully")
except ValueError as e:
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
diff --git a/lambda/session/models.py b/lambda/session/models.py
new file mode 100644
index 000000000..7b4a6aaa7
--- /dev/null
+++ b/lambda/session/models.py
@@ -0,0 +1,117 @@
+# 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.
+
+"""Pydantic models for session API requests and responses."""
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+from utilities.time import iso_string
+
+
+class SessionData(BaseModel):
+ """Session data model for DynamoDB storage."""
+
+ history: list[dict[str, Any]]
+ name: str | None
+ configuration: dict[str, Any]
+ startTime: str
+ createTime: str
+ lastUpdated: str
+
+
+class EncryptedSessionData(BaseModel):
+ """Encrypted session data model for DynamoDB storage."""
+
+ encrypted_history: str
+ name: str | None
+ encrypted_configuration: str
+ startTime: str
+ createTime: str
+ lastUpdated: str
+ encryption_version: str = "1.0"
+ is_encrypted: bool = True
+
+
+class Session(BaseModel):
+ """Full session model from DynamoDB."""
+
+ sessionId: str
+ userId: str
+ history: list[dict[str, Any]] = Field(default_factory=list)
+ name: str | None = None
+ configuration: dict[str, Any] = Field(default_factory=dict)
+ startTime: str | None = None
+ createTime: str | None = None
+ lastUpdated: str | None = None
+
+ @classmethod
+ def from_dynamodb_item(cls, item: dict[str, Any]) -> "Session":
+ """Create a Session from a DynamoDB item."""
+ return cls(
+ sessionId=item.get("sessionId", ""),
+ userId=item.get("userId", ""),
+ history=item.get("history", []),
+ name=item.get("name"),
+ configuration=item.get("configuration", {}),
+ startTime=item.get("startTime"),
+ createTime=item.get("createTime"),
+ lastUpdated=item.get("lastUpdated"),
+ )
+
+
+class SessionSummary(BaseModel):
+ """Summary of a session for list responses."""
+
+ sessionId: str | None = None
+ name: str | None = None
+ firstHumanMessage: str = ""
+ startTime: str | None = None
+ createTime: str | None = None
+ lastUpdated: str | None = None
+ isEncrypted: bool = False
+
+
+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"
+ )
+ name: str | None = Field(default=None, description="Optional session name")
+
+ def to_session_data(self, configuration: dict[str, Any] | None = None) -> SessionData:
+ """Convert request to session data for DynamoDB storage."""
+ timestamp = iso_string()
+ return SessionData(
+ history=self.messages,
+ name=self.name,
+ configuration=configuration if configuration is not None else (self.configuration or {}),
+ startTime=timestamp,
+ createTime=timestamp,
+ lastUpdated=timestamp,
+ )
+
+
+class RenameSessionRequest(BaseModel):
+ """Request model for renaming a session."""
+
+ name: str = Field(description="New session name")
+
+
+class AttachImageRequest(BaseModel):
+ """Request model for attaching an image to a session."""
+
+ message: dict[str, Any] = Field(description="Message object containing image data")
diff --git a/lambda/utilities/auth.py b/lambda/utilities/auth.py
index 47c7db756..3cd26dde1 100644
--- a/lambda/utilities/auth.py
+++ b/lambda/utilities/auth.py
@@ -16,13 +16,19 @@
import logging
import os
import secrets
+import sys
+from collections.abc import Callable
from functools import wraps
-from typing import Any, Callable, Dict, List, Tuple
+from typing import Any
import boto3
from botocore.config import Config
+from fastapi import HTTPException as FastAPIHTTPException
+from fastapi import Request
from utilities.exceptions import HTTPException
+from .auth_provider import get_authorization_provider
+
logger = logging.getLogger(__name__)
retry_config = Config(
@@ -42,26 +48,27 @@ def get_username(event: dict) -> str:
return username
-def get_groups(event: Any) -> List[str]:
+def get_groups(event: Any) -> list[str]:
"""Get user groups from event."""
- groups: List[str] = json.loads(event.get("requestContext", {}).get("authorizer", {}).get("groups", "[]"))
+ groups: list[str] = json.loads(event.get("requestContext", {}).get("authorizer", {}).get("groups", "[]"))
return groups
def is_admin(event: dict) -> bool:
- """Get admin status from event."""
- admin_group = os.environ.get("ADMIN_GROUP", "")
+ """Get admin status from event using the configured authorization provider."""
+ username = get_username(event)
groups = get_groups(event)
- logger.info(f"User groups: {groups} and admin: {admin_group}")
- return admin_group in groups
+ auth_provider = get_authorization_provider()
+ result = auth_provider.check_admin_access(username, groups)
+ return result
-def get_user_context(event: Dict[str, Any]) -> Tuple[str, bool, List[str]]:
+def get_user_context(event: dict[str, Any]) -> tuple[str, bool, list[str]]:
"""Extract user context from event."""
return get_username(event), is_admin(event), get_groups(event)
-def user_has_group_access(user_groups: List[str], allowed_groups: List[str]) -> bool:
+def user_has_group_access(user_groups: list[str], allowed_groups: list[str]) -> bool:
"""
Check if user has access based on group membership.
@@ -81,10 +88,10 @@ def user_has_group_access(user_groups: List[str], allowed_groups: List[str]) ->
def admin_only(func: Callable) -> Callable:
- """Annotation to wrap is_admin"""
+ """Annotation to wrap is_admin for traditional Lambda handlers (event, context signature)."""
@wraps(func)
- def wrapper(event: Dict[str, Any], context: Dict[str, Any], *args: Any, **kwargs: Any) -> Any:
+ 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")
return func(event, context, *args, **kwargs)
@@ -92,11 +99,66 @@ 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=500, 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=403, 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"]
secret_response = secrets_client.get_secret_value(SecretId=secret_name)
- return secret_response["SecretString"]
+ return secret_response["SecretString"] # type: ignore[no-any-return]
# API token utility functions
diff --git a/lambda/utilities/auth_provider.py b/lambda/utilities/auth_provider.py
new file mode 100644
index 000000000..0f28c645d
--- /dev/null
+++ b/lambda/utilities/auth_provider.py
@@ -0,0 +1,176 @@
+# 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.
+
+"""Authorization provider abstraction for pluggable auth implementations."""
+import logging
+import os
+from abc import ABC, abstractmethod
+
+logger = logging.getLogger(__name__)
+
+
+class AuthorizationProvider(ABC):
+ """Abstract base class for authorization providers.
+
+ This abstraction allows swapping between different authorization backends
+ (e.g., OIDC group-based, BRASS bindle lock) without changing the consuming code.
+ """
+
+ @abstractmethod
+ def check_admin_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if a user has admin access.
+
+ Parameters
+ ----------
+ username : str
+ The username to check admin access for
+ groups : list[str] | None
+ Optional list of groups the user belongs to (used by group-based providers)
+
+ Returns
+ -------
+ bool
+ True if user has admin access, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def check_app_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if a user has general application access.
+
+ Parameters
+ ----------
+ username : str
+ The username to check app access for
+ groups : list[str] | None
+ Optional list of groups the user belongs to (used by group-based providers)
+
+ Returns
+ -------
+ bool
+ True if user has app access, False otherwise
+ """
+ pass
+
+
+class OIDCAuthorizationProvider(AuthorizationProvider):
+ """OIDC group-based authorization provider.
+
+ Uses JWT group claims to determine admin and app access.
+ """
+
+ def __init__(self, admin_group: str | None = None, user_group: str | None = None):
+ """Initialize the OIDC authorization provider.
+
+ Parameters
+ ----------
+ admin_group : str | None
+ The admin group name. If not provided, uses ADMIN_GROUP env var at check time.
+ user_group : str | None
+ The user group name. If not provided, uses USER_GROUP env var at check time.
+ """
+ self._admin_group = admin_group
+ self._user_group = user_group
+
+ @property
+ def admin_group(self) -> str:
+ """Get admin group, reading from env if not explicitly set."""
+ return self._admin_group if self._admin_group is not None else os.environ.get("ADMIN_GROUP", "")
+
+ @property
+ def user_group(self) -> str:
+ """Get user group, reading from env if not explicitly set."""
+ return self._user_group if self._user_group is not None else os.environ.get("USER_GROUP", "")
+
+ def check_admin_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if user has admin access based on group membership.
+
+ Parameters
+ ----------
+ username : str
+ The username (not used for group-based auth, but required by interface)
+ groups : list[str] | None
+ List of groups the user belongs to
+
+ Returns
+ -------
+ bool
+ True if user is in admin group, False otherwise
+ """
+ if not groups:
+ logger.debug(f"No groups provided for user {username}")
+ return False
+
+ is_admin = self.admin_group in groups
+ logger.info(f"User groups: {groups} and admin: {self.admin_group}")
+ return is_admin
+
+ def check_app_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if user has app access based on group membership.
+
+ Parameters
+ ----------
+ username : str
+ The username (not used for group-based auth, but required by interface)
+ groups : list[str] | None
+ List of groups the user belongs to
+
+ Returns
+ -------
+ bool
+ True if user is in user group (or no user group configured), False otherwise
+ """
+ # If no user group is configured, allow all authenticated users
+ if not self.user_group:
+ return True
+
+ if not groups:
+ logger.debug(f"No groups provided for user {username}")
+ return False
+
+ has_access = self.user_group in groups
+ logger.info(
+ f"User {username} app access check: groups={groups}, user_group={self.user_group}, result={has_access}"
+ )
+ return has_access
+
+
+# Singleton instance for the authorization provider
+_auth_provider: AuthorizationProvider | None = None
+
+
+def get_authorization_provider() -> AuthorizationProvider:
+ """Get the configured authorization provider instance.
+
+ Returns
+ -------
+ AuthorizationProvider
+ The authorization provider instance (OIDC-based for LISA)
+ """
+ global _auth_provider
+ if _auth_provider is None:
+ _auth_provider = OIDCAuthorizationProvider()
+ return _auth_provider
+
+
+def set_authorization_provider(provider: AuthorizationProvider) -> None:
+ """Set a custom authorization provider (useful for testing).
+
+ Parameters
+ ----------
+ provider : AuthorizationProvider
+ The authorization provider to use
+ """
+ global _auth_provider
+ _auth_provider = provider
diff --git a/lambda/utilities/aws_helpers.py b/lambda/utilities/aws_helpers.py
new file mode 100644
index 000000000..1ae06e207
--- /dev/null
+++ b/lambda/utilities/aws_helpers.py
@@ -0,0 +1,201 @@
+# 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.
+
+"""AWS-specific helper utilities."""
+
+import logging
+import os
+import tempfile
+from functools import cache
+from typing import Any, cast
+
+import boto3
+from botocore.config import Config
+
+logger = logging.getLogger(__name__)
+
+# Boto3 retry configuration
+retry_config = Config(
+ retries={
+ "max_attempts": 3,
+ "mode": "standard",
+ },
+)
+
+# Global SSM client
+ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], config=retry_config)
+
+# Global certificate file handle
+_cert_file = None
+
+
+@cache
+def get_cert_path(iam_client: Any) -> str | bool:
+ """
+ Get certificate path for SSL validation against LISA Serve endpoint.
+
+ This function retrieves IAM server certificates for SSL verification.
+ For ACM certificates or when no certificate is specified, it returns
+ True to use default verification.
+
+ Parameters
+ ----------
+ iam_client : Any
+ Boto3 IAM client instance.
+
+ Returns
+ -------
+ Union[str, bool]
+ Path to certificate file, or True to use default verification.
+
+ Example
+ -------
+ >>> iam = boto3.client("iam")
+ >>> cert_path = get_cert_path(iam)
+ >>> if isinstance(cert_path, str):
+ ... # Use custom certificate
+ ... requests.get(url, verify=cert_path)
+ ... else:
+ ... # Use default verification
+ ... requests.get(url, verify=True)
+ """
+ global _cert_file
+
+ cert_arn = os.environ.get("RESTAPI_SSL_CERT_ARN")
+ if not cert_arn:
+ logger.info("No SSL certificate ARN specified, using default verification")
+ return True
+
+ # For ACM certificates, use default verification since they are trusted AWS certificates
+ if ":acm:" in cert_arn:
+ logger.info("ACM certificate detected, using default SSL verification")
+ return True
+
+ try:
+ # Clean up previous cert file if it exists
+ if _cert_file and os.path.exists(_cert_file.name):
+ try:
+ os.unlink(_cert_file.name)
+ except Exception as e:
+ logger.warning(f"Failed to clean up previous cert file: {e}")
+
+ # Get the certificate name from the ARN
+ cert_name = cert_arn.split("/")[1]
+ logger.info(f"Retrieving certificate '{cert_name}' from IAM")
+
+ # Get the certificate from IAM
+ rest_api_cert = iam_client.get_server_certificate(ServerCertificateName=cert_name)
+ cert_body = rest_api_cert["ServerCertificate"]["CertificateBody"]
+
+ # Create a new temporary file
+ _cert_file = tempfile.NamedTemporaryFile(delete=False)
+ _cert_file.write(cert_body.encode("utf-8"))
+ _cert_file.flush()
+
+ logger.info(f"Certificate saved to temporary file: {_cert_file.name}")
+ return _cert_file.name
+
+ except Exception as e:
+ logger.error(f"Failed to get certificate from IAM: {e}", exc_info=True)
+ # If we fail to get the cert, return True to fall back to default verification
+ return True
+
+
+@cache
+def get_rest_api_container_endpoint() -> str:
+ """
+ Get REST API container base URI from SSM Parameter Store.
+
+ Returns
+ -------
+ str
+ The REST API container endpoint URL.
+
+ Example
+ -------
+ >>> endpoint = get_rest_api_container_endpoint()
+ >>> endpoint
+ 'https://api.example.com/v1/serve'
+ """
+ lisa_api_param_response = ssm_client.get_parameter(Name=os.environ["LISA_API_URL_PS_NAME"])
+ lisa_api_endpoint = lisa_api_param_response["Parameter"]["Value"]
+ return f"{lisa_api_endpoint}/{os.environ['REST_API_VERSION']}/serve"
+
+
+def _get_lambda_role_arn() -> str:
+ """
+ Get the ARN of the Lambda execution role.
+
+ Returns
+ -------
+ str
+ The full ARN of the Lambda execution role.
+
+ Example
+ -------
+ >>> _get_lambda_role_arn()
+ 'arn:aws:sts::123456789012:assumed-role/MyLambdaRole/MyFunction'
+ """
+ sts = boto3.client("sts", region_name=os.environ["AWS_REGION"])
+ identity = sts.get_caller_identity()
+ return cast(str, identity["Arn"])
+
+
+def get_lambda_role_name() -> str:
+ """
+ Extract the role name from the Lambda execution role ARN.
+
+ Returns
+ -------
+ str
+ The name of the Lambda execution role without the full ARN.
+
+ Example
+ -------
+ >>> get_lambda_role_name()
+ 'MyLambdaRole'
+ """
+ arn = _get_lambda_role_arn()
+ parts = arn.split(":assumed-role/")[1].split("/")
+ return parts[0]
+
+
+def get_account_and_partition() -> tuple[str, str]:
+ """
+ Get AWS account ID and partition from environment or ECR repository ARN.
+
+ Returns
+ -------
+ tuple[str, str]
+ Tuple of (account_id, partition).
+
+ Example
+ -------
+ >>> account_id, partition = get_account_and_partition()
+ >>> account_id
+ '123456789012'
+ >>> partition
+ 'aws'
+ """
+ account_id = os.environ.get("AWS_ACCOUNT_ID", "")
+ partition = os.environ.get("AWS_PARTITION", "aws")
+
+ if not account_id:
+ ecr_repo_arn = os.environ.get("ECR_REPOSITORY_ARN", "")
+ if ecr_repo_arn:
+ arn_parts = ecr_repo_arn.split(":")
+ partition = arn_parts[1]
+ account_id = arn_parts[4]
+
+ return account_id, partition
diff --git a/lambda/utilities/bedrock_kb.py b/lambda/utilities/bedrock_kb.py
index 8326bfc65..344fef29f 100644
--- a/lambda/utilities/bedrock_kb.py
+++ b/lambda/utilities/bedrock_kb.py
@@ -22,7 +22,7 @@
import logging
import os
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any
from models.domain_objects import (
IngestionJob,
@@ -45,8 +45,8 @@ def __init__(
skipped: int = 0,
successful: int = 0,
failed: int = 0,
- document_ids: Optional[List[str]] = None,
- errors: Optional[List[str]] = None,
+ document_ids: list[str] | None = None,
+ errors: list[str] | None = None,
):
self.discovered = discovered
self.skipped = skipped
@@ -194,7 +194,7 @@ def discover_and_ingest_documents(
logger.error(f"Failed to discover S3 documents: {str(e)}", exc_info=True)
raise
- def _scan_s3_bucket(self, s3_bucket: str, s3_prefix: str) -> Tuple[List[str], int]:
+ def _scan_s3_bucket(self, s3_bucket: str, s3_prefix: str) -> tuple[list[str], int]:
"""
Scan S3 bucket and return list of document keys.
@@ -227,10 +227,10 @@ def _scan_s3_bucket(self, s3_bucket: str, s3_prefix: str) -> Tuple[List[str], in
return documents_to_process, skipped_count
- def _get_collection(self, repository_id: str, collection_id: str) -> Optional[RagCollectionConfig]:
+ def _get_collection(self, repository_id: str, collection_id: str) -> RagCollectionConfig | None:
"""Get collection configuration."""
try:
- return self.collection_service.get_collection(
+ return self.collection_service.get_collection( # type: ignore[no-any-return]
collection_id=collection_id,
repository_id=repository_id,
username="system",
@@ -250,8 +250,8 @@ def _document_exists(self, repository_id: str, collection_id: str, s3_path: str)
def _create_metadata_file(
self,
- repository: Dict[str, Any],
- collection: Optional[RagCollectionConfig],
+ repository: dict[str, Any],
+ collection: RagCollectionConfig | None,
s3_bucket: str,
document_key: str,
repository_id: str,
@@ -297,7 +297,7 @@ def _create_rag_document(
self.rag_document_repository.save(rag_document)
return rag_document.document_id
- def _trigger_kb_sync(self, repository: Dict[str, Any], collection_id: str, document_count: int) -> None:
+ def _trigger_kb_sync(self, repository: dict[str, Any], collection_id: str, document_count: int) -> None:
"""Trigger Bedrock KB sync for ingested documents."""
bedrock_config = repository.get("bedrockKnowledgeBaseConfig", {})
knowledge_base_id = bedrock_config.get("knowledgeBaseId", bedrock_config.get("bedrockKnowledgeBaseId"))
@@ -322,7 +322,7 @@ def _trigger_kb_sync(self, repository: Dict[str, Any], collection_id: str, docum
def get_datasource_bucket_for_collection(
- repository: Dict[str, Any],
+ repository: dict[str, Any],
collection_id: str,
) -> str:
"""
@@ -349,7 +349,7 @@ def get_datasource_bucket_for_collection(
# Try legacy format first
legacy_bucket = bedrock_config.get("bedrockKnowledgeDatasourceS3Bucket")
if legacy_bucket:
- return legacy_bucket
+ return legacy_bucket # type: ignore[no-any-return]
# Try pipelines array (most common in current configs)
pipelines = repository.get("pipelines", [])
@@ -359,7 +359,7 @@ def get_datasource_bucket_for_collection(
s3_bucket = pipeline.get("s3Bucket") if isinstance(pipeline, dict) else pipeline.s3Bucket
if pipeline_collection_id == collection_id and s3_bucket:
- return s3_bucket
+ return s3_bucket # type: ignore[no-any-return]
# Try dataSources array
data_sources = bedrock_config.get("dataSources", [])
@@ -373,7 +373,7 @@ def get_datasource_bucket_for_collection(
if s3_uri and s3_uri.startswith("s3://"):
bucket = s3_uri[5:].split("/")[0]
if bucket:
- return bucket
+ return bucket # type: ignore[no-any-return]
logger.error(f"Invalid s3Uri format for data source {ds_id}: {s3_uri}")
raise ValueError(
@@ -401,7 +401,7 @@ def ingest_document_to_kb(
s3_client: Any,
bedrock_agent_client: Any,
job: IngestionJob,
- repository: Dict[str, Any],
+ repository: dict[str, Any],
) -> None:
"""
Copy the source object into the KB datasource bucket and trigger ingestion. S3 will
@@ -410,6 +410,9 @@ def ingest_document_to_kb(
bedrock_config = repository.get("bedrockKnowledgeBaseConfig", {})
# Get datasource bucket for this collection (supports multiple config formats)
+ if job.collection_id is None:
+ raise ValueError("collection_id is required for Bedrock KB operations")
+
datasource_bucket = get_datasource_bucket_for_collection(
repository=repository,
collection_id=job.collection_id,
@@ -448,12 +451,15 @@ def delete_document_from_kb(
s3_client: Any,
bedrock_agent_client: Any,
job: IngestionJob,
- repository: Dict[str, Any],
+ repository: dict[str, Any],
) -> None:
"""Remove the source object from the KB datasource bucket and re-sync the KB."""
bedrock_config = repository.get("bedrockKnowledgeBaseConfig", {})
# Get datasource bucket for this collection (supports multiple config formats)
+ if job.collection_id is None:
+ raise ValueError("collection_id is required for Bedrock KB operations")
+
datasource_bucket = get_datasource_bucket_for_collection(
repository=repository,
collection_id=job.collection_id,
@@ -485,9 +491,9 @@ def delete_document_from_kb(
def bulk_delete_documents_from_kb(
s3_client: Any,
bedrock_agent_client: Any,
- repository: Dict[str, Any],
- s3_paths: List[str],
- data_source_id: Optional[str] = None,
+ repository: dict[str, Any],
+ s3_paths: list[str],
+ data_source_id: str | None = None,
) -> None:
"""Bulk delete documents from KB datasource bucket and trigger single ingestion.
@@ -550,8 +556,8 @@ def ingest_bedrock_s3_documents(
embedding_model: str,
s3_prefix: str = "",
batch_size: int = 100,
- metadata: Optional[Dict[str, Any]] = None,
-) -> Tuple[int, int]:
+ metadata: dict[str, Any] | None = None,
+) -> tuple[int, int]:
"""
Discover and create ingestion jobs for existing documents in S3 bucket.
@@ -643,7 +649,7 @@ def create_s3_scan_job(
embedding_model: str,
s3_bucket: str,
s3_prefix: str = "",
- metadata: Optional[Dict[str, Any]] = None,
+ metadata: dict[str, Any] | None = None,
) -> str:
"""
Create a batch ingestion job to scan and ingest existing S3 documents.
diff --git a/lambda/utilities/bedrock_kb_discovery.py b/lambda/utilities/bedrock_kb_discovery.py
index bea8ffb93..39b286293 100644
--- a/lambda/utilities/bedrock_kb_discovery.py
+++ b/lambda/utilities/bedrock_kb_discovery.py
@@ -20,7 +20,7 @@
"""
import logging
-from typing import Any, Dict, List, Optional
+from typing import Any
import boto3
from botocore.exceptions import ClientError
@@ -36,8 +36,8 @@
def list_knowledge_bases(
- bedrock_agent_client: Optional[Any] = None,
-) -> List[KnowledgeBaseMetadata]:
+ bedrock_agent_client: Any | None = None,
+) -> list[KnowledgeBaseMetadata]:
"""
List all Knowledge Bases accessible in the AWS account.
@@ -93,8 +93,8 @@ def list_knowledge_bases(
def discover_kb_data_sources(
kb_id: str,
- bedrock_agent_client: Optional[Any] = None,
-) -> List[DataSourceMetadata]:
+ bedrock_agent_client: Any | None = None,
+) -> list[DataSourceMetadata]:
"""
Discover all data sources in a Bedrock Knowledge Base.
@@ -174,7 +174,7 @@ def discover_kb_data_sources(
raise ValidationError(f"Unexpected error discovering data sources: {str(e)}")
-def extract_s3_configuration(data_source: Dict[str, Any]) -> Dict[str, str]:
+def extract_s3_configuration(data_source: dict[str, Any]) -> dict[str, str]:
"""Extract S3 bucket and prefix from data source configuration.
Args:
@@ -199,7 +199,7 @@ def extract_s3_configuration(data_source: Dict[str, Any]) -> Dict[str, str]:
def build_pipeline_configs_from_kb_config(
kb_config: Any,
-) -> List[Dict[str, Any]]:
+) -> list[dict[str, Any]]:
"""Build PipelineConfigs from BedrockKnowledgeBaseConfig.
Args:
@@ -276,9 +276,9 @@ def build_pipeline_configs_from_kb_config(
def get_available_data_sources(
kb_id: str,
- repository_id: Optional[str] = None,
- bedrock_agent_client: Optional[Any] = None,
-) -> List[DataSourceMetadata]:
+ repository_id: str | None = None,
+ bedrock_agent_client: Any | None = None,
+) -> list[DataSourceMetadata]:
"""
Get all data sources for a Knowledge Base.
diff --git a/lambda/utilities/bedrock_kb_validation.py b/lambda/utilities/bedrock_kb_validation.py
index a4c65d86f..7b529bcb5 100644
--- a/lambda/utilities/bedrock_kb_validation.py
+++ b/lambda/utilities/bedrock_kb_validation.py
@@ -15,7 +15,7 @@
"""Validation utilities for Bedrock Knowledge Base operations."""
import logging
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
from botocore.exceptions import ClientError
@@ -24,7 +24,7 @@
logger = logging.getLogger(__name__)
-def validate_bedrock_kb_exists(kb_id: str, bedrock_agent_client: Optional[Any] = None) -> Dict[str, Any]:
+def validate_bedrock_kb_exists(kb_id: str, bedrock_agent_client: Any | None = None) -> dict[str, Any]:
"""
Validate that a Bedrock Knowledge Base exists and is accessible.
@@ -46,7 +46,7 @@ def validate_bedrock_kb_exists(kb_id: str, bedrock_agent_client: Optional[Any] =
kb_config = response.get("knowledgeBase", {})
logger.info(f"Validated Knowledge Base {kb_id}: {kb_config.get('name')}")
- return kb_config
+ return kb_config # type: ignore[no-any-return]
except ClientError as e:
error_code = e.response.get("Error", {}).get("Code", "")
@@ -67,8 +67,8 @@ def validate_bedrock_kb_exists(kb_id: str, bedrock_agent_client: Optional[Any] =
def validate_data_source_exists(
- kb_id: str, data_source_id: str, bedrock_agent_client: Optional[Any] = None
-) -> Dict[str, Any]:
+ kb_id: str, data_source_id: str, bedrock_agent_client: Any | None = None
+) -> dict[str, Any]:
"""
Validate that a data source exists in a Bedrock Knowledge Base.
@@ -91,7 +91,7 @@ def validate_data_source_exists(
data_source_config = response.get("dataSource", {})
logger.info(f"Validated Data Source {data_source_id} in KB {kb_id}: " f"{data_source_config.get('name')}")
- return data_source_config
+ return data_source_config # type: ignore[no-any-return]
except ClientError as e:
error_code = e.response.get("Error", {}).get("Code", "")
@@ -113,8 +113,8 @@ def validate_data_source_exists(
def validate_bedrock_kb_repository(
- kb_id: str, data_source_id: str, bedrock_agent_client: Optional[Any] = None
-) -> tuple[Dict[str, Any], Dict[str, Any]]:
+ kb_id: str, data_source_id: str, bedrock_agent_client: Any | None = None
+) -> tuple[dict[str, Any], dict[str, Any]]:
"""
Validate both Knowledge Base and Data Source exist.
diff --git a/lambda/utilities/chunking_strategy_factory.py b/lambda/utilities/chunking_strategy_factory.py
index cc7c8b632..0e9762a0e 100644
--- a/lambda/utilities/chunking_strategy_factory.py
+++ b/lambda/utilities/chunking_strategy_factory.py
@@ -16,7 +16,6 @@
import logging
import os
from abc import ABC, abstractmethod
-from typing import List
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
@@ -25,27 +24,30 @@
logger = logging.getLogger(__name__)
-DEFAULT_STRATEGY = FixedChunkingStrategy(size=os.getenv("CHUNK_SIZE", "512"), overlap=os.getenv("CHUNK_OVERLAP", "51"))
+DEFAULT_STRATEGY = FixedChunkingStrategy(
+ size=int(os.getenv("CHUNK_SIZE", "512")),
+ overlap=int(os.getenv("CHUNK_OVERLAP", "51")),
+)
class ChunkingStrategyHandler(ABC):
"""Abstract base class for chunking strategy handlers."""
@abstractmethod
- def chunk_documents(self, docs: List[Document], strategy: ChunkingStrategy) -> List[Document]:
+ def chunk_documents(self, docs: list[Document], strategy: ChunkingStrategy) -> list[Document]:
"""
Chunk documents according to the strategy.
Parameters
----------
- docs : List[Document]
+ docs : list[Document]
List of documents to chunk
strategy : ChunkingStrategy
The chunking strategy configuration
Returns
-------
- List[Document]
+ list[Document]
List of chunked documents
"""
pass
@@ -54,22 +56,26 @@ def chunk_documents(self, docs: List[Document], strategy: ChunkingStrategy) -> L
class FixedSizeChunkingHandler(ChunkingStrategyHandler):
"""Handler for fixed-size chunking strategy."""
- def chunk_documents(self, docs: List[Document], strategy: ChunkingStrategy = DEFAULT_STRATEGY) -> List[Document]:
+ def chunk_documents(self, docs: list[Document], strategy: ChunkingStrategy = DEFAULT_STRATEGY) -> list[Document]:
"""
Chunk documents using fixed-size strategy with RecursiveCharacterTextSplitter.
Parameters
----------
- docs : List[Document]
+ docs : list[Document]
List of documents to chunk
strategy : ChunkingStrategy
The chunking strategy configuration (FixedChunkingStrategy)
Returns
-------
- List[Document]
+ list[Document]
List of chunked documents
"""
+ # Ensure we have a FixedChunkingStrategy
+ if not isinstance(strategy, FixedChunkingStrategy):
+ raise ValueError(f"Expected FixedChunkingStrategy, got {type(strategy).__name__}")
+
# Handle both legacy (size/overlap) and new (chunkSize/chunkOverlap) formats
chunk_size = strategy.size
chunk_overlap = strategy.overlap
@@ -94,26 +100,27 @@ def chunk_documents(self, docs: List[Document], strategy: ChunkingStrategy = DEF
chunk_overlap=chunk_overlap,
length_function=len,
)
- return text_splitter.split_documents(docs) # type: ignore [no-any-return]
+ result: list[Document] = text_splitter.split_documents(docs)
+ return result
class NoneChunkingHandler(ChunkingStrategyHandler):
"""Handler for no-chunking strategy - returns documents as-is."""
- def chunk_documents(self, docs: List[Document], strategy: ChunkingStrategy) -> List[Document]:
+ def chunk_documents(self, docs: list[Document], strategy: ChunkingStrategy) -> list[Document]:
"""
Return documents without chunking.
Parameters
----------
- docs : List[Document]
+ docs : list[Document]
List of documents to process
strategy : ChunkingStrategy
The chunking strategy configuration (NoneChunkingStrategy)
Returns
-------
- List[Document]
+ list[Document]
Original list of documents unmodified
"""
logger.info(f"Processing {len(docs)} documents with NONE chunking strategy (no chunking)")
@@ -129,20 +136,20 @@ class ChunkingStrategyFactory:
}
@classmethod
- def chunk_documents(cls, docs: List[Document], strategy: ChunkingStrategy = DEFAULT_STRATEGY) -> List[Document]:
+ def chunk_documents(cls, docs: list[Document], strategy: ChunkingStrategy = DEFAULT_STRATEGY) -> list[Document]:
"""
Chunk documents using the appropriate strategy handler.
Parameters
----------
- docs : List[Document]
+ docs : list[Document]
List of documents to chunk
strategy : ChunkingStrategy
The chunking strategy configuration
Returns
-------
- List[Document]
+ list[Document]
List of chunked documents
Raises
@@ -180,13 +187,13 @@ def register_handler(cls, strategy_type: ChunkingStrategyType, handler: Chunking
logger.info(f"Registered chunking strategy handler: {strategy_type.value}")
@classmethod
- def get_supported_strategies(cls) -> List[ChunkingStrategyType]:
+ def get_supported_strategies(cls) -> list[ChunkingStrategyType]:
"""
Get list of supported chunking strategy types.
Returns
-------
- List[ChunkingStrategyType]
+ list[ChunkingStrategyType]
List of supported strategy types
"""
return list(cls._handlers.keys())
diff --git a/lambda/utilities/common_functions.py b/lambda/utilities/common_functions.py
index 24c70d48e..3027f1032 100644
--- a/lambda/utilities/common_functions.py
+++ b/lambda/utilities/common_functions.py
@@ -12,37 +12,42 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Common helper functions for RAG Lambdas."""
-import copy
-import functools
-import json
+"""
+Common helper functions for RAG Lambdas.
+
+DEPRECATED: This module is maintained for backward compatibility.
+New code should import from the specific utility modules:
+
+- lambda_decorators: api_wrapper, authorization_wrapper
+- response_builder: generate_html_response, generate_exception_response, DecimalEncoder
+- event_parser: get_session_id, get_principal_id, get_bearer_token, get_id_token
+- aws_helpers: get_cert_path, get_rest_api_container_endpoint, get_lambda_role_name, get_account_and_partition
+- dict_helpers: merge_fields, get_property_path, get_item
+"""
import logging
-import os
-import tempfile
-from contextvars import ContextVar
-from datetime import datetime
-from decimal import Decimal
-from functools import cache
-from typing import Any, Callable, cast, Dict, Optional, TypeVar, Union
-
-import boto3
-from botocore.config import Config
+from collections.abc import Callable
+from typing import Any, TypeVar
+
+# Re-export from organized modules for backward compatibility
+from utilities.aws_helpers import (
+ get_account_and_partition,
+ get_cert_path,
+ get_lambda_role_name,
+ get_rest_api_container_endpoint,
+ retry_config,
+ ssm_client,
+)
+from utilities.dict_helpers import get_item, get_property_path, merge_fields
+from utilities.event_parser import get_bearer_token, get_id_token, get_principal_id, get_session_id
+from utilities.lambda_decorators import api_wrapper, authorization_wrapper, ctx_context
+from utilities.response_builder import DecimalEncoder, generate_exception_response, generate_html_response
from . import create_env_variables # noqa type: ignore
-retry_config = Config(
- retries={
- "max_attempts": 3,
- "mode": "standard",
- },
-)
-ctx_context: ContextVar[Any] = ContextVar("lamdbacontext")
F = TypeVar("F", bound=Callable[..., Any])
logger = logging.getLogger(__name__)
logging_configured = False
-ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], config=retry_config)
-
class LambdaContextFilter(logging.Filter):
"""Filter for logging to include request id and function name."""
@@ -98,421 +103,33 @@ def setup_root_logging() -> None:
setup_root_logging()
-def _sanitize_event(event: Dict[str, Dict[str, Any]]) -> str:
- """Sanitize event before logging.
-
- Parameters
- ----------
- event : Dict[str, Dict[str, Any]]
- The lambda event.
-
- Returns
- -------
- str
- The sanitized event as a JSON-formatted string.
- """
- # First normalize keys for our object
- sanitized = copy.deepcopy(event)
- if "headers" in event:
- for key in event["headers"]:
- if key != key.lower():
- sanitized["headers"][key.lower()] = event["headers"][key]
- del sanitized["headers"][key]
- if "multiValueHeaders" in sanitized:
- for key in event["multiValueHeaders"]:
- if key != key.lower():
- sanitized["multiValueHeaders"][key.lower()] = event["multiValueHeaders"][key]
- del sanitized["multiValueHeaders"][key]
-
- if "headers" in sanitized and "authorization" in sanitized["headers"]:
- sanitized["headers"]["authorization"] = ""
- if "multiValueHeaders" in sanitized and "authorization" in sanitized["headers"]:
- sanitized["multiValueHeaders"]["authorization"] = [""]
- return json.dumps(sanitized)
-
-
-def api_wrapper(f: F) -> F:
- """Wrap the lambda function.
-
- Parameters
- ----------
- f : F
- The function to be wrapped.
-
- Returns
- -------
- F
- The wrapped function.
- """
-
- @functools.wraps(f)
- def wrapper(event: dict, context: dict) -> Dict[str, Union[str, int, Dict[str, str]]]:
- """Wrap Lambda event.
-
- Parameters
- ----------
- event : dict
- Lambda event.
- context : dict
- Lambda context.
-
- Returns
- -------
- Dict[str, Union[str, int, Dict[str, str]]]
- _description_
- """
- ctx_context.set(context)
- code_func_name = f.__name__
- lambda_func_name = context.function_name # type: ignore [attr-defined]
- logger.info(f"Lambda {lambda_func_name}({code_func_name}) invoked with {_sanitize_event(event)}")
- try:
- result = f(event, context)
- return generate_html_response(200, result)
- except Exception as e:
- return generate_exception_response(e)
-
- return wrapper # type: ignore [return-value]
-
-
-def authorization_wrapper(f: F) -> F:
- """Wrap the lambda function.
-
- Parameters
- ----------
- f : F
- The function to be wrapped.
-
- Returns
- -------
- F
- The wrapped function.
- """
-
- @functools.wraps(f)
- def wrapper(event: dict, context: dict) -> F:
- """Wrap Lambda event.
-
- Parameters
- ----------
- event : dict
- Lambda event.
- context : dict
- Lambda context.
-
- Returns
- -------
- F
- The wrapped function.
- """
- ctx_context.set(context)
- return f(event, context) # type: ignore [no-any-return]
-
- return wrapper # type: ignore [return-value]
-
-
-class DecimalEncoder(json.JSONEncoder):
- def default(self, obj: Any) -> Any:
- if isinstance(obj, Decimal):
- return float(obj)
- if isinstance(obj, datetime):
- return obj.isoformat()
- return super().default(obj)
-
-
-def generate_html_response(status_code: int, response_body: dict) -> Dict[str, Union[str, int, Dict[str, str]]]:
- """Generate a response for an API call.
-
- Parameters
- ----------
- status_code : int
- HTTP status code.
- response_body : dict
- Response body.
-
- Returns
- -------
- Dict[str, Union[str, int, Dict[str, str]]]
- An HTML response.
- """
- return {
- "statusCode": status_code,
- "body": json.dumps(response_body, cls=DecimalEncoder),
- "headers": {
- "Access-Control-Allow-Origin": "*",
- "Content-Type": "application/json",
- "Cache-Control": "no-store, no-cache",
- "Pragma": "no-cache",
- "Strict-Transport-Security": "max-age:47304000; includeSubDomains",
- "X-Content-Type-Options": "nosniff",
- "X-Frame-Options": "DENY",
- },
- }
-
-
-def generate_exception_response(
- e: Exception,
-) -> Dict[str, Union[str, int, Dict[str, str]]]:
- """Generate a response for an exception used for all exceptions that are not caught by the API.
-
- Parameters
- ----------
- e : Exception
- Exception that was caught.
-
- Returns
- -------
- Dict[str, Union[str, int, Dict[str, str]]]
- An HTML response.
- """
- # Check for ValidationError from utilities.validation
- status_code = 400
- error_message: str
- if type(e).__name__ == "ValidationError":
- error_message = str(e)
- logger.exception(e)
- elif hasattr(e, "response"): # i.e. validate the exception was from an API call
- metadata = e.response.get("ResponseMetadata")
- if metadata:
- status_code = metadata.get("HTTPStatusCode", 400)
- error_message = str(e)
- logger.exception(e)
- elif hasattr(e, "http_status_code"):
- status_code = e.http_status_code
- error_message = getattr(e, "message", str(e))
- logger.exception(e)
- elif hasattr(e, "status_code"):
- status_code = e.status_code
- error_message = getattr(e, "message", str(e))
- logger.exception(e)
- else:
- error_msg = str(e)
- if error_msg in ["'requestContext'", "'pathParameters'", "'body'"]:
- error_message = f"Missing event parameter: {error_msg}"
- else:
- error_message = f"Bad Request: {error_msg}"
- logger.exception(e)
- return generate_html_response(status_code, error_message) # type: ignore [arg-type]
-
-
-def get_id_token(event: dict) -> str:
- """Return token from event request headers.
-
- Extracts bearer token from authorization header in lambda event.
- """
- auth_header = None
-
- if "authorization" in event["headers"]:
- auth_header = event["headers"]["authorization"]
- elif "Authorization" in event["headers"]:
- auth_header = event["headers"]["Authorization"]
- else:
- raise ValueError("Missing authorization token.")
-
- # remove bearer token prefix if present
- return str(auth_header).removeprefix("Bearer ").removeprefix("bearer ").strip()
-
-
-_cert_file = None
-
-
-@cache
-def get_cert_path(iam_client: Any) -> Union[str, bool]:
- """
- Get cert path for IAM certs for SSL validation against LISA Serve endpoint.
-
- Returns the path to the certificate file for SSL verification, or True to use
- default verification if no certificate ARN is specified.
- """
- global _cert_file
-
- cert_arn = os.environ.get("RESTAPI_SSL_CERT_ARN")
- if not cert_arn:
- logger.info("No SSL certificate ARN specified, using default verification")
- return True
- # For ACM certificates, use default verification since they are trusted AWS certificates
- elif ":acm:" in cert_arn:
- logger.info("ACM certificate detected, using default SSL verification")
- return True
-
- try:
- # Clean up previous cert file if it exists
- if _cert_file and os.path.exists(_cert_file.name):
- try:
- os.unlink(_cert_file.name)
- except Exception as e:
- logger.warning(f"Failed to clean up previous cert file: {e}")
-
- # Get the certificate name from the ARN
- cert_name = cert_arn.split("/")[1]
- logger.info(f"Retrieving certificate '{cert_name}' from IAM")
-
- # Get the certificate from IAM
- rest_api_cert = iam_client.get_server_certificate(ServerCertificateName=cert_name)
- cert_body = rest_api_cert["ServerCertificate"]["CertificateBody"]
-
- # Create a new temporary file
- _cert_file = tempfile.NamedTemporaryFile(delete=False)
- _cert_file.write(cert_body.encode("utf-8"))
- _cert_file.flush()
-
- logger.info(f"Certificate saved to temporary file: {_cert_file.name}")
- return _cert_file.name
-
- except Exception as e:
- logger.error(f"Failed to get certificate from IAM: {e}", exc_info=True)
- # If we fail to get the cert, return True to fall back to default verification
- return True
-
-
-@cache
-def get_rest_api_container_endpoint() -> str:
- """Get REST API container base URI from SSM Parameter Store."""
- lisa_api_param_response = ssm_client.get_parameter(Name=os.environ["LISA_API_URL_PS_NAME"])
- lisa_api_endpoint = lisa_api_param_response["Parameter"]["Value"]
- return f"{lisa_api_endpoint}/{os.environ['REST_API_VERSION']}/serve"
-
-
-def get_session_id(event: dict) -> str:
- """Get the session ID from the event."""
- session_id: str = event.get("pathParameters", {}).get("sessionId")
- return session_id
-
-
-def get_principal_id(event: Any) -> str:
- """Get principal from event."""
- principal: str = event.get("requestContext", {}).get("authorizer", {}).get("principal", "")
- return principal
-
-
-def merge_fields(source: dict, target: dict, fields: list[str]) -> dict:
- """
- Merge specified fields from source dictionary to target dictionary.
- Supports both top-level and nested fields using dot notation.
-
- Args:
- source: Source dictionary to copy fields from
- target: Target dictionary to copy fields into
- fields: List of field names, can use dot notation for nested fields
-
- Returns:
- Updated target dictionary
- """
-
- def get_nested_value(obj: dict[str, Any], path: list[str]) -> Any:
- current: Any = obj
- for key in path:
- if not isinstance(current, dict):
- return None
- current = current.get(key)
- if current is None:
- return None
- return current
-
- def set_nested_value(obj: dict, path: list[str], value: Any) -> None:
- current = obj
- for key in path[:-1]:
- if key not in current:
- current[key] = {}
- current = current[key]
- if value is not None:
- current[path[-1]] = value
-
- for field in fields:
- if "." in field:
- # Handle nested fields
- keys = field.split(".")
- value = get_nested_value(source, keys)
- if value is not None:
- set_nested_value(target, keys, value)
- else:
- # Handle top-level fields
- if field in source:
- target[field] = source[field]
-
- return target
-
-
-def _get_lambda_role_arn() -> str:
- """Get the ARN of the Lambda execution role.
-
- Returns
- -------
- str
- The full ARN of the Lambda execution role
- """
- sts = boto3.client("sts", region_name=os.environ["AWS_REGION"])
- identity = sts.get_caller_identity()
- return cast(str, identity["Arn"]) # This will include the role name
-
-
-def get_lambda_role_name() -> str:
- """Extract the role name from the Lambda execution role ARN.
-
- Returns
- -------
- str
- The name of the Lambda execution role without the full ARN
- """
- arn = _get_lambda_role_arn()
- parts = arn.split(":assumed-role/")[1].split("/")
- return parts[0] # This is the role name
-
-
-def get_item(response: Any) -> Any:
- items = response.get("Items", [])
- return items[0] if items else None
-
-
-def get_property_path(data: dict[str, Any], property_path: str) -> Optional[Any]:
- """Get the value represented by a property path."""
- props = property_path.split(".")
- current_node = data
- for prop in props:
- if prop in current_node:
- current_node = current_node[prop]
- else:
- return None
-
- return current_node
-
-
-def get_bearer_token(event, with_prefix: bool = True):
- """
- Extracts a Bearer token from the Authorization header in a Lambda event.
-
- Args:
- event (dict): AWS Lambda event (API Gateway / ALB proxy style).
-
- Returns:
- str | None: The token string if present and properly formatted, else None.
- """
- headers = event.get("headers") or {}
- # Headers may vary in casing
- auth_header = headers.get("Authorization") or headers.get("authorization")
- if not auth_header:
- return None
-
- if not auth_header.lower().startswith("bearer "):
- return None
-
- # Return the token after "Bearer "
- return auth_header.split(" ", 1)[1].strip()
-
-
-def get_account_and_partition() -> tuple[str, str]:
- """Get AWS account ID and partition from environment or ECR repository ARN.
-
- Returns:
- tuple[str, str]: (account_id, partition)
- """
- account_id = os.environ.get("AWS_ACCOUNT_ID", "")
- partition = os.environ.get("AWS_PARTITION", "aws")
-
- if not account_id:
- ecr_repo_arn = os.environ.get("ECR_REPOSITORY_ARN", "")
- if ecr_repo_arn:
- arn_parts = ecr_repo_arn.split(":")
- partition = arn_parts[1]
- account_id = arn_parts[4]
-
- return account_id, partition
+# Export all public functions for backward compatibility
+__all__ = [
+ # Lambda decorators
+ "api_wrapper",
+ "authorization_wrapper",
+ "ctx_context",
+ # Response builders
+ "generate_html_response",
+ "generate_exception_response",
+ "DecimalEncoder",
+ # Event parsers
+ "get_session_id",
+ "get_principal_id",
+ "get_bearer_token",
+ "get_id_token",
+ # AWS helpers
+ "get_cert_path",
+ "get_rest_api_container_endpoint",
+ "get_lambda_role_name",
+ "get_account_and_partition",
+ "retry_config",
+ "ssm_client",
+ # Dict helpers
+ "merge_fields",
+ "get_property_path",
+ "get_item",
+ # Logging
+ "LambdaContextFilter",
+ "setup_root_logging",
+]
diff --git a/lambda/utilities/db_setup_iam_auth.py b/lambda/utilities/db_setup_iam_auth.py
index 07a65cc7e..46c30c2ba 100644
--- a/lambda/utilities/db_setup_iam_auth.py
+++ b/lambda/utilities/db_setup_iam_auth.py
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import json
+import logging
import os
from typing import Any
@@ -19,73 +20,228 @@
import psycopg2
from botocore.exceptions import ClientError
+logger = logging.getLogger(__name__)
-def get_db_credentials(secret_arn: str) -> Any:
- """Retrieve database credentials from Secrets Manager"""
+
+class IamAuthSetupRequest:
+ """Request payload for IAM database user setup."""
+
+ def __init__(
+ self,
+ secret_arn: str,
+ db_host: str,
+ db_port: int,
+ db_name: str,
+ db_user: str,
+ iam_name: str,
+ ):
+ self.secret_arn = secret_arn
+ self.db_host = db_host
+ self.db_port = db_port
+ self.db_name = db_name
+ self.db_user = db_user
+ self.iam_name = iam_name
+
+ @classmethod
+ def from_event(cls, event: dict[str, Any]) -> "IamAuthSetupRequest":
+ """Parse and validate request from Lambda event payload."""
+ required_fields = {
+ "secretArn": "secret_arn", # pragma: allowlist secret
+ "dbHost": "db_host",
+ "dbPort": "db_port",
+ "dbName": "db_name",
+ "dbUser": "db_user",
+ "iamName": "iam_name",
+ }
+
+ missing = [field for field in required_fields if field not in event]
+ if missing:
+ raise ValueError(f"Missing required fields: {', '.join(missing)}")
+
+ return cls(
+ secret_arn=str(event["secretArn"]),
+ db_host=str(event["dbHost"]),
+ db_port=int(event["dbPort"]),
+ db_name=str(event["dbName"]),
+ db_user=str(event["dbUser"]),
+ iam_name=str(event["iamName"]),
+ )
+
+
+def get_db_credentials(secret_arn: str) -> Any | None:
+ """Retrieve database credentials from Secrets Manager.
+
+ Returns None if the secret doesn't exist (already deleted after bootstrap).
+ """
client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"])
try:
response = client.get_secret_value(SecretId=secret_arn)
except ClientError as e:
+ error_code = e.response.get("Error", {}).get("Code", "")
+ if error_code == "ResourceNotFoundException":
+ logger.info(f"Bootstrap secret not found (already deleted): {secret_arn}")
+ return None
raise Exception(f"Error retrieving secrets: {e}")
secret = response["SecretString"]
- secret_dict = json.loads(secret) # Converting string to dictionary
+ secret_dict = json.loads(secret)
return secret_dict
-def create_db_user(db_host: str, db_port: str, db_name: str, db_user: str, secret_arn: str, iam_name: str) -> None:
- """Create a PostgreSQL user for IAM authentication"""
- # Get credentials from Secrets Manager
+def delete_bootstrap_secret(secret_arn: str) -> bool:
+ """Delete the bootstrap password secret from Secrets Manager.
+
+ Returns True if secret was deleted, False if deletion was skipped or failed.
+ """
+ client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"])
+
+ try:
+ client.delete_secret(SecretId=secret_arn, ForceDeleteWithoutRecovery=True)
+ logger.info(f"Successfully deleted bootstrap secret: {secret_arn}")
+ return True
+ except ClientError as e:
+ error_code = e.response.get("Error", {}).get("Code", "")
+ if error_code == "ResourceNotFoundException":
+ logger.info(f"Bootstrap secret already deleted: {secret_arn}")
+ return True
+ logger.error(f"Failed to delete bootstrap secret: {e}")
+ return False
+
+
+def create_db_user(db_host: str, db_port: str, db_name: str, db_user: str, secret_arn: str, iam_name: str) -> bool:
+ """Create a PostgreSQL user for IAM authentication.
+
+ Returns True if user was created/updated, False if skipped (secret not found).
+ """
+ logger.info(f"Starting IAM user creation for: {iam_name}")
+ logger.info(f"Database connection details - host: {db_host}, port: {db_port}, dbname: {db_name}, user: {db_user}")
+
credentials = get_db_credentials(secret_arn)
- # Connect to the database as the admin user
- conn = psycopg2.connect(dbname=db_name, user=db_user, password=credentials["password"], host=db_host, port=db_port)
+ if credentials is None:
+ logger.info("Bootstrap secret not found - IAM user was likely already created in a previous run")
+ logger.info("Skipping user creation to avoid errors. If permissions need updating, manually run SQL grants.")
+ return False
+
+ logger.info("Successfully retrieved bootstrap credentials from Secrets Manager")
+ logger.info(f"Connecting to database at {db_host}:{db_port}/{db_name} as {db_user}")
+
+ try:
+ conn = psycopg2.connect(
+ dbname=db_name, user=db_user, password=credentials["password"], host=db_host, port=db_port
+ )
+ except psycopg2.Error as e:
+ logger.error(f"Failed to connect to database: {e}")
+ raise Exception(f"Failed to connect to database: {e}")
+
cursor = conn.cursor()
- # Attempt to create the database user for IAM authentication
+ # Create vector extension (requires superuser privileges from bootstrap user)
try:
+ logger.info("Creating vector extension if not exists")
+ cursor.execute("CREATE EXTENSION IF NOT EXISTS vector")
+ conn.commit()
+ logger.info("Vector extension created or already exists")
+ except psycopg2.Error as e:
+ conn.rollback()
+ logger.error(f"Error creating vector extension: {e}")
+ raise Exception(f"Error creating vector extension: {e}")
+
+ try:
+ logger.info(f"Creating database user: {iam_name}")
cursor.execute(f'CREATE USER "{iam_name}"')
conn.commit()
except psycopg2.Error as e:
- # Log but ignore the error if the user already exists
- if e.pgcode not in ["23505", "42710"]: # Unique violation error code
+ conn.rollback() # Must rollback failed transaction before executing more commands
+ if e.pgcode not in ["23505", "42710"]:
+ logger.error(f"Error creating user: {e}")
raise Exception(f"Error creating user: {e}")
+ logger.info(f"User {iam_name} already exists (pgcode: {e.pgcode})")
- # Other SQL commands to configure user privileges
sql_commands = [
f'GRANT rds_iam to "{iam_name}"',
+ # Schema-level permissions
f'GRANT USAGE, CREATE ON SCHEMA public TO "{iam_name}"',
+ f'GRANT ALL ON SCHEMA public TO "{iam_name}"',
+ # Existing object permissions
f'GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{iam_name}"',
f'GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO "{iam_name}"',
f'GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO "{iam_name}"',
f'GRANT ALL PRIVILEGES ON ALL PROCEDURES IN SCHEMA public TO "{iam_name}"',
+ # Default privileges for future objects
f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO "{iam_name}"',
+ f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON SEQUENCES TO "{iam_name}"',
+ f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON FUNCTIONS TO "{iam_name}"',
+ # Database-level permissions
f'GRANT CONNECT ON DATABASE "{db_name}" TO "{iam_name}"',
+ f'GRANT CREATE ON DATABASE "{db_name}" TO "{iam_name}"',
f'GRANT ALL PRIVILEGES ON DATABASE "{db_name}" TO "{iam_name}"',
+ # RDS-specific admin role (provides elevated privileges without SUPERUSER)
+ f'GRANT rds_superuser TO "{iam_name}"',
]
try:
for command in sql_commands:
+ logger.info(f"Executing: {command}")
cursor.execute(command)
conn.commit()
+ logger.info("Successfully granted all privileges to IAM user")
except psycopg2.Error as e:
+ logger.error(f"Error granting privileges to user: {e}")
raise Exception(f"Error granting privileges to user: {e}")
finally:
cursor.close()
conn.close()
+ return True
+
def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
- """Lambda handler"""
- # Extract parameters from the environment and event
- secret_arn = os.environ["SECRET_ARN"]
- db_host = os.environ["DB_HOST"]
- db_port = os.environ["DB_PORT"]
- db_name = os.environ["DB_NAME"]
- db_user = os.environ["DB_USER"]
- iam_name = os.environ["IAM_NAME"]
-
- # Call function to create DB user
- create_db_user(db_host, db_port, db_name, db_user, secret_arn, iam_name)
-
- return {"statusCode": 200, "body": "Database user created successfully"}
+ """Lambda handler for IAM database user setup.
+
+ Creates an IAM-authenticated PostgreSQL user. The bootstrap secret is kept
+ for CloudFormation compatibility (not deleted) even though it won't be used
+ for authentication after IAM auth is enabled.
+ """
+ logger.info(f"IAM auth setup Lambda invoked with event: {json.dumps(event)}")
+
+ try:
+ request = IamAuthSetupRequest.from_event(event)
+ logger.info(
+ f"""Parsed request - dbHost: {request.db_host}, dbPort: {request.db_port}, dbName: {request.db_name},
+ iamName: {request.iam_name}"""
+ )
+ except (ValueError, KeyError, TypeError) as e:
+ logger.error(f"Invalid request payload: {e}")
+ return {"statusCode": 400, "body": json.dumps({"error": f"Invalid request payload: {e}"})}
+
+ try:
+ user_created = create_db_user(
+ request.db_host,
+ str(request.db_port),
+ request.db_name,
+ request.db_user,
+ request.secret_arn,
+ request.iam_name,
+ )
+
+ # Note: We no longer delete the bootstrap secret to maintain CloudFormation compatibility
+ # The secret remains but is not used for authentication when IAM auth is enabled
+ logger.info("IAM user setup complete. Bootstrap secret retained for CloudFormation compatibility.")
+
+ result = {
+ "statusCode": 200,
+ "body": json.dumps(
+ {
+ "message": "Database user setup complete",
+ "userCreated": user_created,
+ "secretDeleted": False, # Secret is retained
+ }
+ ),
+ }
+ logger.info(f"IAM auth setup completed successfully: {result}")
+ return result
+
+ except Exception as e:
+ logger.error(f"IAM auth setup failed: {e}")
+ return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
diff --git a/lambda/utilities/dict_helpers.py b/lambda/utilities/dict_helpers.py
new file mode 100644
index 000000000..3db830ed8
--- /dev/null
+++ b/lambda/utilities/dict_helpers.py
@@ -0,0 +1,140 @@
+# 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.
+
+"""Generic dictionary manipulation utilities."""
+
+from typing import Any
+
+
+def merge_fields(source: dict, target: dict, fields: list[str]) -> dict:
+ """
+ Merge specified fields from source dictionary to target dictionary.
+
+ Supports both top-level and nested fields using dot notation.
+
+ Parameters
+ ----------
+ source : dict
+ Source dictionary to copy fields from.
+ target : dict
+ Target dictionary to copy fields into.
+ fields : list[str]
+ List of field names, can use dot notation for nested fields.
+
+ Returns
+ -------
+ dict
+ Updated target dictionary.
+
+ Example
+ -------
+ >>> source = {"user": {"name": "John", "age": 30}, "status": "active"}
+ >>> target = {"id": "123"}
+ >>> merge_fields(source, target, ["user.name", "status"])
+ {'id': '123', 'user': {'name': 'John'}, 'status': 'active'}
+ """
+
+ def get_nested_value(obj: dict[str, Any], path: list[str]) -> Any:
+ """Get value from nested dictionary using path."""
+ current: Any = obj
+ for key in path:
+ if not isinstance(current, dict):
+ return None
+ current = current.get(key)
+ if current is None:
+ return None
+ return current
+
+ def set_nested_value(obj: dict, path: list[str], value: Any) -> None:
+ """Set value in nested dictionary using path."""
+ current = obj
+ for key in path[:-1]:
+ if key not in current:
+ current[key] = {}
+ current = current[key]
+ if value is not None:
+ current[path[-1]] = value
+
+ for field in fields:
+ if "." in field:
+ # Handle nested fields
+ keys = field.split(".")
+ value = get_nested_value(source, keys)
+ if value is not None:
+ set_nested_value(target, keys, value)
+ else:
+ # Handle top-level fields
+ if field in source:
+ target[field] = source[field]
+
+ return target
+
+
+def get_property_path(data: dict[str, Any], property_path: str) -> Any | None:
+ """
+ Get value from nested dictionary using dot-notation path.
+
+ Parameters
+ ----------
+ data : dict[str, Any]
+ Dictionary to extract value from.
+ property_path : str
+ Dot-notation path to the property (e.g., "user.address.city").
+
+ Returns
+ -------
+ Optional[Any]
+ The value at the specified path, or None if path doesn't exist.
+
+ Example
+ -------
+ >>> data = {"user": {"address": {"city": "Seattle"}}}
+ >>> get_property_path(data, "user.address.city")
+ 'Seattle'
+ >>> get_property_path(data, "user.phone")
+ None
+ """
+ props = property_path.split(".")
+ current_node = data
+ for prop in props:
+ if prop in current_node:
+ current_node = current_node[prop]
+ else:
+ return None
+
+ return current_node
+
+
+def get_item(response: Any) -> Any:
+ """
+ Extract first item from DynamoDB query/scan response.
+
+ Parameters
+ ----------
+ response : Any
+ DynamoDB query or scan response.
+
+ Returns
+ -------
+ Any
+ First item from the response, or None if no items.
+
+ Example
+ -------
+ >>> response = {"Items": [{"id": "123", "name": "John"}]}
+ >>> get_item(response)
+ {'id': '123', 'name': 'John'}
+ """
+ items = response.get("Items", [])
+ return items[0] if items else None
diff --git a/lambda/utilities/event_parser.py b/lambda/utilities/event_parser.py
new file mode 100644
index 000000000..741ad3b28
--- /dev/null
+++ b/lambda/utilities/event_parser.py
@@ -0,0 +1,229 @@
+# 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.
+
+"""Utilities for parsing API Gateway Lambda events."""
+
+import copy
+import json
+from typing import Any
+
+from utilities.header_sanitizer import sanitize_headers
+
+
+def sanitize_event_for_logging(event: dict[str, Any]) -> str:
+ """
+ Sanitize Lambda event before logging.
+
+ This function sanitizes the event by:
+ 1. Redacting authorization headers
+ 2. Applying allowlist to only log safe headers
+ 3. Replacing security-critical headers with server-controlled values
+
+ Parameters
+ ----------
+ event : Dict[str, Any]
+ The Lambda event from API Gateway.
+
+ Returns
+ -------
+ str
+ The sanitized event as a JSON-formatted string.
+
+ Example
+ -------
+ >>> event = {
+ ... "headers": {"Authorization": "Bearer token123"},
+ ... "path": "/users/123"
+ ... }
+ >>> sanitized = sanitize_event_for_logging(event)
+ >>> "token123" in sanitized
+ False
+ """
+ # Deep copy to avoid modifying original event
+ sanitized = copy.deepcopy(event)
+
+ # Redact authorization headers BEFORE applying allowlist
+ # This ensures we log that auth was present, but not the actual token
+ if sanitized.get("headers"):
+ # Normalize to lowercase and redact authorization
+ normalized_headers = {}
+ for key, value in sanitized["headers"].items():
+ key_lower = key.lower()
+ if key_lower == "authorization":
+ normalized_headers[key_lower] = ""
+ else:
+ normalized_headers[key_lower] = value
+ sanitized["headers"] = normalized_headers
+
+ if sanitized.get("multiValueHeaders"):
+ # Normalize to lowercase and redact authorization
+ normalized_multi = {}
+ for key, value in sanitized["multiValueHeaders"].items():
+ key_lower = key.lower()
+ if key_lower == "authorization":
+ normalized_multi[key_lower] = [""]
+ else:
+ normalized_multi[key_lower] = value
+ sanitized["multiValueHeaders"] = normalized_multi
+
+ # Apply allowlist filtering to headers
+ if sanitized.get("headers"):
+ sanitized["headers"] = sanitize_headers(sanitized["headers"], event)
+ # Add back redacted authorization if it was present
+ original_headers = event.get("headers") or {}
+ if "authorization" in original_headers or "Authorization" in original_headers:
+ sanitized["headers"]["authorization"] = ""
+
+ # Also sanitize multiValueHeaders if present
+ if sanitized.get("multiValueHeaders"):
+ # Convert to single-value dict for sanitization, then back
+ multi_headers = sanitized["multiValueHeaders"]
+ single_value_headers = {k: v[0] if v else "" for k, v in multi_headers.items()}
+ sanitized_single = sanitize_headers(single_value_headers, event)
+
+ # Rebuild multiValueHeaders with sanitized values
+ sanitized["multiValueHeaders"] = {k: [v] for k, v in sanitized_single.items()}
+ # Add back redacted authorization if it was present
+ original_multi_headers = event.get("multiValueHeaders") or {}
+ if "authorization" in original_multi_headers or "Authorization" in original_multi_headers:
+ sanitized["multiValueHeaders"]["authorization"] = [""]
+
+ return json.dumps(sanitized)
+
+
+def get_session_id(event: dict) -> str:
+ """
+ Extract session ID from Lambda event path parameters.
+
+ Parameters
+ ----------
+ event : dict
+ Lambda event from API Gateway.
+
+ Returns
+ -------
+ str
+ The session ID from path parameters.
+
+ Example
+ -------
+ >>> event = {"pathParameters": {"sessionId": "sess-123"}}
+ >>> get_session_id(event)
+ 'sess-123'
+ """
+ session_id: str = event.get("pathParameters", {}).get("sessionId")
+ return session_id
+
+
+def get_principal_id(event: dict) -> str:
+ """
+ Extract principal ID from Lambda event authorizer context.
+
+ Parameters
+ ----------
+ event : dict
+ Lambda event from API Gateway.
+
+ Returns
+ -------
+ str
+ The principal ID from authorizer context.
+
+ Example
+ -------
+ >>> event = {
+ ... "requestContext": {
+ ... "authorizer": {"principal": "user-123"}
+ ... }
+ ... }
+ >>> get_principal_id(event)
+ 'user-123'
+ """
+ principal: str = event.get("requestContext", {}).get("authorizer", {}).get("principal", "")
+ return principal
+
+
+def get_bearer_token(event: dict) -> str | None:
+ """
+ Extract Bearer token from Authorization header in Lambda event.
+
+ Parameters
+ ----------
+ event : dict
+ Lambda event from API Gateway.
+
+ Returns
+ -------
+ Optional[str]
+ The token string if present and properly formatted, else None.
+
+ Example
+ -------
+ >>> event = {"headers": {"Authorization": "Bearer abc123"}}
+ >>> get_bearer_token(event)
+ 'abc123'
+ """
+ headers = event.get("headers") or {}
+ # Headers may vary in casing
+ auth_header: str | None = headers.get("Authorization") or headers.get("authorization")
+ if not auth_header:
+ return None
+
+ if not auth_header.lower().startswith("bearer "):
+ return None
+
+ # Return the token after "Bearer "
+ token: str = auth_header.split(" ", 1)[1].strip()
+ return token
+
+
+def get_id_token(event: dict) -> str:
+ """
+ Extract ID token from Authorization header in Lambda event.
+
+ This function extracts the bearer token from the authorization header,
+ removing the "Bearer" prefix if present.
+
+ Parameters
+ ----------
+ event : dict
+ Lambda event from API Gateway.
+
+ Returns
+ -------
+ str
+ The ID token without the "Bearer" prefix.
+
+ Raises
+ ------
+ ValueError
+ If authorization header is missing.
+
+ Example
+ -------
+ >>> event = {"headers": {"Authorization": "Bearer token123"}}
+ >>> get_id_token(event)
+ 'token123'
+ """
+ auth_header = None
+
+ if "authorization" in event["headers"]:
+ auth_header = event["headers"]["authorization"]
+ elif "Authorization" in event["headers"]:
+ auth_header = event["headers"]["Authorization"]
+ else:
+ raise ValueError("Missing authorization token.")
+
+ # Remove bearer token prefix if present
+ return str(auth_header).removeprefix("Bearer ").removeprefix("bearer ").strip()
diff --git a/lambda/utilities/exceptions.py b/lambda/utilities/exceptions.py
index f8815da9e..c24fae60d 100644
--- a/lambda/utilities/exceptions.py
+++ b/lambda/utilities/exceptions.py
@@ -35,3 +35,8 @@ def __init__(self, detail: str = "Not Found"):
class UnauthorizedException(HTTPException):
def __init__(self, detail: str = "Unauthorized"):
super().__init__(401, detail) # flake8: noqa
+
+
+class ForbiddenException(HTTPException):
+ def __init__(self, detail: str = "Forbidden"):
+ super().__init__(403, detail) # flake8: noqa
diff --git a/lambda/utilities/fastapi_factory.py b/lambda/utilities/fastapi_factory.py
new file mode 100644
index 000000000..0bd05c0ba
--- /dev/null
+++ b/lambda/utilities/fastapi_factory.py
@@ -0,0 +1,133 @@
+# 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.
+
+"""Factory for creating FastAPI applications with standard LISA configuration."""
+
+from fastapi import FastAPI, Request
+from fastapi.encoders import jsonable_encoder
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from utilities.exceptions import ForbiddenException, HTTPException, NotFoundException, UnauthorizedException
+from utilities.fastapi_middleware.aws_api_gateway_middleware import AWSAPIGatewayMiddleware
+from utilities.fastapi_middleware.exception_handlers import generic_exception_handler
+from utilities.fastapi_middleware.input_validation_middleware import InputValidationMiddleware
+from utilities.fastapi_middleware.request_logging_middleware import RequestLoggingMiddleware
+from utilities.fastapi_middleware.security_headers_middleware import SecurityHeadersMiddleware
+
+
+def create_fastapi_app() -> FastAPI:
+ """
+ Create a FastAPI application with standard LISA configuration.
+
+ This factory function creates a FastAPI app with:
+ - Standard FastAPI settings (redirect_slashes, lifespan, docs)
+ - Input validation middleware (null bytes, request size, HTTP methods)
+ - AWS API Gateway middleware (extracts Lambda event context)
+ - Request logging middleware (audit trail with sanitized data)
+ - Security headers middleware (HSTS, X-Frame-Options, etc.)
+ - CORS middleware with permissive settings
+ - Request validation exception handler (422 errors)
+ - Generic exception handler (500 errors)
+
+ Middleware execution order (IMPORTANT):
+ 1. InputValidationMiddleware - Validates input FIRST (security)
+ 2. AWSAPIGatewayMiddleware - Extracts AWS event context
+ 3. RequestLoggingMiddleware - Logs requests with sanitized data
+ 4. SecurityHeadersMiddleware - Adds security headers to responses
+ 5. CORSMiddleware - Handles CORS (last middleware)
+
+ Returns:
+ FastAPI: Configured FastAPI application instance
+
+ Example:
+ >>> from utilities.fastapi_factory import create_fastapi_app
+ >>> app = create_fastapi_app()
+ >>> # Add domain-specific exception handlers
+ >>> @app.exception_handler(MyCustomError)
+ >>> async def my_handler(request, exc):
+ >>> return JSONResponse(status_code=404, content={"error": str(exc)})
+ """
+ # Create FastAPI app with standard settings
+ app = FastAPI(
+ redirect_slashes=False,
+ lifespan="off",
+ docs_url="/docs",
+ openapi_url="/openapi.json",
+ )
+
+ # Add middleware in reverse order (last added = first executed)
+ # Middleware execution order: InputValidation -> AWSAPIGateway -> RequestLogging -> SecurityHeaders -> CORS
+
+ # CORS middleware (executed last, added first)
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # Security headers middleware (adds HSTS, X-Frame-Options, etc.)
+ app.add_middleware(SecurityHeadersMiddleware)
+
+ # Request logging middleware (logs all requests with sanitized data)
+ app.add_middleware(RequestLoggingMiddleware)
+
+ # AWS API Gateway middleware (extracts Lambda event context)
+ app.add_middleware(AWSAPIGatewayMiddleware)
+
+ # Input validation middleware (must be executed first for security)
+ app.add_middleware(InputValidationMiddleware)
+
+ # Register standard exception handlers
+
+ # HTTP exceptions (401, 403, 404, etc.)
+ @app.exception_handler(HTTPException)
+ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
+ """Handle custom HTTP exceptions and translate to appropriate status codes."""
+ return JSONResponse(status_code=exc.http_status_code, content={"message": exc.message})
+
+ # Convenience aliases for specific HTTP exceptions (for direct import in tests)
+ @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})
+
+ @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})
+
+ @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})
+
+ # 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,
+ content={"detail": jsonable_encoder(exc.errors()), "type": "RequestValidationError"},
+ )
+
+ # Generic exception handler (500) - must be registered last
+ @app.exception_handler(Exception)
+ async def handle_generic_exception(request: Request, exc: Exception) -> JSONResponse:
+ """Handle all unhandled exceptions - delegates to common handler."""
+ return await generic_exception_handler(request, exc)
+
+ return app
diff --git a/lambda/utilities/fastapi_middleware/exception_handlers.py b/lambda/utilities/fastapi_middleware/exception_handlers.py
new file mode 100644
index 000000000..b416446b3
--- /dev/null
+++ b/lambda/utilities/fastapi_middleware/exception_handlers.py
@@ -0,0 +1,62 @@
+# 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.
+
+"""Common exception handlers for FastAPI applications."""
+
+import logging
+
+from fastapi import Request
+from fastapi.responses import JSONResponse
+
+logger = logging.getLogger(__name__)
+
+
+async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
+ """
+ Handle all unhandled exceptions.
+
+ This handler catches any exceptions not handled by more specific handlers.
+ It logs detailed error information internally but returns a generic message
+ to the client to avoid exposing internal implementation details.
+
+ Security Note: Never expose internal details (stack traces, file paths, etc.)
+ in error responses as they can aid attackers in reconnaissance.
+
+ Args:
+ request: The FastAPI request object
+ exc: The exception that was raised
+
+ Returns:
+ JSONResponse with 500 status code and generic error message
+ """
+ # Log detailed error information for debugging
+ logger.error(
+ f"Unhandled exception in {request.method} {request.url.path}",
+ exc_info=exc,
+ extra={
+ "method": request.method,
+ "path": request.url.path,
+ "exception_type": type(exc).__name__,
+ "exception_message": str(exc),
+ },
+ )
+
+ # Return generic error message to client
+ return JSONResponse(
+ status_code=500,
+ 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
new file mode 100644
index 000000000..43a4b92ad
--- /dev/null
+++ b/lambda/utilities/fastapi_middleware/input_validation_middleware.py
@@ -0,0 +1,298 @@
+# 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.
+
+"""Middleware for FastAPI that validates and sanitizes input to prevent security vulnerabilities."""
+
+import html
+import logging
+import re
+
+from fastapi import status
+from fastapi.responses import JSONResponse
+from starlette.middleware.base import ASGIApp, BaseHTTPMiddleware, Request, RequestResponseEndpoint, Response
+
+logger = logging.getLogger(__name__)
+
+# Default maximum request size: 1MB
+DEFAULT_MAX_REQUEST_SIZE = 1024 * 1024
+
+
+def sanitize_input(data: str) -> str:
+ """
+ Sanitize string input by removing or escaping dangerous characters.
+
+ This function:
+ - Escapes HTML/XML special characters to prevent XSS
+ - Removes script tags and their content
+ - Preserves legitimate special characters (hyphens, underscores, etc.)
+
+ Args:
+ data: String to sanitize
+
+ Returns:
+ Sanitized string safe for processing
+ """
+ if not data:
+ return data
+
+ # Remove script tags and their content (case-insensitive)
+ data = re.sub(r"", "", data, flags=re.IGNORECASE | re.DOTALL)
+
+ # Escape HTML special characters to prevent XSS
+ # This preserves legitimate characters like hyphens, underscores, etc.
+ data = html.escape(data)
+
+ return data
+
+
+class InputValidationMiddleware(BaseHTTPMiddleware):
+ """
+ Middleware that validates and sanitizes all incoming requests.
+
+ This middleware provides security protections against:
+ - Null byte injection attacks
+ - Oversized payload attacks
+ - Special character injection
+
+ It intercepts all requests before they reach the application handlers
+ and returns appropriate HTTP error codes for invalid input.
+ """
+
+ def __init__(self, app: ASGIApp, max_request_size: int = DEFAULT_MAX_REQUEST_SIZE) -> None:
+ """
+ Initialize the input validation middleware.
+
+ Args:
+ app: The ASGI application
+ max_request_size: Maximum allowed request body size in bytes (default: 1MB)
+ """
+ super().__init__(app)
+ self.app = app
+ self.max_request_size = max_request_size
+
+ def contains_null_bytes(self, data: str) -> bool:
+ """
+ Check if a string contains null bytes.
+
+ Null bytes (\\x00) can be used to bypass input validation or cause
+ unexpected behavior in string processing.
+
+ Args:
+ data: String to check for null bytes
+
+ Returns:
+ True if null bytes are found, False otherwise
+ """
+ return "\x00" in data
+
+ async def check_request_size(self, request: Request) -> JSONResponse | None:
+ """
+ Validate that the request body size does not exceed the configured limit.
+
+ Args:
+ request: The incoming HTTP request
+
+ Returns:
+ JSONResponse with 413 status if size exceeds limit, None otherwise
+ """
+ content_length = request.headers.get("content-length")
+ if content_length:
+ try:
+ size = int(content_length)
+ if size > self.max_request_size:
+ logger.warning(
+ f"Request size {size} bytes exceeds maximum {self.max_request_size} bytes",
+ extra={
+ "request_size": size,
+ "max_size": self.max_request_size,
+ "path": request.url.path,
+ "method": request.method,
+ },
+ )
+ return JSONResponse(
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
+ content={
+ "error": "Payload Too Large",
+ "message": (
+ f"Request body size exceeds maximum allowed size " f"of {self.max_request_size} bytes"
+ ),
+ },
+ )
+ except ValueError:
+ # Invalid content-length header, let it pass and fail later if needed
+ logger.warning(f"Invalid content-length header: {content_length}")
+
+ return None
+
+ async def validate_query_params(self, request: Request) -> JSONResponse | None:
+ """
+ Validate query parameters for null bytes.
+
+ Args:
+ request: The incoming HTTP request
+
+ Returns:
+ JSONResponse with 400 status if null bytes found, None otherwise
+ """
+ for key, value in request.query_params.items():
+ if self.contains_null_bytes(key) or self.contains_null_bytes(value):
+ logger.warning(
+ f"Null byte detected in query parameter: {key}",
+ extra={
+ "parameter_name": key,
+ "path": request.url.path,
+ "method": request.method,
+ },
+ )
+ return JSONResponse(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in query parameters",
+ },
+ )
+ return None
+
+ async def validate_path_params(self, request: Request) -> JSONResponse | None:
+ """
+ Validate path parameters for null bytes.
+
+ Args:
+ request: The incoming HTTP request
+
+ Returns:
+ JSONResponse with 400 status if null bytes found, None otherwise
+ """
+ path = str(request.url.path)
+ if self.contains_null_bytes(path):
+ logger.warning(
+ "Null byte detected in path",
+ extra={
+ "path": path,
+ "method": request.method,
+ },
+ )
+ return JSONResponse(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in request path",
+ },
+ )
+ return None
+
+ async def validate_request_body(self, request: Request) -> JSONResponse | None:
+ """
+ Validate request body for null bytes.
+
+ This reads the request body and checks for null bytes. If found,
+ returns an error response. Otherwise, the body is consumed and needs
+ to be restored for downstream handlers.
+
+ Args:
+ request: The incoming HTTP request
+
+ Returns:
+ JSONResponse with 400 status if null bytes found, None otherwise
+ """
+ # Only check body for methods that typically have a body
+ if request.method in ("POST", "PUT", "PATCH"):
+ try:
+ body = await request.body()
+ if body:
+ # Check for null bytes in the raw body
+ if b"\x00" in body:
+ logger.warning(
+ "Null byte detected in request body",
+ extra={
+ "path": request.url.path,
+ "method": request.method,
+ "body_size": len(body),
+ },
+ )
+ return JSONResponse(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in request body",
+ },
+ )
+ except Exception as e:
+ # If we can't read the body, let it pass and fail later with proper error handling
+ logger.warning(f"Error reading request body for validation: {e}")
+
+ return None
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+ """
+ Process the request through validation checks before passing to handlers.
+
+ Validation order:
+ 1. HTTP method validation (returns 405 if invalid)
+ 2. Request size check (returns 413 if too large)
+ 3. Path parameter validation (returns 400 if null bytes found)
+ 4. Query parameter validation (returns 400 if null bytes found)
+ 5. Request body validation (returns 400 if null bytes found)
+
+ Args:
+ request: The incoming HTTP request
+ call_next: The next middleware or handler in the chain
+
+ Returns:
+ Response from validation or from the next handler
+ """
+ # Validate HTTP method
+ # FastAPI will handle method validation at the route level, but we add
+ # this as a safety check for any routes that might not be properly configured
+ valid_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
+ if request.method not in valid_methods:
+ logger.warning(
+ f"Invalid HTTP method: {request.method}",
+ extra={
+ "method": request.method,
+ "path": request.url.path,
+ },
+ )
+ return JSONResponse(
+ status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
+ content={
+ "error": "Method Not Allowed",
+ "message": f"HTTP method {request.method} is not allowed",
+ },
+ headers={"Allow": ", ".join(sorted(valid_methods))},
+ )
+
+ # Check request size
+ size_error = await self.check_request_size(request)
+ if size_error:
+ return size_error
+
+ # Validate path parameters
+ path_error = await self.validate_path_params(request)
+ if path_error:
+ return path_error
+
+ # Validate query parameters
+ query_error = await self.validate_query_params(request)
+ if query_error:
+ return query_error
+
+ # Validate request body
+ body_error = await self.validate_request_body(request)
+ if body_error:
+ return body_error
+
+ # All validations passed, proceed to next handler
+ response = await call_next(request)
+ return response
diff --git a/lambda/utilities/fastapi_middleware/request_logging_middleware.py b/lambda/utilities/fastapi_middleware/request_logging_middleware.py
new file mode 100644
index 000000000..105cfe76d
--- /dev/null
+++ b/lambda/utilities/fastapi_middleware/request_logging_middleware.py
@@ -0,0 +1,162 @@
+# 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.
+
+"""Middleware for logging all incoming requests to FastAPI applications."""
+
+import json
+import logging
+import time
+from typing import Any
+
+from starlette.middleware.base import BaseHTTPMiddleware, Request, RequestResponseEndpoint, Response
+from utilities.header_sanitizer import sanitize_headers
+
+logger = logging.getLogger(__name__)
+
+
+class RequestLoggingMiddleware(BaseHTTPMiddleware):
+ """
+ Middleware that logs all incoming requests with sanitized data.
+
+ This middleware provides:
+ - Automatic logging of all requests (method, path, headers, params)
+ - Header sanitization (redacts auth, replaces user-controlled headers)
+ - Request timing (duration in milliseconds)
+ - User context extraction (username, groups, auth type)
+ - Correlation IDs for request tracing
+
+ Security features:
+ - Authorization headers are redacted
+ - User-controlled headers (x-forwarded-for) replaced with server values
+ - Real client IP extracted from API Gateway context
+ """
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+ """
+ Process the request, log details, and pass to next handler.
+
+ Args:
+ request: The incoming HTTP request
+ call_next: The next middleware or handler in the chain
+
+ Returns:
+ Response from the next handler
+ """
+ # Start timing
+ start_time = time.time()
+
+ # Extract AWS event from request scope (set by AWSAPIGatewayMiddleware)
+ event = request.scope.get("aws.event", {})
+
+ # Build sanitized request data for logging
+ log_data = self._build_log_data(request, event)
+
+ # Log the incoming request
+ logger.info(
+ f"Request: {request.method} {request.url.path}",
+ extra=log_data,
+ )
+
+ # Process the request
+ response = await call_next(request)
+
+ # Calculate request duration
+ duration_ms = (time.time() - start_time) * 1000
+
+ # Log the response
+ logger.info(
+ f"Response: {request.method} {request.url.path} - {response.status_code} ({duration_ms:.2f}ms)",
+ extra={
+ "method": request.method,
+ "path": request.url.path,
+ "status_code": response.status_code,
+ "duration_ms": duration_ms,
+ "request_id": event.get("requestContext", {}).get("requestId"),
+ },
+ )
+
+ return response
+
+ def _build_log_data(self, request: Request, event: dict[str, Any]) -> dict[str, Any]:
+ """
+ Build sanitized log data from request and AWS event.
+
+ Args:
+ request: The FastAPI request object
+ event: The AWS Lambda event (from API Gateway)
+
+ Returns:
+ Dictionary with sanitized request data for logging
+ """
+ # Extract request context
+ request_context = event.get("requestContext", {})
+ authorizer = request_context.get("authorizer", {})
+ identity = request_context.get("identity", {})
+
+ # Sanitize headers (redact auth, replace user-controlled headers)
+ raw_headers = dict(request.headers)
+ sanitized_headers = sanitize_headers(raw_headers, event)
+
+ # Build log data
+ log_data = {
+ "method": request.method,
+ "path": request.url.path,
+ "query_params": dict(request.query_params),
+ "headers": sanitized_headers,
+ "request_id": request_context.get("requestId"),
+ "source_ip": identity.get("sourceIp"), # Real IP from API Gateway
+ "user_agent": identity.get("userAgent"),
+ "user": {
+ "username": authorizer.get("username"),
+ "groups": authorizer.get("groups", []),
+ "auth_type": authorizer.get("authType"),
+ },
+ }
+
+ # Add path parameters if present
+ if hasattr(request, "path_params") and request.path_params:
+ log_data["path_params"] = dict(request.path_params)
+
+ return log_data
+
+ def _sanitize_body(self, body: bytes) -> str:
+ """
+ Sanitize request body for logging.
+
+ Attempts to parse as JSON and redact sensitive fields.
+ If parsing fails, returns a placeholder.
+
+ Args:
+ body: Raw request body bytes
+
+ Returns:
+ Sanitized body as string
+ """
+ if not body:
+ return ""
+
+ try:
+ # Try to parse as JSON
+ body_json = json.loads(body)
+
+ # Redact sensitive fields
+ sensitive_fields = ["password", "token", "secret", "apiKey", "api_key"]
+ for field in sensitive_fields:
+ if field in body_json:
+ body_json[field] = ""
+
+ return json.dumps(body_json)
+ except (json.JSONDecodeError, UnicodeDecodeError):
+ # Not JSON or can't decode - return placeholder
+ return f""
diff --git a/lambda/utilities/fastapi_middleware/security_headers_middleware.py b/lambda/utilities/fastapi_middleware/security_headers_middleware.py
new file mode 100644
index 000000000..6351da5e1
--- /dev/null
+++ b/lambda/utilities/fastapi_middleware/security_headers_middleware.py
@@ -0,0 +1,68 @@
+# 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.
+
+"""Middleware for adding security headers to all FastAPI responses."""
+
+from starlette.middleware.base import BaseHTTPMiddleware, Request, RequestResponseEndpoint, Response
+
+
+class SecurityHeadersMiddleware(BaseHTTPMiddleware):
+ """
+ Middleware that adds security headers to all HTTP responses.
+
+ Security headers included:
+ - Strict-Transport-Security: Forces HTTPS connections
+ - X-Content-Type-Options: Prevents MIME sniffing attacks
+ - X-Frame-Options: Prevents clickjacking attacks
+ - Cache-Control: Prevents caching of sensitive data
+ - Pragma: Legacy cache control for HTTP/1.0
+ - Content-Type: Ensures JSON responses are properly typed
+
+ These headers protect against common web vulnerabilities and ensure
+ secure communication between client and server.
+ """
+
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+ """
+ Process the request and add security headers to the response.
+
+ Args:
+ request: The incoming HTTP request
+ call_next: The next middleware or handler in the chain
+
+ Returns:
+ Response with security headers added
+ """
+ # Call the next handler to get the response
+ response = await call_next(request)
+
+ # Add security headers to the response
+ # HSTS: Force HTTPS for 547 days (47304000 seconds) including subdomains
+ response.headers["Strict-Transport-Security"] = "max-age=47304000; includeSubDomains"
+
+ # Prevent MIME sniffing (forces browser to respect Content-Type)
+ response.headers["X-Content-Type-Options"] = "nosniff"
+
+ # Prevent clickjacking by disallowing iframe embedding
+ response.headers["X-Frame-Options"] = "DENY"
+
+ # Prevent caching of sensitive data
+ response.headers["Cache-Control"] = "no-store, no-cache"
+ response.headers["Pragma"] = "no-cache"
+
+ # Ensure Content-Type is set (FastAPI usually sets this, but we ensure it)
+ if "Content-Type" not in response.headers:
+ response.headers["Content-Type"] = "application/json"
+
+ return response
diff --git a/lambda/utilities/file_processing.py b/lambda/utilities/file_processing.py
index e8a557539..4e92d02e1 100644
--- a/lambda/utilities/file_processing.py
+++ b/lambda/utilities/file_processing.py
@@ -132,7 +132,7 @@ def _extract_text_content(s3_object: dict) -> str:
----------
s3_object (dict): an S3 object containing a text file body.
"""
- return s3_object["Body"].read().decode("utf-8", errors="replace")
+ return s3_object["Body"].read().decode("utf-8", errors="replace") # type: ignore[no-any-return]
def generate_chunks(ingestion_job: IngestionJob) -> list[Document]:
@@ -184,8 +184,11 @@ def generate_chunks(ingestion_job: IngestionJob) -> list[Document]:
]
# Use factory to chunk documents based on strategy
- logger.info(f"Processing document with chunking strategy: {ingestion_job.chunk_strategy.type}")
- doc_chunks = ChunkingStrategyFactory.chunk_documents(docs, ingestion_job.chunk_strategy)
+ chunk_strategy = ingestion_job.chunk_strategy
+ if chunk_strategy is None:
+ raise ValueError("Chunking strategy is required")
+ logger.info(f"Processing document with chunking strategy: {chunk_strategy.type}")
+ doc_chunks = ChunkingStrategyFactory.chunk_documents(docs, chunk_strategy)
# Update part number of doc metadata
for i, doc in enumerate(doc_chunks):
diff --git a/lambda/utilities/header_sanitizer.py b/lambda/utilities/header_sanitizer.py
new file mode 100644
index 000000000..588c32d04
--- /dev/null
+++ b/lambda/utilities/header_sanitizer.py
@@ -0,0 +1,126 @@
+# 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.
+
+"""Utility for sanitizing HTTP headers before logging to prevent log injection attacks."""
+
+import logging
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# Allowlist of headers that are safe and useful to log
+# This prevents log injection attacks by only logging known, trusted headers
+ALLOWED_HEADERS = {
+ "accept",
+ "accept-encoding",
+ "accept-language",
+ "content-type",
+ "content-length",
+ "host",
+ "user-agent",
+ "referer",
+ "origin",
+ # Note: authorization is handled separately and redacted
+}
+
+# Headers that need server-controlled values for security
+# These will be replaced with values from API Gateway context
+HEADERS_WITH_SERVER_VALUES = {
+ "x-forwarded-for": lambda event: event.get("requestContext", {}).get("identity", {}).get("sourceIp", "unknown"),
+ "x-forwarded-host": lambda event: event.get("requestContext", {}).get("domainName", "unknown"),
+ "x-forwarded-proto": lambda event: "https", # API Gateway always uses HTTPS
+}
+
+
+def sanitize_headers(headers: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
+ """
+ Sanitize HTTP headers using a allowlist approach.
+
+ Only headers in the ALLOWED_HEADERS set are logged. This prevents log injection
+ attacks by rejecting any unexpected or potentially malicious headers.
+
+ Security-critical headers like x-forwarded-for are replaced with server-controlled
+ values from API Gateway to prevent IP spoofing in logs.
+
+ Args:
+ headers: Original HTTP headers from the request
+ event: Lambda event from API Gateway (used to extract real values)
+
+ Returns:
+ Dictionary containing only allowlisted headers with sanitized values
+
+ Example:
+ >>> headers = {
+ ... "accept": "application/json",
+ ... "x-amzn-actiontrace": "injected-value",
+ ... "x-forwarded-for": "1.2.3.4"
+ ... }
+ >>> event = {"requestContext": {"identity": {"sourceIp": "9.10.11.12"}}}
+ >>> sanitized = sanitize_headers(headers, event)
+ >>> sanitized
+ {"accept": "application/json", "x-forwarded-for": "9.10.11.12"}
+ """
+ if not headers:
+ return {}
+
+ sanitized = {}
+
+ # Process each header
+ for key, value in headers.items():
+ key_lower = key.lower()
+
+ # Check if this header should have a server-controlled value
+ if key_lower in HEADERS_WITH_SERVER_VALUES:
+ server_value = HEADERS_WITH_SERVER_VALUES[key_lower](event)
+ sanitized[key_lower] = server_value
+
+ # Log when we replace a user-provided value
+ if value != server_value:
+ logger.debug(f"Replaced header {key_lower}: user_value={value}, server_value={server_value}")
+
+ # Check if this header is in the allowlist
+ elif key_lower in ALLOWED_HEADERS:
+ sanitized[key_lower] = value
+
+ # All other headers are silently dropped (not logged)
+ else:
+ logger.debug(f"Dropped non-allowlisted header: {key_lower}")
+
+ return sanitized
+
+
+def get_sanitized_headers_for_logging(event: dict[str, Any]) -> dict[str, Any]:
+ """
+ Extract and sanitize headers from Lambda event for safe logging.
+
+ This is a convenience function that extracts headers from the event
+ and sanitizes them in one step.
+
+ Args:
+ event: Lambda event from API Gateway
+
+ Returns:
+ Dictionary of sanitized headers safe for logging
+
+ Example:
+ >>> event = {
+ ... "headers": {"x-forwarded-for": "1.2.3.4"},
+ ... "requestContext": {"identity": {"sourceIp": "5.6.7.8"}}
+ ... }
+ >>> headers = get_sanitized_headers_for_logging(event)
+ >>> headers["x-forwarded-for"]
+ "5.6.7.8"
+ """
+ headers = event.get("headers", {})
+ return sanitize_headers(headers, event)
diff --git a/lambda/utilities/healthcheck_validator.py b/lambda/utilities/healthcheck_validator.py
new file mode 100644
index 000000000..f342de9fd
--- /dev/null
+++ b/lambda/utilities/healthcheck_validator.py
@@ -0,0 +1,83 @@
+# 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.
+
+"""Validator for ECS healthcheck command format."""
+
+
+def validate_healthcheck_command(command: str | list[str]) -> None:
+ """
+ Validate ECS healthcheck command format.
+
+ This validation ensures the command format is compatible with ECS requirements
+ to prevent deployment failures. It does NOT restrict command content - admins
+ are trusted to configure their containers appropriately.
+
+ Args:
+ command: Healthcheck command as string or array
+
+ Raises:
+ ValueError: If command format is invalid for ECS
+
+ Examples:
+ Valid formats:
+ - "curl -f http://localhost:8080/health"
+ - ["CMD-SHELL", "curl -f http://localhost:8080/health"]
+ - ["CMD", "curl", "-f", "http://localhost:8080/health"]
+
+ Invalid formats:
+ - "" (empty string)
+ - [] (empty array)
+ - ["curl", "-f", "..."] (missing CMD/CMD-SHELL prefix)
+ """
+ # Check if command is None
+ if command is None:
+ raise ValueError("Healthcheck command cannot be None")
+
+ # Check if command is string
+ if isinstance(command, str):
+ if not command.strip():
+ raise ValueError("Healthcheck command cannot be an empty string")
+ # String format is valid - ECS converts to CMD-SHELL
+ return
+
+ # Check if command is list
+ if isinstance(command, list):
+ if len(command) == 0:
+ raise ValueError("Healthcheck command array cannot be empty")
+
+ # Check first element is CMD or CMD-SHELL
+ if command[0] not in ["CMD", "CMD-SHELL"]:
+ raise ValueError(
+ f"Healthcheck array must start with 'CMD' or 'CMD-SHELL', got: '{command[0]}'. "
+ "Example: ['CMD-SHELL', 'curl -f http://localhost:8080/health']"
+ )
+
+ # Check there's at least one command after the prefix
+ if len(command) < 2:
+ raise ValueError(
+ f"Healthcheck array must contain a command after '{command[0]}'. "
+ "Example: ['CMD-SHELL', 'curl -f http://localhost:8080/health']"
+ )
+
+ # Check command part is not empty
+ if isinstance(command[1], str) and not command[1].strip():
+ raise ValueError("Healthcheck command cannot be empty after CMD/CMD-SHELL prefix")
+
+ return
+
+ # Invalid type
+ raise ValueError(
+ f"Healthcheck command must be a string or array, got: {type(command).__name__}. "
+ "Example: ['CMD-SHELL', 'curl -f http://localhost:8080/health']"
+ )
diff --git a/lambda/utilities/input_validation.py b/lambda/utilities/input_validation.py
new file mode 100644
index 000000000..24951bec5
--- /dev/null
+++ b/lambda/utilities/input_validation.py
@@ -0,0 +1,210 @@
+# 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.
+
+"""Input validation utilities for Lambda functions."""
+
+import functools
+import logging
+from collections.abc import Callable
+from typing import Any, TypeVar
+
+from utilities.response_builder import generate_html_response
+
+logger = logging.getLogger(__name__)
+
+F = TypeVar("F", bound=Callable[..., Any])
+
+# Default maximum request size: 1MB
+DEFAULT_MAX_REQUEST_SIZE = 1024 * 1024
+# Max API Gateway size - use for image uploads / chat sessions
+MAX_LARGE_REQUEST_SIZE = 10 * 1024 * 1024
+
+
+def contains_null_bytes(data: str) -> bool:
+ """
+ Check if a string contains null bytes.
+
+ Null bytes (\\x00) can be used to bypass input validation or cause
+ unexpected behavior in string processing.
+
+ Args:
+ data: String to check for null bytes
+
+ Returns:
+ True if null bytes are found, False otherwise
+ """
+ return "\x00" in data
+
+
+def validate_input(max_request_size: int = DEFAULT_MAX_REQUEST_SIZE) -> Callable[[F], F]:
+ """
+ Decorator to validate Lambda event input before processing.
+
+ This decorator provides security protections against:
+ - Null byte injection attacks
+ - Oversized payload attacks
+ - Invalid HTTP methods
+
+ Args:
+ max_request_size: Maximum allowed request body size in bytes (default: 1MB)
+
+ Returns:
+ Decorator function that wraps the Lambda handler
+ """
+
+ def decorator(f: F) -> F:
+ @functools.wraps(f)
+ def wrapper(event: dict, context: dict) -> dict[str, str | int | dict[str, str]]:
+ """
+ Validate Lambda event input.
+
+ Validation order:
+ 1. HTTP method validation (returns 405 if invalid)
+ 2. Request size check (returns 413 if too large)
+ 3. Path validation (returns 400 if null bytes found)
+ 4. Path parameter validation (returns 400 if null bytes found)
+ 5. Query parameter validation (returns 400 if null bytes found)
+ 6. Request body validation (returns 400 if null bytes found)
+
+ Args:
+ event: Lambda event from API Gateway
+ context: Lambda context
+
+ Returns:
+ Error response if validation fails, otherwise calls wrapped function
+ """
+ # 1. Validate HTTP method
+ http_method = event.get("httpMethod", "")
+ valid_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
+ if http_method not in valid_methods:
+ logger.warning(
+ f"Invalid HTTP method: {http_method}",
+ extra={
+ "method": http_method,
+ "path": event.get("path", ""),
+ },
+ )
+ return generate_html_response(
+ 405,
+ {
+ "error": "Method Not Allowed",
+ "message": f"HTTP method {http_method} is not allowed",
+ },
+ )
+
+ # 2. Check request size
+ body = event.get("body", "")
+ if body:
+ body_size = len(body.encode("utf-8"))
+ if body_size > max_request_size:
+ logger.warning(
+ f"Request size {body_size} bytes exceeds maximum {max_request_size} bytes",
+ extra={
+ "request_size": body_size,
+ "max_size": max_request_size,
+ "path": event.get("path", ""),
+ "method": http_method,
+ },
+ )
+ return generate_html_response(
+ 413,
+ {
+ "error": "Payload Too Large",
+ "message": f"Request body size exceeds maximum allowed size of {max_request_size} bytes",
+ },
+ )
+
+ # 3. Validate path for null bytes
+ path = event.get("path", "")
+ if contains_null_bytes(path):
+ logger.warning(
+ "Null byte detected in path",
+ extra={
+ "path": path,
+ "method": http_method,
+ },
+ )
+ return generate_html_response(
+ 400,
+ {
+ "error": "Bad Request",
+ "message": "Invalid characters detected in request path",
+ },
+ )
+
+ # 4. Validate path parameters for null bytes
+ path_params = event.get("pathParameters") or {}
+ for key, value in path_params.items():
+ if contains_null_bytes(key) or contains_null_bytes(str(value)):
+ logger.warning(
+ f"Null byte detected in path parameter: {key}",
+ extra={
+ "parameter_name": key,
+ "path": path,
+ "method": http_method,
+ },
+ )
+ return generate_html_response(
+ 400,
+ {
+ "error": "Bad Request",
+ "message": "Invalid characters detected in path parameters",
+ },
+ )
+
+ # 5. Validate query parameters for null bytes
+ query_params = event.get("queryStringParameters") or {}
+ for key, value in query_params.items():
+ if contains_null_bytes(key) or contains_null_bytes(str(value)):
+ logger.warning(
+ f"Null byte detected in query parameter: {key}",
+ extra={
+ "parameter_name": key,
+ "path": path,
+ "method": http_method,
+ },
+ )
+ return generate_html_response(
+ 400,
+ {
+ "error": "Bad Request",
+ "message": "Invalid characters detected in query parameters",
+ },
+ )
+
+ # 6. Validate request body for null bytes
+ if body and contains_null_bytes(body):
+ logger.warning(
+ "Null byte detected in request body",
+ extra={
+ "path": path,
+ "method": http_method,
+ "body_size": body_size,
+ },
+ )
+ return generate_html_response(
+ 400,
+ {
+ "error": "Bad Request",
+ "message": "Invalid characters detected in request body",
+ },
+ )
+
+ # All validations passed, call the wrapped function
+ result: dict[str, str | int | dict[str, str]] = f(event, context)
+ return result
+
+ return wrapper # type: ignore [return-value]
+
+ return decorator
diff --git a/lambda/utilities/lambda_decorators.py b/lambda/utilities/lambda_decorators.py
new file mode 100644
index 000000000..73f041de6
--- /dev/null
+++ b/lambda/utilities/lambda_decorators.py
@@ -0,0 +1,171 @@
+# 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.
+
+"""Lambda function decorators for API Gateway integration."""
+
+import functools
+import logging
+from collections.abc import Callable
+from contextvars import ContextVar
+from typing import Any, overload
+
+from utilities.event_parser import sanitize_event_for_logging
+from utilities.input_validation import DEFAULT_MAX_REQUEST_SIZE, validate_input
+from utilities.response_builder import generate_exception_response, generate_html_response
+
+logger = logging.getLogger(__name__)
+
+# Context variable to store Lambda context across the request
+ctx_context: ContextVar[Any] = ContextVar("lamdbacontext")
+
+# Type for Lambda handler functions - can return dict, list, or any JSON-serializable type
+LambdaHandler = Callable[[dict[Any, Any], Any], Any]
+
+
+@overload
+def api_wrapper(_func: LambdaHandler) -> LambdaHandler:
+ """Overload for decorator without parentheses."""
+ ...
+
+
+@overload
+def api_wrapper(
+ _func: None = None,
+ *,
+ max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
+) -> Callable[[LambdaHandler], LambdaHandler]:
+ """Overload for decorator with parameters."""
+ ...
+
+
+def api_wrapper(
+ _func: LambdaHandler | None = None,
+ *,
+ max_request_size: int = DEFAULT_MAX_REQUEST_SIZE,
+) -> LambdaHandler | Callable[[LambdaHandler], LambdaHandler]:
+ """
+ Wrap Lambda function with comprehensive API Gateway integration.
+
+ This decorator provides:
+ - Input validation (null bytes, request size, HTTP methods)
+ - Request logging with sanitized headers
+ - Exception handling with appropriate status codes
+ - Security headers in responses
+
+ Can be used with or without parameters:
+ - @api_wrapper
+ - @api_wrapper()
+ - @api_wrapper(max_request_size=10 * 1024 * 1024)
+
+ Parameters
+ ----------
+ _func : LambdaHandler | None
+ The Lambda handler function (used when decorator is applied without parentheses).
+ max_request_size : int
+ Maximum allowed request body size in bytes (default: 1MB).
+
+ Returns
+ -------
+ LambdaHandler | Callable[[LambdaHandler], LambdaHandler]
+ The wrapped function with API Gateway integration.
+
+ Example
+ -------
+ >>> @api_wrapper
+ ... def get_user(event: dict, context: dict) -> dict:
+ ... user_id = event["pathParameters"]["userId"]
+ ... return {"userId": user_id, "name": "John"}
+
+ >>> @api_wrapper(max_request_size=10 * 1024 * 1024)
+ ... def upload_image(event: dict, context: dict) -> dict:
+ ... # Handle large payload
+ ... return {"status": "uploaded"}
+ """
+
+ def decorator(f: LambdaHandler) -> LambdaHandler:
+ @functools.wraps(f)
+ @validate_input(max_request_size=max_request_size)
+ def wrapper(event: dict[Any, Any], context: Any) -> dict[Any, Any]:
+ """Execute Lambda handler with API Gateway integration."""
+ ctx_context.set(context)
+ code_func_name = f.__name__
+ lambda_func_name = getattr(context, "function_name", "unknown")
+
+ # Log request with sanitized event data
+ sanitized_event = sanitize_event_for_logging(event)
+ logger.info(f"Lambda {lambda_func_name}({code_func_name}) invoked with {sanitized_event}")
+
+ try:
+ result = f(event, context)
+ return generate_html_response(200, result)
+ except Exception as e:
+ return generate_exception_response(e)
+
+ return wrapper
+
+ # Handle both @api_wrapper and @api_wrapper() syntax
+ if _func is not None:
+ return decorator(_func)
+ return decorator
+
+
+def authorization_wrapper(f: LambdaHandler) -> LambdaHandler:
+ """
+ Wrap Lambda authorizer function.
+
+ This decorator sets up the Lambda context for authorizer functions
+ without adding API Gateway response formatting.
+
+ Parameters
+ ----------
+ f : LambdaHandler
+ The Lambda authorizer function to wrap.
+
+ Returns
+ -------
+ LambdaHandler
+ The wrapped authorizer function.
+
+ Example
+ -------
+ >>> @authorization_wrapper
+ ... def authorizer(event: dict, context: dict) -> dict:
+ ... token = event["authorizationToken"]
+ ... return {"principalId": "user123", "policyDocument": {...}}
+ """
+
+ @functools.wraps(f)
+ def wrapper(event: dict[Any, Any], context: Any) -> Any:
+ """Execute Lambda authorizer with context setup."""
+ ctx_context.set(context)
+ return f(event, context)
+
+ return wrapper
+
+
+def get_lambda_context() -> Any:
+ """
+ Get the current Lambda context from context variable.
+
+ Returns
+ -------
+ Any
+ The Lambda context object.
+
+ Raises
+ ------
+ LookupError
+ If called outside of a Lambda execution context.
+ """
+ return ctx_context.get()
diff --git a/lambda/utilities/repository_types.py b/lambda/utilities/repository_types.py
index 1800d951f..afa0c8d2f 100644
--- a/lambda/utilities/repository_types.py
+++ b/lambda/utilities/repository_types.py
@@ -15,7 +15,7 @@
from __future__ import annotations
from enum import Enum
-from typing import Any, Dict
+from typing import Any
class RepositoryType(str, Enum):
@@ -24,11 +24,11 @@ class RepositoryType(str, Enum):
BEDROCK_KB = "bedrock_knowledge_base"
@classmethod
- def get_type(cls, repository: Dict[str, Any]) -> RepositoryType:
+ def get_type(cls, repository: dict[str, Any]) -> RepositoryType:
return RepositoryType(repository.get("type"))
@classmethod
- def is_type(cls, repository: Dict[str, Any], repo_type: RepositoryType) -> bool:
+ def is_type(cls, repository: dict[str, Any], repo_type: RepositoryType) -> bool:
return repository.get("type") == repo_type
def calculate_similarity_score(self, score: float) -> float:
diff --git a/lambda/utilities/response_builder.py b/lambda/utilities/response_builder.py
new file mode 100644
index 000000000..c6462bb24
--- /dev/null
+++ b/lambda/utilities/response_builder.py
@@ -0,0 +1,205 @@
+# 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.
+
+"""Response builders for API Gateway Lambda functions."""
+
+import json
+import logging
+from datetime import datetime
+from decimal import Decimal
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+class DecimalEncoder(json.JSONEncoder):
+ """JSON encoder that handles Decimal, datetime, and Pydantic objects."""
+
+ def default(self, obj: Any) -> Any:
+ """
+ Encode special types to JSON-serializable formats.
+
+ Parameters
+ ----------
+ obj : Any
+ Object to encode.
+
+ Returns
+ -------
+ Any
+ JSON-serializable representation.
+ """
+ if isinstance(obj, Decimal):
+ return float(obj)
+ if isinstance(obj, datetime):
+ return obj.isoformat()
+ # Handle Pydantic models
+ if hasattr(obj, "model_dump"):
+ return obj.model_dump(mode="json")
+ return super().default(obj)
+
+
+def _serialize_pydantic(obj: Any) -> Any:
+ """
+ Recursively serialize Pydantic models to dictionaries.
+
+ Parameters
+ ----------
+ obj : Any
+ Object to serialize.
+
+ Returns
+ -------
+ Any
+ Serialized object.
+ """
+ if hasattr(obj, "model_dump"):
+ return obj.model_dump(mode="json")
+ if isinstance(obj, list):
+ return [_serialize_pydantic(item) for item in obj]
+ if isinstance(obj, dict):
+ return {key: _serialize_pydantic(value) for key, value in obj.items()}
+ return obj
+
+
+def generate_html_response(status_code: int, response_body: Any) -> dict[str, str | int | dict[str, str]]:
+ """
+ Generate API Gateway response with security headers.
+
+ This function creates a properly formatted API Gateway response with:
+ - JSON-encoded body
+ - Security headers (HSTS, X-Frame-Options, etc.)
+ - CORS headers
+ - Cache control headers
+
+ Parameters
+ ----------
+ status_code : int
+ HTTP status code (e.g., 200, 400, 500).
+ response_body : Any
+ Response body to be JSON-encoded. Can be dict, list, Pydantic model, or list of Pydantic models.
+
+ Returns
+ -------
+ Dict[str, Union[str, int, Dict[str, str]]]
+ API Gateway response object.
+
+ Example
+ -------
+ >>> generate_html_response(200, {"userId": "123", "name": "John"})
+ {
+ "statusCode": 200,
+ "body": '{"userId": "123", "name": "John"}',
+ "headers": {...}
+ }
+ """
+ # Serialize Pydantic models before JSON encoding
+ serialized_body = _serialize_pydantic(response_body)
+
+ return {
+ "statusCode": status_code,
+ "body": json.dumps(serialized_body, cls=DecimalEncoder),
+ "headers": {
+ "Access-Control-Allow-Origin": "*",
+ "Content-Type": "application/json",
+ "Cache-Control": "no-store, no-cache",
+ "Pragma": "no-cache",
+ "Strict-Transport-Security": "max-age:47304000; includeSubDomains",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ },
+ }
+
+
+def generate_exception_response(e: Exception) -> dict[str, str | int | dict[str, str]]:
+ """
+ Generate API Gateway error response from exception.
+
+ This function maps exceptions to appropriate HTTP status codes and
+ generates user-friendly error messages while logging detailed errors
+ internally.
+
+ Exception Mapping:
+ - ValidationError → 400 Bad Request
+ - AWS SDK exceptions → Status from response metadata
+ - Custom exceptions with http_status_code/status_code → Custom status
+ - Missing event parameters → 400 Bad Request
+ - All other exceptions → 500 Internal Server Error
+
+ Parameters
+ ----------
+ e : Exception
+ Exception that was caught.
+
+ Returns
+ -------
+ Dict[str, Union[str, int, Dict[str, str]]]
+ API Gateway error response.
+
+ Example
+ -------
+ >>> try:
+ ... raise ValueError("Invalid user ID")
+ ... except Exception as e:
+ ... response = generate_exception_response(e)
+ >>> response["statusCode"]
+ 500
+ """
+ status_code = 400
+ error_message: str
+
+ if type(e).__name__ == "ValidationError":
+ # User input validation error - return 400 with error message
+ error_message = str(e)
+ logger.exception(e)
+ elif hasattr(e, "response"):
+ # AWS SDK exception - extract status code and message
+ metadata = e.response.get("ResponseMetadata")
+ if metadata:
+ status_code = metadata.get("HTTPStatusCode", 400)
+ error_message = str(e)
+ logger.exception(e)
+ elif hasattr(e, "http_status_code"):
+ # Custom exception with http_status_code attribute
+ status_code = e.http_status_code
+ error_message = getattr(e, "message", str(e))
+ logger.exception(e)
+ elif hasattr(e, "status_code"):
+ # Custom exception with status_code attribute (e.g., HTTPException)
+ status_code = e.status_code
+ error_message = getattr(e, "message", str(e))
+ logger.exception(e)
+ else:
+ # Generic unhandled exception - return 500 with generic message
+ error_msg = str(e)
+ if error_msg in ["'requestContext'", "'pathParameters'", "'body'"]:
+ # Missing event parameter - this is a 400 error
+ status_code = 400
+ error_message = f"Missing event parameter: {error_msg}"
+ else:
+ # Genuine server error - return 500 with generic message
+ status_code = 500
+ error_message = "An unexpected error occurred while processing your request"
+ # Log detailed error for debugging
+ logger.error(
+ f"Unhandled exception: {type(e).__name__}: {error_msg}",
+ exc_info=e,
+ extra={
+ "exception_type": type(e).__name__,
+ "exception_message": error_msg,
+ },
+ )
+ logger.exception(e)
+
+ return generate_html_response(status_code, error_message)
diff --git a/lambda/utilities/session_encryption.py b/lambda/utilities/session_encryption.py
index 3b57ef8b7..1ec78ab64 100644
--- a/lambda/utilities/session_encryption.py
+++ b/lambda/utilities/session_encryption.py
@@ -19,7 +19,7 @@
import logging
import os
from decimal import Decimal
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
from botocore.exceptions import ClientError
@@ -64,7 +64,7 @@ def _get_kms_key_arn() -> str:
return key_arn
-def _generate_data_key(key_arn: str, encryption_context: Optional[Dict[str, str]] = None) -> tuple[bytes, bytes]:
+def _generate_data_key(key_arn: str, encryption_context: dict[str, str] | None = None) -> tuple[bytes, bytes]:
"""
Generate a data key from KMS.
@@ -85,7 +85,7 @@ def _generate_data_key(key_arn: str, encryption_context: Optional[Dict[str, str]
raise SessionEncryptionError(f"Failed to generate data key: {e}")
-def _decrypt_data_key(encrypted_data_key: bytes, encryption_context: Optional[Dict[str, str]] = None) -> bytes:
+def _decrypt_data_key(encrypted_data_key: bytes, encryption_context: dict[str, str] | None = None) -> bytes:
"""
Decrypt a data key using KMS.
@@ -104,7 +104,7 @@ def _decrypt_data_key(encrypted_data_key: bytes, encryption_context: Optional[Di
raise SessionEncryptionError(f"Failed to decrypt data key: {e}")
-def _create_encryption_context(user_id: str, session_id: str) -> Dict[str, str]:
+def _create_encryption_context(user_id: str, session_id: str) -> dict[str, str]:
"""
Create encryption context for KMS operations.
@@ -226,7 +226,7 @@ def is_encrypted_data(data: str) -> bool:
return False
-def migrate_session_to_encrypted(session_data: Dict[str, Any], user_id: str, session_id: str) -> Dict[str, Any]:
+def migrate_session_to_encrypted(session_data: dict[str, Any], user_id: str, session_id: str) -> dict[str, Any]:
"""
Migrate a session from unencrypted to encrypted format.
@@ -264,7 +264,7 @@ def migrate_session_to_encrypted(session_data: Dict[str, Any], user_id: str, ses
raise SessionEncryptionError(f"Failed to migrate session to encrypted: {e}")
-def decrypt_session_fields(session_data: Dict[str, Any], user_id: str, session_id: str) -> Dict[str, Any]:
+def decrypt_session_fields(session_data: dict[str, Any], user_id: str, session_id: str) -> dict[str, Any]:
"""
Decrypt encrypted fields in session data.
diff --git a/lambda/utilities/time.py b/lambda/utilities/time.py
index 2cd0d855a..730d9d4ca 100644
--- a/lambda/utilities/time.py
+++ b/lambda/utilities/time.py
@@ -12,20 +12,20 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from datetime import datetime, timezone
+from datetime import datetime, timezone, tzinfo
-def now(tz=timezone.utc) -> int:
+def now(tz: tzinfo = timezone.utc) -> int:
"""Return UTC epoch milliseconds."""
return int(datetime.now(tz).timestamp() * 1000)
-def now_seconds(tz=timezone.utc) -> int:
+def now_seconds(tz: tzinfo = timezone.utc) -> int:
"""Return UTC epoch seconds."""
return int(datetime.now(tz).timestamp())
-def iso_string(tz=timezone.utc) -> str:
+def iso_string(tz: tzinfo = timezone.utc) -> str:
"""Return ISO datetime string with UTC offset."""
return datetime.now(tz).isoformat()
diff --git a/lambda/utilities/validation.py b/lambda/utilities/validation.py
index 7ab79c5cd..a48d4b633 100644
--- a/lambda/utilities/validation.py
+++ b/lambda/utilities/validation.py
@@ -14,7 +14,7 @@
"""Validation utilities for Lambda functions."""
import logging
-from typing import Any, List
+from typing import Any
import botocore.session
@@ -78,7 +78,7 @@ def validate_instance_type(type: str) -> str:
raise ValueError("Invalid EC2 instance type.")
-def validate_all_fields_defined(fields: List[Any]) -> bool:
+def validate_all_fields_defined(fields: list[Any]) -> bool:
"""Validate that all fields are non-null in the field list.
Args:
@@ -87,10 +87,10 @@ def validate_all_fields_defined(fields: List[Any]) -> bool:
Returns:
bool: True if all fields are non-null, False otherwise
"""
- return all((field is not None for field in fields))
+ return all(field is not None for field in fields)
-def validate_any_fields_defined(fields: List[Any]) -> bool:
+def validate_any_fields_defined(fields: list[Any]) -> bool:
"""Validate that at least one field is non-null in the field list.
Args:
@@ -99,7 +99,7 @@ def validate_any_fields_defined(fields: List[Any]) -> bool:
Returns:
bool: True if at least one field is non-null, False otherwise
"""
- return any((field is not None for field in fields))
+ return any(field is not None for field in fields)
def safe_error_response(error: Exception) -> dict:
diff --git a/lib/api-base/authorizer.ts b/lib/api-base/authorizer.ts
index 832ae0fed..4f522c33c 100644
--- a/lib/api-base/authorizer.ts
+++ b/lib/api-base/authorizer.ts
@@ -79,7 +79,6 @@ export class CustomAuthorizer extends Construct {
// Create Lambda authorizer
const lambdaPath = config.lambdaPath || LAMBDA_PATH;
const authorizerLambda = new Function(this, 'AuthorizerLambda', {
-
runtime: getPythonRuntime(),
handler: 'authorizer.lambda_functions.lambda_handler',
functionName: `${cdk.Stack.of(this).stackName}-lambda-authorizer`,
diff --git a/lib/api-base/ecsCluster.ts b/lib/api-base/ecsCluster.ts
index 71e95a245..4b70e6e00 100644
--- a/lib/api-base/ecsCluster.ts
+++ b/lib/api-base/ecsCluster.ts
@@ -20,6 +20,7 @@ import { AdjustmentType, AutoScalingGroup, BlockDeviceVolume, GroupMetrics, Moni
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { Metric } from 'aws-cdk-lib/aws-cloudwatch';
import { InstanceType, ISecurityGroup, Port, SecurityGroup } from 'aws-cdk-lib/aws-ec2';
+import { Alias } from 'aws-cdk-lib/aws-kms';
import {
AmiHardwareType,
AsgCapacityProvider,
@@ -35,6 +36,7 @@ import {
LinuxParameters,
LogDriver,
MountPoint,
+ NetworkMode,
Protocol,
Volume,
} from 'aws-cdk-lib/aws-ecs';
@@ -139,6 +141,7 @@ export class ECSCluster extends Construct {
): { taskDefinition: Ec2TaskDefinition, container: ContainerDefinition } {
const ec2TaskDefinition = new Ec2TaskDefinition(this, createCdkId([taskDefinitionName, 'Ec2TaskDefinition']), {
family: createCdkId([config.deploymentName, taskDefinitionName], 32, 2),
+ networkMode: NetworkMode.BRIDGE,
volumes,
...(taskRole && { taskRole }),
...(executionRole && { executionRole }),
@@ -212,14 +215,15 @@ export class ECSCluster extends Construct {
});
// Create auto-scaling group
+ // Note: cooldown is not set here because we use step scaling policies which don't support cooldown.
+ // Step scaling uses evaluation periods instead for controlling scaling behavior.
const autoScalingGroup = new AutoScalingGroup(this, createCdkId([config.deploymentName, config.deploymentStage, 'ASG']), {
vpc: vpc.vpc,
vpcSubnets: vpc.subnetSelection,
instanceType: new InstanceType(ecsConfig.instanceType),
- machineImage: EcsOptimizedImage.amazonLinux2(ecsConfig.amiHardwareType),
+ machineImage: EcsOptimizedImage.amazonLinux2023(ecsConfig.amiHardwareType),
minCapacity: ecsConfig.autoScalingConfig.minCapacity,
maxCapacity: ecsConfig.autoScalingConfig.maxCapacity,
- cooldown: Duration.seconds(ecsConfig.autoScalingConfig.cooldown),
groupMetrics: [GroupMetrics.all()],
instanceMonitoring: Monitoring.DETAILED,
defaultInstanceWarmup: Duration.seconds(ecsConfig.autoScalingConfig.defaultInstanceWarmup),
@@ -236,6 +240,15 @@ export class ECSCluster extends Construct {
updatePolicy: UpdatePolicy.rollingUpdate({})
});
+ // Enable SNS topic encryption for ECS lifecycle hooks
+ // AppSec Finding #5: SNS topics must use server-side encryption
+ // Uses AWS managed key (alias/aws/sns) for lifecycle hook drain notifications
+ const snsEncryptionKey = Alias.fromAliasName(
+ this,
+ createCdkId([config.deploymentName, config.deploymentStage, 'SnsKey']),
+ 'alias/aws/sns'
+ );
+
const asgCapacityProvider = new AsgCapacityProvider(this, createCdkId([config.deploymentName, config.deploymentStage, 'AsgCapacityProvider']), {
autoScalingGroup,
// Managed scaling tracks cluster reservation to add/remove instances automatically
@@ -247,6 +260,9 @@ export class ECSCluster extends Construct {
// disable managed scaling because we are going to setup rules to do it
enableManagedScaling: false,
enableManagedTerminationProtection: false,
+
+ // Encrypt SNS topic used for lifecycle hook notifications
+ topicEncryptionKey: snsEncryptionKey,
});
cluster.addAsgCapacityProvider(asgCapacityProvider);
@@ -270,7 +286,6 @@ export class ECSCluster extends Construct {
],
evaluationPeriods: 5,
adjustmentType: AdjustmentType.CHANGE_IN_CAPACITY,
- cooldown: Duration.seconds(300)
});
autoScalingGroup.scaleOnMetric(createCdkId(['ASG', identifier, 'ScaleOut']), {
@@ -281,7 +296,6 @@ export class ECSCluster extends Construct {
],
evaluationPeriods: 2,
adjustmentType: AdjustmentType.CHANGE_IN_CAPACITY,
- cooldown: Duration.seconds(120)
});
// Tag Auto Scaling Group for schedule management
@@ -385,13 +399,16 @@ export class ECSCluster extends Construct {
asgSecurityGroup.addIngressRule(securityGroup, Port.allTcp());
// Add listener
+ // AppSec TLS Configuration: Use TLS 1.2/1.3 policy with forward secrecy (ECDHE cipher suites only)
+ // SslPolicy.TLS13_RES maps to ELBSecurityPolicy-TLS13-1-2-2021-06
+ // This policy excludes RSA key exchange cipher suites to meet tlscheckerv2 compliance requirements
const listenerProps: BaseApplicationListenerProps = {
port: ecsConfig.loadBalancerConfig.sslCertIamArn ? 443 : 80,
open: ecsConfig.internetFacing,
certificates: ecsConfig.loadBalancerConfig.sslCertIamArn
? [{ certificateArn: ecsConfig.loadBalancerConfig.sslCertIamArn }]
: undefined,
- sslPolicy: ecsConfig.loadBalancerConfig.sslCertIamArn ? SslPolicy.RECOMMENDED_TLS : SslPolicy.RECOMMENDED,
+ sslPolicy: ecsConfig.loadBalancerConfig.sslCertIamArn ? SslPolicy.TLS13_RES : undefined,
};
const listener = loadBalancer.addListener(
@@ -581,7 +598,12 @@ export class ECSCluster extends Construct {
circuitBreaker: !this.config.region.includes('iso') ? { rollback: true } : undefined,
capacityProviderStrategies: [
{ capacityProvider: this.asgCapacityProvider.capacityProviderName, weight: 1 }
- ]
+ ],
+ // Speed up deployments by allowing more aggressive rollout
+ minHealthyPercent: 50, // Allow 50% of tasks to be replaced at once
+ maxHealthyPercent: 200, // Allow up to 2x desired count during deployment
+ // Reduce health check grace period for faster failure detection
+ healthCheckGracePeriod: Duration.seconds(60)
};
const service = new Ec2Service(this, createCdkId([this.config.deploymentName, taskName, 'Ec2Svc']), serviceProps);
@@ -596,7 +618,8 @@ export class ECSCluster extends Construct {
const loadBalancerHealthCheckConfig = this.ecsConfig.loadBalancerConfig.healthCheckConfig;
const targetGroup = this.listener.addTargets(createCdkId([this.identifier, taskName, 'TgtGrp']), {
- targetGroupName: createCdkId([this.config.deploymentName, this.identifier, taskName], 32, 2).toLowerCase(),
+ // Note: targetGroupName intentionally omitted to allow CloudFormation to generate unique names.
+ // This enables seamless replacement when immutable properties (like TargetType) change.
healthCheck: {
path: loadBalancerHealthCheckConfig.path,
interval: Duration.seconds(loadBalancerHealthCheckConfig.interval),
diff --git a/lib/api-base/fastApiContainer.ts b/lib/api-base/fastApiContainer.ts
index b9d23c144..57d036d4c 100644
--- a/lib/api-base/fastApiContainer.ts
+++ b/lib/api-base/fastApiContainer.ts
@@ -71,7 +71,7 @@ export class FastApiContainer extends Construct {
const { config, securityGroup, tokenTable, vpc, managementKeyName} = props;
- const instanceType = 'm5.large';
+ const instanceType = 'm5.xlarge';
const buildArgs: Record | undefined = {
BASE_IMAGE: config.baseImage,
@@ -176,7 +176,7 @@ export class FastApiContainer extends Construct {
interval: 60,
timeout: 30,
healthyThresholdCount: 2,
- unhealthyThresholdCount: 10
+ unhealthyThresholdCount: 3 // Reduced from 10 to 3 for faster failure detection
},
domainName: config.restApiConfig.domainName,
sslCertIamArn: config.restApiConfig?.sslCertIamArn ?? null,
diff --git a/lib/api-base/rest-api-gw.ts b/lib/api-base/rest-api-gw.ts
index 0042dadb1..9e878689a 100644
--- a/lib/api-base/rest-api-gw.ts
+++ b/lib/api-base/rest-api-gw.ts
@@ -52,7 +52,7 @@ export class RestApiGateway extends Construct {
this.restApi = new RestApi(this, `${id}-RestApi`, {
description: 'The User Interface and session management Lambda API Layer.',
- endpointConfiguration: { types: [EndpointType.REGIONAL] },
+ endpointTypes: [EndpointType.REGIONAL],
deployOptions,
defaultCorsPreflightOptions: {
allowOrigins: Cors.ALL_ORIGINS,
diff --git a/lib/api-base/utils.ts b/lib/api-base/utils.ts
index 9854fe484..94974c9fd 100644
--- a/lib/api-base/utils.ts
+++ b/lib/api-base/utils.ts
@@ -36,6 +36,7 @@ import {
} from 'aws-cdk-lib/aws-apigateway';
import { ISecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { IRole } from 'aws-cdk-lib/aws-iam';
+import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { CfnPermission, Code, Function, IFunction, ILayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';
import { Vpc } from '../networking/vpc';
@@ -94,7 +95,10 @@ export function registerAPIEndpoint (
let handler;
if (funcDef.existingFunction) {
- handler = Function.fromFunctionArn(scope, functionId, funcDef.existingFunction);
+ handler = Function.fromFunctionAttributes(scope, functionId, {
+ functionArn: funcDef.existingFunction,
+ sameEnvironment: true,
+ });
// create a CFN L1 primitive because `handler.addPermission` doesn't behave as expected
// https://stackoverflow.com/questions/71075361/aws-cdk-lambda-resource-based-policy-for-a-function-with-an-alias
@@ -121,6 +125,7 @@ export function registerAPIEndpoint (
vpc: vpc.vpc,
securityGroups,
vpcSubnets: vpc.subnetSelection,
+ logRetention: RetentionDays.ONE_MONTH,
});
}
diff --git a/lib/chat/api/configuration.ts b/lib/chat/api/configuration.ts
index e2db8707d..cceb93823 100644
--- a/lib/chat/api/configuration.ts
+++ b/lib/chat/api/configuration.ts
@@ -29,6 +29,7 @@ import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resour
import { IRole } from 'aws-cdk-lib/aws-iam';
import { LAMBDA_PATH } from '../../util';
import { McpApi } from './mcp';
+import { RemovalPolicy } from 'aws-cdk-lib';
/**
* Props for the ConfigurationApi construct
@@ -86,6 +87,7 @@ export class ConfigurationApi extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
const lambdaRole: IRole = createLambdaRole(this, config.deploymentName, 'ConfigurationApi', this.configTable.tableArn, config.roles?.LambdaConfigurationApiExecutionRole);
@@ -115,13 +117,13 @@ export class ConfigurationApi extends Construct {
'editPromptTemplate': { 'BOOL': 'True' },
'editChatHistoryBuffer': { 'BOOL': 'True' },
'editNumOfRagDocument': { 'BOOL': 'True' },
- 'uploadRagDocs': { 'BOOL': 'True' },
+ 'uploadRagDocs': { 'BOOL': config.deployRag ? 'True' : 'False' },
'uploadContextDocs': { 'BOOL': 'True' },
'documentSummarization': { 'BOOL': 'True' },
- 'showRagLibrary': { 'BOOL': 'True' },
- 'showMcpWorkbench': { 'BOOL': 'False' },
+ 'showRagLibrary': { 'BOOL': config.deployRag ? 'True' : 'False' },
+ 'showMcpWorkbench': { 'BOOL': config.deployMcpWorkbench ? 'True' : 'False' },
'showPromptTemplateLibrary': { 'BOOL': 'True' },
- 'mcpConnections': { 'BOOL': 'True' },
+ 'mcpConnections': { 'BOOL': config.deployMcp ? 'True' : 'False' },
'modelLibrary': { 'BOOL': 'True' },
'encryptSession': { 'BOOL': 'False' },
}
@@ -151,7 +153,8 @@ export class ConfigurationApi extends Construct {
let environment = {
CONFIG_TABLE_NAME: this.configTable.tableName,
- FASTAPI_ENDPOINT: fastApiEndpoint
+ FASTAPI_ENDPOINT: fastApiEndpoint,
+ ADMIN_GROUP: config.authConfig?.adminGroup || '',
};
if (mcpApi) {
diff --git a/lib/chat/api/mcp.ts b/lib/chat/api/mcp.ts
index 2c2d93ce9..04845cda5 100644
--- a/lib/chat/api/mcp.ts
+++ b/lib/chat/api/mcp.ts
@@ -27,6 +27,7 @@ import { BaseProps } from '../../schema';
import { createLambdaRole } from '../../core/utils';
import { Vpc } from '../../networking/vpc';
import { LAMBDA_PATH } from '../../util';
+import { RemovalPolicy } from 'aws-cdk-lib';
/**
* Properties for McpApi Construct.
@@ -84,6 +85,7 @@ export class McpApi extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
const byOwnerIndex = 'byOwner';
mcpServersTable.addGlobalSecondaryIndex({
@@ -119,7 +121,7 @@ export class McpApi extends Construct {
// Create API Lambda functions
const apis: PythonLambdaFunction[] = [
{
- name: 'list',
+ name: 'list_mcp_servers',
resource: 'mcp_server',
description: 'Lists available mcp servers for user',
path: 'mcp-server',
diff --git a/lib/chat/api/prompt-template-api.ts b/lib/chat/api/prompt-template-api.ts
index 284ce41c3..6f8296b0b 100644
--- a/lib/chat/api/prompt-template-api.ts
+++ b/lib/chat/api/prompt-template-api.ts
@@ -26,6 +26,7 @@ import { IAuthorizer, RestApi } from 'aws-cdk-lib/aws-apigateway';
import { Vpc } from '../../networking/vpc';
import { ISecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { LAMBDA_PATH } from '../../util';
+import { RemovalPolicy } from 'aws-cdk-lib';
/**
* Properties required to initialize the PromptTemplateApi construct,
@@ -70,8 +71,8 @@ export class PromptTemplateApi extends Construct {
type: AttributeType.STRING
},
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
billingMode: BillingMode.PAY_PER_REQUEST,
- pointInTimeRecovery: true
});
const byOwnerIndexName = 'byOwner';
@@ -126,7 +127,7 @@ export class PromptTemplateApi extends Construct {
environment,
},
{
- name: 'list',
+ name: 'list_prompt',
resource: 'prompt_templates',
description: 'Lists all available prompt templates',
path: 'prompt-templates',
diff --git a/lib/chat/api/session.ts b/lib/chat/api/session.ts
index 1e8a777e2..ac9a14164 100644
--- a/lib/chat/api/session.ts
+++ b/lib/chat/api/session.ts
@@ -24,12 +24,10 @@ import { Key } from 'aws-cdk-lib/aws-kms';
import { Construct } from 'constructs';
import { getPythonRuntime, PythonLambdaFunction, registerAPIEndpoint } from '../../api-base/utils';
-import { BaseProps } from '../../schema';
+import { BaseProps, RemovalPolicy } from '../../schema';
import { createLambdaRole } from '../../core/utils';
import { Vpc } from '../../networking/vpc';
import { LAMBDA_PATH } from '../../util';
-import { Bucket, HttpMethods } from 'aws-cdk-lib/aws-s3';
-import { RemovalPolicy } from 'aws-cdk-lib';
/**
* Properties for SessionApi Construct.
@@ -88,6 +86,7 @@ export class SessionApi extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
const byUserIdIndex = 'byUserId';
sessionTable.addGlobalSecondaryIndex({
@@ -115,27 +114,12 @@ export class SessionApi extends Construct {
stringValue: sessionEncryptionKey.keyArn,
});
- const bucketAccessLogsBucket = Bucket.fromBucketArn(scope, 'BucketAccessLogsBucket',
- StringParameter.valueForStringParameter(scope, `${config.deploymentPrefix}/bucket/bucket-access-logs`)
+ // Get Images S3 bucket name from API Base stack (created there for cross-stack access)
+ const imagesBucketName = StringParameter.valueForStringParameter(
+ this,
+ `${config.deploymentPrefix}/generatedImagesBucketName`
);
- // Create Images S3 bucket
- const imagesBucket = new Bucket(scope, 'GeneratedImagesBucket', {
- removalPolicy: config.removalPolicy,
- autoDeleteObjects: config.removalPolicy === RemovalPolicy.DESTROY,
- enforceSSL: true,
- cors: [
- {
- allowedMethods: [HttpMethods.GET, HttpMethods.POST],
- allowedHeaders: ['*'],
- allowedOrigins: ['*'],
- exposedHeaders: ['Access-Control-Allow-Origin'],
- },
- ],
- serverAccessLogsBucket: bucketAccessLogsBucket,
- serverAccessLogsPrefix: 'logs/generated-images-bucket/'
- });
-
const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', {
restApiId: restApiId,
rootResourceId: rootResourceId,
@@ -150,7 +134,7 @@ export class SessionApi extends Construct {
const env = {
SESSIONS_TABLE_NAME: sessionTable.tableName,
SESSIONS_BY_USER_ID_INDEX_NAME: byUserIdIndex,
- GENERATED_IMAGES_S3_BUCKET_NAME: imagesBucket.bucketName,
+ GENERATED_IMAGES_S3_BUCKET_NAME: imagesBucketName,
MODEL_TABLE_NAME: modelTableName,
CONFIG_TABLE_NAME: configTable.tableName,
SESSION_ENCRYPTION_KEY_ARN: sessionEncryptionKey.keyArn,
@@ -288,13 +272,42 @@ export class SessionApi extends Construct {
);
if (f.method === 'POST' || f.method === 'PUT') {
sessionTable.grantWriteData(lambdaFunction);
- imagesBucket.grantReadWrite(lambdaFunction);
+ // Grant S3 read/write permissions for image/video operations
+ lambdaRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:PutObject', 's3:GetObject'],
+ resources: [`arn:${config.partition}:s3:::${imagesBucketName}/*`]
+ })
+ );
} else if (f.method === 'GET') {
sessionTable.grantReadData(lambdaFunction);
- imagesBucket.grantRead(lambdaFunction);
+ // Grant S3 read permissions
+ lambdaRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:GetObject'],
+ resources: [`arn:${config.partition}:s3:::${imagesBucketName}/*`]
+ })
+ );
} else if (f.method === 'DELETE') {
sessionTable.grantReadWriteData(lambdaFunction);
- imagesBucket.grantDelete(lambdaFunction);
+ // Grant S3 list permission on bucket for prefix-based listing
+ lambdaRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:ListBucket'],
+ resources: [`arn:${config.partition}:s3:::${imagesBucketName}`]
+ })
+ );
+ // Grant S3 delete permissions on objects
+ lambdaRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:DeleteObject'],
+ resources: [`arn:${config.partition}:s3:::${imagesBucketName}/*`]
+ })
+ );
}
});
}
diff --git a/lib/chat/api/user-preferences.ts b/lib/chat/api/user-preferences.ts
index af52e93df..2091d63f3 100644
--- a/lib/chat/api/user-preferences.ts
+++ b/lib/chat/api/user-preferences.ts
@@ -27,6 +27,7 @@ import { BaseProps } from '../../schema';
import { createLambdaRole } from '../../core/utils';
import { Vpc } from '../../networking/vpc';
import { LAMBDA_PATH } from '../../util';
+import { RemovalPolicy } from 'aws-cdk-lib';
/**
* Properties for UserPreferencesApi Construct.
@@ -78,6 +79,7 @@ export class UserPreferencesApi extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
const restApi = RestApi.fromRestApiAttributes(this, 'RestApi', {
diff --git a/lib/core/apiBaseConstruct.ts b/lib/core/apiBaseConstruct.ts
index dc6f41661..725ff54ca 100644
--- a/lib/core/apiBaseConstruct.ts
+++ b/lib/core/apiBaseConstruct.ts
@@ -15,16 +15,16 @@
*/
-import { Authorizer, Cors, EndpointType, RestApi, StageOptions } from 'aws-cdk-lib/aws-apigateway';
+import { Authorizer, CfnAccount, Cors, EndpointType, RestApi, StageOptions } from 'aws-cdk-lib/aws-apigateway';
import { AttributeType, BillingMode, ProjectionType, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { CustomAuthorizer } from '../api-base/authorizer';
-import { Duration, Stack, StackProps } from 'aws-cdk-lib';
+import { CfnResource, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { ITable, Table } from 'aws-cdk-lib/aws-dynamodb';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
-import { Code, Function, } from 'aws-cdk-lib/aws-lambda';
+import { Code, Function, IFunction, LayerVersion } from 'aws-cdk-lib/aws-lambda';
import { createCdkId } from '../core/utils';
import { Vpc } from '../networking/vpc';
@@ -32,7 +32,6 @@ import { APP_MANAGEMENT_KEY, BaseProps, Config } from '../schema';
import {
Effect,
ManagedPolicy,
- PolicyDocument,
PolicyStatement,
Role,
ServicePrincipal,
@@ -42,6 +41,7 @@ import { LAMBDA_PATH } from '../util';
import { getPythonRuntime } from '../api-base/utils';
import { ISecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { EventBus } from 'aws-cdk-lib/aws-events';
+import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from 'aws-cdk-lib/aws-s3';
export type LisaApiBaseProps = {
vpc: Vpc;
@@ -60,12 +60,46 @@ export class LisaApiBaseConstruct extends Construct {
public readonly restApiUrl: string;
public readonly tokenTable?: ITable;
public readonly managementKeySecretName: string;
+ public readonly iamAuthSetupFn: IFunction;
+ public readonly imagesBucket: Bucket;
constructor (scope: Stack, id: string, props: LisaApiBaseProps) {
super(scope, id);
const { config, vpc, securityGroups } = props;
+ // Get bucket access logs bucket
+ const bucketAccessLogsBucket = Bucket.fromBucketArn(scope, 'BucketAccessLogsBucket',
+ StringParameter.valueForStringParameter(scope, `${config.deploymentPrefix}/bucket/bucket-access-logs`)
+ );
+
+ // Create Images S3 bucket for generated images and videos
+ // This is created in API Base stack so it's available to both Chat and Serve stacks
+ this.imagesBucket = new Bucket(scope, 'GeneratedImagesBucket', {
+ removalPolicy: config.removalPolicy,
+ autoDeleteObjects: config.removalPolicy === RemovalPolicy.DESTROY,
+ enforceSSL: true,
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
+ cors: [
+ {
+ allowedMethods: [HttpMethods.GET, HttpMethods.POST],
+ allowedHeaders: ['*'],
+ allowedOrigins: ['*'],
+ exposedHeaders: ['Access-Control-Allow-Origin'],
+ },
+ ],
+ serverAccessLogsBucket: bucketAccessLogsBucket,
+ serverAccessLogsPrefix: 'logs/generated-images-bucket/',
+ encryption: BucketEncryption.S3_MANAGED
+ });
+
+ // Store bucket name in SSM for cross-stack access
+ new StringParameter(scope, 'GeneratedImagesBucketNameParameter', {
+ parameterName: `${config.deploymentPrefix}/generatedImagesBucketName`,
+ stringValue: this.imagesBucket.bucketName,
+ description: 'S3 bucket name for generated images and videos',
+ });
+
// TokenTable is now managed in API Base so it's independent of Serve
// Create the table - if it already exists from previous Serve deployment,
// CloudFormation will handle the conflict. For new deployments, it will be created.
@@ -86,8 +120,13 @@ export class LisaApiBaseConstruct extends Construct {
billingMode: BillingMode.PAY_PER_REQUEST,
encryption: TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
+ // Set DeletionPolicy to RetainExceptOnCreate to allow CloudFormation to import existing tables
+ const cfnTokenTable = tokenTable.node.defaultChild as CfnResource;
+ cfnTokenTable.applyRemovalPolicy(RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE);
+
// Add GSI for querying tokens by username
tokenTable.addGlobalSecondaryIndex({
indexName: 'username-index',
@@ -107,6 +146,27 @@ export class LisaApiBaseConstruct extends Construct {
const { managementKeySecretName } = this.createManagementKeySecret(scope, config, vpc, securityGroups);
this.managementKeySecretName = managementKeySecretName;
+ // Create shared IAM auth setup Lambda for PGVector databases
+ // This Lambda is used by Serve, RAG, and vector_store_deployer stacks
+ this.iamAuthSetupFn = this.createIamAuthSetupLambda(scope, config, vpc, securityGroups);
+
+ // Create IAM role for API Gateway to write logs to CloudWatch
+ // This is an account-level setting required before enabling API Gateway logging
+ const apiGatewayCloudWatchRole = new Role(scope, 'ApiGatewayCloudWatchRole', {
+ assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
+ managedPolicies: [
+ ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonAPIGatewayPushToCloudWatchLogs'), // pragma: allowlist secret
+ ],
+ });
+
+ // Configure API Gateway account settings with the CloudWatch role
+ const apiGatewayAccount = new CfnAccount(scope, 'ApiGatewayAccount', {
+ cloudWatchRoleArn: apiGatewayCloudWatchRole.roleArn,
+ });
+
+ // Ensure the role is created before the account settings
+ apiGatewayAccount.node.addDependency(apiGatewayCloudWatchRole);
+
const deployOptions: StageOptions = {
stageName: config.deploymentStage,
throttlingRateLimit: 100,
@@ -131,7 +191,7 @@ export class LisaApiBaseConstruct extends Construct {
const restApi = new RestApi(scope, `${scope.node.id}-RestApi`, {
description: 'Base API Gateway for LISA.',
- endpointConfiguration: { types: [config.privateEndpoints ? EndpointType.PRIVATE : EndpointType.REGIONAL] },
+ endpointTypes: [config.privateEndpoints ? EndpointType.PRIVATE : EndpointType.REGIONAL],
deploy: true,
deployOptions,
defaultCorsPreflightOptions: {
@@ -142,6 +202,9 @@ export class LisaApiBaseConstruct extends Construct {
binaryMediaTypes: ['font/*', 'image/*'],
});
+ // Ensure API Gateway account settings (CloudWatch role) are configured before the API stage
+ restApi.node.addDependency(apiGatewayAccount);
+
this.restApi = restApi;
this.restApiId = restApi.restApiId;
@@ -156,6 +219,25 @@ export class LisaApiBaseConstruct extends Construct {
eventBusName: `${config.deploymentName}-management-events`,
});
+ // Create the role first without the secret policy to avoid circular dependency
+ // The circular dependency occurs when:
+ // 1. Role has inline policy referencing secret ARN
+ // 2. Secret's rotation schedule references the role
+ // 3. Secret's auto-created KMS key policy references the role
+ const rotationRole = new Role(scope, createCdkId([scope.node.id, 'managementKeyRotationRole']), {
+ assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+ managedPolicies: [
+ ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'),
+ ],
+ });
+
+ // Grant EventBus permissions to the role
+ rotationRole.addToPolicy(new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['events:PutEvents'],
+ resources: [managementEventBus.eventBusArn]
+ }));
+
const managementKeySecret = new Secret(scope, createCdkId([scope.node.id, 'managementKeySecret']), {
secretName: managementKeySecretName,
description: 'LISA management key secret',
@@ -166,6 +248,11 @@ export class LisaApiBaseConstruct extends Construct {
removalPolicy: config.removalPolicy
});
+ // Grant secret permissions after secret is created using CDK's grant methods
+ // This avoids circular dependency by letting CDK manage the dependency order
+ managementKeySecret.grantRead(rotationRole);
+ managementKeySecret.grantWrite(rotationRole);
+
const rotationLambda = new Function(scope, createCdkId([scope.node.id, 'managementKeyRotationLambda']), {
runtime: getPythonRuntime(),
handler: 'management_key.handler',
@@ -174,33 +261,7 @@ export class LisaApiBaseConstruct extends Construct {
environment: {
EVENT_BUS_NAME: managementEventBus.eventBusName,
},
- role: new Role(scope, createCdkId([scope.node.id, 'managementKeyRotationRole']), {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- managedPolicies: [
- ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'),
- ],
- inlinePolicies: {
- 'SecretsManagerRotation': new PolicyDocument({
- statements: [
- new PolicyStatement({
- effect: Effect.ALLOW,
- actions: [
- 'secretsmanager:DescribeSecret',
- 'secretsmanager:GetSecretValue',
- 'secretsmanager:PutSecretValue',
- 'secretsmanager:UpdateSecretVersionStage'
- ],
- resources: [managementKeySecret.secretArn]
- }),
- new PolicyStatement({
- effect: Effect.ALLOW,
- actions: ['events:PutEvents'],
- resources: [managementEventBus.eventBusArn]
- })
- ]
- })
- }
- }),
+ role: rotationRole,
securityGroups: securityGroups,
vpc: vpc.vpc,
vpcSubnets: vpc.subnetSelection
@@ -218,4 +279,63 @@ export class LisaApiBaseConstruct extends Construct {
return { managementKeySecretName };
}
+
+ /**
+ * Creates a shared Lambda for IAM authentication setup on PGVector databases.
+ * This Lambda creates IAM database users and deletes bootstrap secrets.
+ * It's shared across Serve, RAG, and vector_store_deployer stacks.
+ */
+ private createIamAuthSetupLambda (scope: Stack, config: Config, vpc: Vpc, securityGroups: ISecurityGroup[]): IFunction {
+ // Create IAM role for the Lambda
+ const iamAuthSetupRole = new Role(scope, 'IamAuthSetupRole', {
+ assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
+ managedPolicies: [
+ ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'),
+ ],
+ });
+
+ // Grant permissions to read/delete secrets (specific secrets will be passed via event)
+ iamAuthSetupRole.addToPolicy(new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DeleteSecret'],
+ resources: [`arn:${config.partition}:secretsmanager:${config.region}:${config.accountNumber}:secret:*`],
+ }));
+
+ // Get common layer for psycopg2
+ const commonLayer = LayerVersion.fromLayerVersionArn(
+ scope,
+ 'IamAuthCommonLayer',
+ StringParameter.valueForStringParameter(scope, `${config.deploymentPrefix}/layerVersion/common`),
+ );
+
+ const iamAuthSetupFn = new Function(scope, 'IamAuthSetupFn', {
+ functionName: createCdkId([config.deploymentName, config.deploymentStage, 'iam_auth_setup']),
+ runtime: getPythonRuntime(),
+ handler: 'utilities.db_setup_iam_auth.handler',
+ code: Code.fromAsset(config.lambdaPath || LAMBDA_PATH),
+ timeout: Duration.minutes(2),
+ memorySize: 256,
+ role: iamAuthSetupRole,
+ vpc: vpc.vpc,
+ vpcSubnets: vpc.subnetSelection,
+ securityGroups: securityGroups,
+ layers: [commonLayer],
+ });
+
+ // Store the IAM auth setup Lambda ARN in SSM for other stacks to use
+ new StringParameter(scope, 'IamAuthSetupFnArnParam', {
+ parameterName: `${config.deploymentPrefix}/iamAuthSetupFnArn`,
+ stringValue: iamAuthSetupFn.functionArn,
+ description: 'ARN of the shared IAM auth setup Lambda for PGVector databases',
+ });
+
+ // Store the IAM auth setup Lambda role ARN in SSM for granting secret permissions
+ new StringParameter(scope, 'IamAuthSetupRoleArnParam', {
+ parameterName: `${config.deploymentPrefix}/iamAuthSetupRoleArn`,
+ stringValue: iamAuthSetupRole.roleArn,
+ description: 'ARN of the IAM auth setup Lambda role for granting secret permissions',
+ });
+
+ return iamAuthSetupFn;
+ }
}
diff --git a/lib/core/coreConstruct.ts b/lib/core/coreConstruct.ts
index 831ade7d3..6f1559996 100644
--- a/lib/core/coreConstruct.ts
+++ b/lib/core/coreConstruct.ts
@@ -22,7 +22,7 @@ import { BaseProps } from '../schema';
import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { COMMON_LAYER_PATH, FASTAPI_LAYER_PATH, AUTHORIZER_LAYER_PATH, CDK_LAYER_PATH } from '../util';
-import { Bucket } from 'aws-cdk-lib/aws-s3';
+import { BlockPublicAccess, Bucket, BucketEncryption, ObjectOwnership } from 'aws-cdk-lib/aws-s3';
import { getNodeRuntime } from '../api-base/utils';
export const ARCHITECTURE = lambda.Architecture.X86_64;
@@ -47,6 +47,9 @@ export class CoreConstruct extends Construct {
autoDeleteObjects: config.removalPolicy === RemovalPolicy.DESTROY,
bucketName: ([config.deploymentName, config.accountNumber, config.deploymentStage, 'bucket', 'access', 'logs'].join('-')).toLowerCase(),
enforceSSL: true,
+ encryption: BucketEncryption.S3_MANAGED,
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
+ objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED,
});
new StringParameter(scope, 'LISABucketAccessLogsBucket', {
diff --git a/lib/core/layers/cdk/package.json b/lib/core/layers/cdk/package.json
index e7c1e3814..6d4496e12 100644
--- a/lib/core/layers/cdk/package.json
+++ b/lib/core/layers/cdk/package.json
@@ -5,6 +5,7 @@
"dependencies": {
"aws-cdk": "^2.1033.0",
"aws-cdk-lib": "^2.232.1",
+ "constructs": "^10.4.3",
"zod": "^4.1.13"
},
"scripts": {
diff --git a/lib/core/layers/fastapi/requirements.txt b/lib/core/layers/fastapi/requirements.txt
index 488f78a38..0a768869d 100644
--- a/lib/core/layers/fastapi/requirements.txt
+++ b/lib/core/layers/fastapi/requirements.txt
@@ -1,4 +1,4 @@
-# boto3==1.36.0 // Provided by Lambda
+# boto3==1.40.76 // Provided by Lambda
# requests==2.32.5 // provided by Common Layer
fastapi==0.124.2
mangum==0.19.0
diff --git a/lib/docs/.vitepress/config.mts b/lib/docs/.vitepress/config.mts
index dd4852bf0..bb47a11c3 100644
--- a/lib/docs/.vitepress/config.mts
+++ b/lib/docs/.vitepress/config.mts
@@ -27,6 +27,7 @@ const navLinks = [
{ text: 'What is LISA', link: '/admin/getting-started#what-is-lisa' },
{ text: 'Major Features', link: '/admin/getting-started#major-features' },
{ text: 'Key Features & Benefits', link: '/admin/getting-started#key-features-benefits' },
+ { text: 'Access Control', link: '/admin/getting-started#access-control' },
]
},
{
@@ -60,10 +61,12 @@ const navLinks = [
{ text: 'Usage & Features', link: '/config/usage' },
{ text: 'RAG Repository', link: '/config/repositories' },
{ text: 'Langfuse Tracing', link: '/config/langfuse-tracing'},
+ { text: 'Private Labeling', link: '/config/custom-branding' },
{
text: 'Configuration Schema',
link: '/config/configuration',
items: [
+ { text: 'Config Generator CLI', link: '/config/config-generator' },
{ text: 'VPC & Subnet Overrides', link: '/config/vpc-overrides' },
{ text: 'Security Group Overrides', link: '/config/security-group-overrides' },
{ text: 'Role Overrides', link: '/config/role-overrides' },
diff --git a/lib/docs/admin/architecture.md b/lib/docs/admin/architecture.md
index 3a21cee2c..c34efe3e7 100644
--- a/lib/docs/admin/architecture.md
+++ b/lib/docs/admin/architecture.md
@@ -18,10 +18,10 @@ LISA Serve and LISA MCP are standalone, core solutions with APIs for customers n
## LISA Serve

-LISA Serve provides model self-hosting and integration with compatible external model providers. Serve supports text generation, image generation, and embedding models. Serve’s components are designed for scale and reliability. Serve can be accessed via LISA’s REST APIs, or through LISA’s chat
+LISA Serve provides model self-hosting and integration with compatible external model providers. Serve supports text generation, image generation, video generation, and embedding models. Serve’s components are designed for scale and reliability. Serve can be accessed via LISA’s REST APIs, or through LISA’s chat
UI. Regardless of origin, all inference requests are routed via an Application Load Balancer (ALB), which serves as the main entry point to LISA Serve. The ALB forwards requests through the LiteLLM proxy, hosted in its own scalable Amazon Elastic Container Service (ECS) cluster with Amazon Elastic Compute Cloud (EC2) instance. LiteLLM routes traffic to the appropriate model.
-Self-hosted model traffic is directed to model specific ALBs, which enable autoscaling in the event of heavy traffic. Each self-hosted model has its own Amazon ECS cluster and Amazon EC2 instance. Text generation and image generation models compatible with Hugging Face’s
+Self-hosted model traffic is directed to model specific ALBs, which enable autoscaling in the event of heavy traffic. Each self-hosted model has its own Amazon ECS cluster and Amazon EC2 instance. Text generation, image generation, video generation, and embedding models compatible with Hugging Face’s
[Text Generation Inference (TGI)](https://huggingface.co/docs/text-generation-inference/en/index) and
[vLLM](https://docs.vllm.ai/en/latest/) images are supported. Embedding models compatible with Hugging Face’s
[Text Embedding Inference (TEI)](https://huggingface.co/docs/text-embeddings-inference/en/index) and
@@ -117,7 +117,7 @@ Administrator role have access to application configuration.
**Features:**
-* Prompting text and image generation LLMs and receiving responses
+* Prompting text, image, and video generation LLMs and receiving responses
* Viewing, deleting, and exporting chat history
* Supports streaming responses, viewing metadata, RAG citations
* Supports Markdown, mermaid, and math formatting
diff --git a/lib/docs/admin/deploy.md b/lib/docs/admin/deploy.md
index cb9e6463d..3a593cd01 100644
--- a/lib/docs/admin/deploy.md
+++ b/lib/docs/admin/deploy.md
@@ -384,7 +384,7 @@ cp -r ~/.cache/prisma* lib/serve/rest-api/PRISMA_CACHE/
```
**Important Notes:**
-- The cache is platform-specific. Generate it on a system matching your Docker base image (e.g., for `python:3.13-slim` which is Debian-based, so you may want to use a Debian-based system)
+- The cache is platform-specific. Generate it on a system matching your Docker base image (e.g., for `public.ecr.aws/docker/library/python:3.13-slim` which is Debian-based, so you may want to use a Debian-based system)
- The `prisma version` command downloads binaries for your current platform
- Both `prisma/` and `prisma-python/` directories are required for offline operation
diff --git a/lib/docs/admin/getting-started.md b/lib/docs/admin/getting-started.md
index 6f4b0cde5..bf1828ccf 100644
--- a/lib/docs/admin/getting-started.md
+++ b/lib/docs/admin/getting-started.md
@@ -20,7 +20,7 @@ LISA’s core component, Serve, provides secure, scalable, low latency access to
language models. Serve offers model flexibility out of the box. Customers can self-host models directly within LISA
infrastructure, or integrate with compatible third party model providers. LISA supports model self-hosting and inference
via Amazon Elastic Container Service (ECS) with Amazon Elastic Compute Cloud (EC2). Text generation, image generation,
-and embedding models compatible with Hugging Face’s
+video generation, and embedding models compatible with Hugging Face’s
[Text Generation Inference (TGI)](https://huggingface.co/docs/text-generation-inference/en/index)
and [Text Embedding Inference (TEI)](https://huggingface.co/docs/text-embeddings-inference/en/index) images,
and [vLLM](https://docs.vllm.ai/en/latest/) are supported.
@@ -96,3 +96,54 @@ flexibility for different use cases.
*The below screenshot showcases LISA’s Model Management page. It is filtered to display the Claude models configured with LISA, although they are hosted by the Amazon Bedrock service. Via LISA’s Model Management page, Administrators configure self-hosted and externally hosted third party (3P) models with LISA. LISA is compatible with over 100 externally hosted models via the LiteLLM proxy. Administrators do not need to worry about the 3P model provider’s unique API requirements since LiteLLM handles the standardization.*

+
+# Access Control
+
+LISA Roles and Enterprise Groups control access to features and resources.
+
+## Roles
+
+`AdminGroup` and `UserGroup` properties in the configuration are used to control tiers of application access, not resource access.
+
+- **AdminGroup**: The IDP group that distinguishes which users have access to create and manage restricted resource configuration within the UI, including:
+ - Activating application features
+ - Configuring models via Model Management
+ - Configuring repos and Collections via RAG management
+ - MCP server management
+ - MCP Workbench code editor
+
+- **UserGroup** (optional): If provided, this is required when the IDP is used for multiple systems and you want to control which users in the IDP have access to LISA.
+
+- **API Management** (v6.1+): A new role that allows users to manage their API tokens within LISA, but does not grant full Admin privileges.
+
+## Groups
+
+Access to resources can be constrained by Enterprise Groups, including:
+
+- LISA models
+- Prompt templates
+- RAG repos
+- RAG collections
+- MCP Connections
+- LISA MCP servers
+- API tokens
+
+You can create or bring any number of Enterprise Groups in your IDP, which can then be used in LISA to lock down resources at creation/update. When you create/update any resource, you can assign 0, 1, or many Groups to that resource.
+
+### Example: Group-Based Access Control
+
+For example, let's say your IDP has the following groups: **Team Red**, **Team White**, and **Team Blue**. Below shows how you can use Groups to lock down access to Models, and then RAG repos and their Collections:
+
+**Models:**
+- Model 1: Teams Red and White
+- Model 2: none (Global)
+- Model 3: Team Blue
+
+**RAG Repositories and Collections:**
+- RAG Repo 1: Teams Red, White, Blue
+ - Collection A: Team Red
+ - Collection B: Team White
+ - Collection C: Teams White and Blue
+- RAG Repo 2: none (Global)
+ - Collection X: Team Blue
+ - Collection Y: none (Global)
diff --git a/lib/docs/config/config-generator.md b/lib/docs/config/config-generator.md
new file mode 100644
index 000000000..7a1405ecc
--- /dev/null
+++ b/lib/docs/config/config-generator.md
@@ -0,0 +1,207 @@
+# Configuration Generator CLI
+
+The Configuration Generator is an interactive command-line tool that helps you create a valid `config-custom.yaml` file for LISA deployment. Instead of manually editing YAML files, the generator prompts you for each configuration value, validates your inputs, and produces a properly formatted configuration file.
+
+## Running the Generator
+
+From the project root, run:
+
+```bash
+npm run generate-config
+```
+
+Or directly with tsx:
+
+```bash
+npx tsx scripts/generate-config.ts
+```
+
+## What It Configures
+
+The generator walks you through the following configuration sections:
+
+### Core Configuration
+
+- **AWS Account Number**: Your 12-digit AWS account ID
+- **AWS Region**: The region for deployment (e.g., `us-east-1`)
+- **AWS Partition**: The AWS partition (`aws`, `aws-cn`, `aws-gov`, `aws-iso`, `aws-iso-b`, `aws-iso-f`)
+- **Deployment Stage**: Environment identifier (default: `prod`)
+- **Deployment Name**: Name for your deployment (default: `prod`)
+- **S3 Bucket for Models**: The S3 bucket where your models are stored
+
+### Prebuilt Assets
+
+If you're using prebuilt assets from `@amzn/lisa-adc`, the generator automatically configures:
+
+- Lambda layer paths
+- Web app assets path
+- Documentation path
+- ECS model deployer path
+- Vector store deployer path
+- ECR image configurations for REST API, MCP Workbench, and Batch Ingestion
+
+### Authentication (Optional)
+
+- **OIDC Authority URL**: Your identity provider's authority URL
+- **Client ID**: The OIDC client ID
+- **Admin Group**: Group name for admin users
+- **JWT Groups Property**: The JWT claim containing user groups
+
+### API Gateway (Optional)
+
+- **Domain Name**: Custom domain for API Gateway
+
+### REST API (Optional)
+
+- **SSL Certificate IAM ARN**: ARN of your SSL certificate
+- **Domain Name**: Custom domain for the REST API
+
+### ECS Models (Optional)
+
+Configure models to deploy on ECS:
+
+- **Model Name**: The S3 path where the model is stored (e.g., `openai/gpt-oss-20b`)
+- **Inference Container**: The container type (`vllm`, `tei`, or `tgi`)
+- **Base Image**: The container image (defaults provided for each container type)
+
+### Feature Flags
+
+Enable or disable LISA features:
+
+- Deploy Chat
+- Deploy Metrics
+- Deploy MCP Workbench
+- Deploy RAG
+- Deploy Docs
+- Deploy UI
+- Deploy MCP
+- Deploy Serve
+
+## File Handling
+
+The generator handles existing configuration files gracefully:
+
+- **No existing config**: Creates `config-custom.yaml`
+- **Existing config found**: Offers to merge with existing values or create a new `config-generated.yaml`
+
+When merging, your existing values are preserved and new values are added or updated.
+
+## Example Session
+
+```
+╔════════════════════════════════════════════════════════════════╗
+║ LISA Configuration Generator ║
+║ Generate a config-custom.yaml for LISA deployment ║
+╚════════════════════════════════════════════════════════════════╝
+
+📋 Core Configuration
+
+AWS Account Number (12 digits): 123456789012
+AWS Region: us-east-1
+
+Partition options: aws, aws-cn, aws-gov, aws-iso, aws-iso-b, aws-iso-f
+AWS Partition [aws]: aws
+Deployment Stage [prod]: prod
+Deployment Name [prod]: prod
+S3 Bucket for Models: my-models-bucket
+
+📦 Prebuilt Assets
+
+Use prebuilt assets from @amzn/lisa-adc? [Y/n]: y
+
+🔐 Authentication Configuration
+
+Configure Authentication? [y/N]: y
+OIDC Authority URL: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxx
+Client ID: my-client-id
+Admin Group Name (optional): admins
+JWT Groups Property (optional): cognito:groups
+
+🤖 ECS Model Configuration
+
+Models will be deployed from S3 bucket: my-models-bucket
+The model name corresponds to the path in S3 where the model is stored.
+Example: "openai/gpt-oss-20b" means s3://my-models-bucket/openai/gpt-oss-20b
+
+Would you like to add ECS models? [y/N]: y
+
+--- Model 1 ---
+Model name (S3 path, e.g., openai/gpt-oss-20b): openai/gpt-oss-20b
+
+Inference container options: vllm, tei, tgi
+Inference container type [vllm]: vllm
+Base image [vllm/vllm-openai:latest]: vllm/vllm-openai:latest
+
+✓ Added model: openai/gpt-oss-20b (vllm)
+
+Add another model? [y/N]: n
+
+🚀 Feature Deployment Flags
+
+Use default feature flags (all enabled)? [Y/n]: y
+
+✅ Configuration generated successfully!
+📄 Output file: config-custom.yaml
+```
+
+## Example Output
+
+```yaml
+accountNumber: "123456789012"
+region: us-east-1
+partition: aws
+deploymentStage: prod
+deploymentName: prod
+s3BucketModels: my-models-bucket
+ragRepositories: []
+ecsModels:
+ - modelName: openai/gpt-oss-20b
+ baseImage: vllm/vllm-openai:latest
+ inferenceContainer: vllm
+authConfig:
+ authority: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxxx
+ clientId: my-client-id
+ adminGroup: admins
+ jwtGroupsProperty: cognito:groups
+restApiConfig:
+ imageConfig:
+ type: ecr
+ repositoryArn: arn:aws:ecr:us-east-1:123456789012:repository/lisa-rest-api
+ tag: latest
+mcpWorkbenchConfig:
+ imageConfig:
+ type: ecr
+ repositoryArn: arn:aws:ecr:us-east-1:123456789012:repository/lisa-mcp-workbench
+ tag: latest
+batchIngestionConfig:
+ imageConfig:
+ type: ecr
+ 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
+deployChat: true
+deployMetrics: true
+deployMcpWorkbench: true
+deployRag: true
+deployDocs: true
+deployUi: true
+deployMcp: true
+deployServe: true
+```
+
+## Validation
+
+The generator validates all inputs:
+
+- **Account Number**: Must be exactly 12 digits
+- **Region**: Must be a valid AWS region
+- **Partition**: Must be one of the supported AWS partitions
+- **Inference Container**: Must be `vllm`, `tei`, or `tgi`
+- **Required Fields**: Cannot be empty
+
+Invalid inputs display an error message and re-prompt for the correct value.
diff --git a/lib/docs/config/custom-branding.md b/lib/docs/config/custom-branding.md
new file mode 100644
index 000000000..2a3293f2d
--- /dev/null
+++ b/lib/docs/config/custom-branding.md
@@ -0,0 +1,444 @@
+# Custom Branding
+
+LISA supports custom branding, allowing customers to tailor the user interface with specific logos and color schemes. This feature enables you to replace LISA branding with your organization's own branding while maintaining all functionality.
+
+## Overview
+
+The custom branding feature provides three key customization areas:
+
+1. **Visual Assets** - Replace logos, favicons, and login images
+2. **Display Name** - Change "LISA" brand name to your organization's product name
+3. **Theme Customization** - Modify colors, fonts, and visual styling
+
+## Configuration
+
+### Enable Custom Branding
+
+To enable custom branding, add these settings to your `config-custom.yaml`:
+
+```yaml
+useCustomBranding: true
+customDisplayName: "YourProductName"
+```
+
+### Configuration Options
+
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `useCustomBranding` | boolean | No | Enables custom branding assets (logos, favicon). Default: `false` |
+| `customDisplayName` | string | No | Replaces "LISA" brand name throughout the UI with your product name |
+
+## Custom Assets
+
+When `useCustomBranding: true` is set, LISA looks for your custom assets in the following location:
+
+```
+lib/user-interface/react/public/branding/custom/
+```
+
+### Required Asset Files
+
+Create a `custom` directory and provide these three files:
+
+| File | Format | Recommended Size | Purpose |
+|------|--------|------------------|---------|
+| `favicon.ico` | ICO | 32x32 or 16x16 | Browser tab icon |
+| `logo.svg` | SVG | Vector (scalable) | Main application logo in top navigation |
+| `login.png` | PNG | 400x400 or larger | Displayed on the login page |
+
+### Directory Structure
+
+```
+lib/user-interface/react/public/branding/
+├── base/ # Default LISA branding (don't modify)
+│ ├── favicon.ico
+│ ├── logo.svg
+│ └── login.png
+└── custom/ # Your custom branding (create this)
+ ├── favicon.ico # Your organization's favicon
+ ├── logo.svg # Your organization's logo
+ └── login.png # Your organization's login image
+```
+
+### Asset Guidelines
+
+**Favicon (`favicon.ico`)**
+- Standard browser icon format
+- Appears in browser tabs and bookmarks
+- Should be simple and recognizable at small sizes
+
+**Logo (`logo.svg`)**
+- Vector format for optimal rendering at any size
+- Used in the top navigation bar
+- Recommended: Display size: ~120-200px wide
+
+**Login Image (`login.png`)**
+- Displayed on the authentication page
+
+## Display Name Customization
+
+The `customDisplayName` setting replaces the "LISA" brand name throughout the interface, including:
+
+- Browser page titles (e.g., "LISA Chat" → "YourProduct Chat")
+- Headers and descriptions
+- Welcome messages
+
+### Example Configuration
+
+```yaml
+# config-custom.yaml
+useCustomBranding: true
+customDisplayName: "YourProductName"
+```
+
+With this configuration:
+- The page title changes from "AWS LISA AI Chat Assistant" to "YourProductName AI Chat Assistant"
+- All references to "LISA" in the UI become "YourProductName"
+- Your custom logo, favicon, and login image are used
+
+## Theme Customization
+
+Beyond assets and names, you can customize the visual theme by creating a custom theme file that overrides the default styling.
+
+### Theme File Location
+
+LISA contains two theme files:
+
+**Base Theme (Default):**
+```
+lib/user-interface/react/src/theme.ts
+```
+This file contains a minimal theme with an empty token configuration and should not be modified directly.
+
+This theme serves as a fallback if no custom theme is defined and will load the Cloudscape default theming.
+
+**Custom Theme (Optional):**
+```
+lib/user-interface/react/src/theme-custom.ts
+```
+Create this file to define your custom theme. This file is gitignored, allowing you to maintain organization-specific branding without committing it to version control.
+
+When `useCustomBranding: true` is configured, LISA will automatically:
+1. Look for `theme-custom.ts` first
+2. Fall back to `theme.ts` if the custom file doesn't exist
+3. Use Cloudscape's default theme if neither contains customizations
+
+### Customizable Theme Elements
+
+The [Cloudscape theming system](https://cloudscape.design/foundation/visual-foundation/theming/) allows you to customize various visual aspects of the default Cloudscape theme such as:
+
+**Typography**
+- Font families
+- Font sizes and weights
+
+**Colors**
+- Background colors (layout, containers, inputs)
+- Text colors (body, headings, links)
+- Button colors (primary, secondary, hover states)
+- Border colors
+- Notification/alert colors
+- Selection/highlight colors
+
+**Layout**
+- Border radius for buttons and containers
+- Spacing and padding
+- Component sizing
+
+**Context-Specific Styling**
+- Top navigation appearance
+- Dropdown menus
+- Flashbar notifications
+- Alert boxes
+
+### Theme Customization Process
+
+1. **Create Custom Theme File**
+
+ Copy the example custom theme to create your own:
+ ```bash
+ cp lib/user-interface/react/src/theme-custom.ts.example \
+ lib/user-interface/react/src/theme-custom.ts
+ ```
+
+2. **Edit Theme Variables**
+
+ Open `theme-custom.ts` and customize the theme variables at the top of the file:
+ ```typescript
+ // THEME VARIABLES - Edit these to customize the entire theme
+
+ // Typography
+ const FONT_FAMILY = 'YourFont, Arial, sans-serif';
+
+ // Colors
+ const LIGHT_BUTTON_PRIMARY_BACKGROUND = '#0066CC';
+ const LIGHT_TEXT_LINK = '#0066CC';
+ const LIGHT_TOPNAV_BACKGROUND = '#0066CC';
+ // ... customize any variables you need
+ ```
+
+3. **Configure Branding**
+
+ Enable custom branding in `config-custom.yaml`:
+ ```yaml
+ useCustomBranding: true
+ customDisplayName: "YourProductName"
+ ```
+
+4. **Test Locally (Optional)**
+
+ For local development testing:
+
+ a. Update `lib/user-interface/react/public/env.js`:
+ ```js
+ "USE_CUSTOM_BRANDING": true,
+ "CUSTOM_DISPLAY_NAME": "YourProductName"
+ ```
+
+ b. Start the development server:
+ ```bash
+ npm run dev
+ ```
+
+ c. The server will hot-reload as you edit `theme-custom.ts`
+
+5. **Deploy**
+
+ Deploy your changes:
+ ```bash
+ make deploy
+ ```
+
+> [!NOTE]
+> During development, Vite automatically detects which theme files exist and loads the appropriate one. No build configuration changes are needed.
+
+### Theme Application
+
+The theme is conditionally applied based on the `useCustomBranding` setting. LISA uses Vite's glob import feature to automatically detect and load the appropriate theme file:
+
+```typescript
+// main.tsx
+if (window.env?.USE_CUSTOM_BRANDING) {
+ try {
+ // Vite will only include files that actually exist
+ const themeModules = import.meta.glob('./theme*.ts');
+
+ // Try custom first, fall back to base
+ const themeModule = themeModules['./theme-custom.ts']
+ ? await themeModules['./theme-custom.ts']()
+ : await themeModules['./theme.ts']();
+
+ const { brandTheme } = themeModule;
+ applyTheme({ theme: brandTheme });
+ console.log('Theme loaded:', themeModules['./theme-custom.ts'] ? 'custom' : 'base');
+ } catch (error) {
+ console.warn('No theme file found, using Cloudscape default theme');
+ }
+}
+```
+
+**How it works:**
+1. When `USE_CUSTOM_BRANDING` is true, LISA scans for theme files
+2. If `theme-custom.ts` exists, it loads that file
+3. Otherwise, it falls back to `theme.ts` (the base theme)
+4. If neither file contains customizations, Cloudscape's default theme is used
+5. The console logs which theme was loaded for debugging
+
+## Implementation Details
+
+### Asset Resolution
+
+The branding system uses a utility function to determine asset paths:
+
+```typescript
+// From branding.ts
+function getBrandingPath(): string {
+ const brandingDir = window.env?.USE_CUSTOM_BRANDING ? 'custom' : 'base';
+ const baseUrl = import.meta.env.BASE_URL || '/';
+ return `${baseUrl}branding/${brandingDir}/`;
+}
+```
+
+### Display Name Resolution
+
+```typescript
+export function getDisplayName(): string {
+ const customDisplayName = window.env?.CUSTOM_DISPLAY_NAME;
+ return customDisplayName ? customDisplayName : 'LISA';
+}
+```
+
+These utilities ensure:
+- Assets are loaded from the correct directory
+- The correct display name is used throughout the application
+- Fallback to default LISA branding if custom assets are missing
+
+## Deployment Workflow
+
+### Complete Custom Branding Setup
+
+1. **Update Configuration**
+ ```yaml
+ # config-custom.yaml
+ useCustomBranding: true
+ customDisplayName: "YourProductName"
+ ```
+
+2. **Create Custom Assets Directory**
+ ```bash
+ mkdir -p lib/user-interface/react/public/branding/custom
+ ```
+
+3. **Add Your Assets**
+ ```bash
+ # Copy your branded assets
+ cp /path/to/your/favicon.ico lib/user-interface/react/public/branding/custom/
+ cp /path/to/your/logo.svg lib/user-interface/react/public/branding/custom/
+ cp /path/to/your/login.png lib/user-interface/react/public/branding/custom/
+ ```
+
+4. **Customize Theme (Optional)**
+ ```bash
+ # Create and edit theme-custom.ts with your color scheme
+ cp lib/user-interface/react/src/theme-custom.ts.example \
+ lib/user-interface/react/src/theme-custom.ts
+
+ # Edit the file to customize colors and styling
+ # lib/user-interface/react/src/theme-custom.ts
+ ```
+
+5. **Deploy**
+ ```bash
+ make deploy
+ ```
+
+### Verification
+
+After deployment, verify your branding:
+
+1. **Check Browser Tab**
+ - Should show your custom favicon
+ - Page title should use your custom display name
+
+2. **Check Navigation**
+ - Top navigation should display your custom logo
+ - Product name should appear where "LISA" previously appeared
+
+3. **Check Login Page**
+ - Should display your custom login image
+
+4. **Check Theme (if customized)**
+ - Verify colors, fonts, and styling match your specifications
+
+## Troubleshooting
+
+### Assets Not Loading
+
+**Issue**: Custom assets don't appear after deployment
+
+**Solutions**:
+- Verify files exist in `lib/user-interface/react/public/branding/custom/`
+- Check file names match exactly: `favicon.ico`, `logo.svg`, `login.png`
+- Ensure `useCustomBranding: true` in config
+- Clear browser cache and refresh
+- Check browser console for errors
+
+### Display Name Not Changing
+
+**Issue**: "LISA" still appears instead of custom name
+
+**Solutions**:
+- Verify `customDisplayName` is set in `config-custom.yaml`
+- Ensure config changes were deployed
+- Check `{LISA_URL}/{STAGE}/env.js` path for `CUSTOM_DISPLAY_NAME` and `USE_CUSTOM_BRANDING`
+- Redeploy the application
+
+### Theme Not Applied
+
+**Issue**: Custom theme colors don't appear
+
+**Solutions**:
+- Verify `useCustomBranding: true` (theme is only applied when branding is enabled)
+- Ensure `theme-custom.ts` exists in `lib/user-interface/react/src/`
+- Verify theme variables are properly defined in `theme-custom.ts`
+- Check browser console to see which theme was loaded (`custom` or `base`)
+- Rebuild the web application: `npm run build -w lisa-web`
+- Clear browser cache and hard refresh
+- Check browser console for errors
+
+**Issue**: Changes to theme-custom.ts not appearing
+
+**Solutions**:
+- Restart the development server (`npm run dev`)
+- Clear browser cache
+- Check for TypeScript errors in the theme file
+- Ensure the file is saved before refreshing
+
+### Partial Branding
+
+**Issue**: Some assets are custom, others are default
+
+**Solutions**:
+- Ensure all three asset files are present in the `custom` directory
+- Check file permissions are readable
+- Verify no typos in file names (case-sensitive on Linux)
+- Review deployment logs for asset copying errors
+
+### Theme Colors are Incorrect
+
+**Issue**: Some components are not showing the color they were configured with in `theme-custom.ts`
+
+**Solutions**:
+- Restart the development server
+- Clear browser cache
+- Change the value of the component (e.g. `#0054E3` -> `#0054E2`). Reuse of the same values can occasionally be problematic in the Cloudscape theming system.
+- Verify the correct token name is being used (refer to [Cloudscape theming docs](https://cloudscape.design/foundation/visual-foundation/theming/))
+- Check browser console for theme loading confirmation
+
+## Example: Complete Setup
+
+Here's a complete example showing all aspects of custom branding:
+
+### config-custom.yaml
+```yaml
+accountNumber: 123456789012
+region: us-east-1
+deploymentName: ACME-AI
+s3BucketModels: acme-ai-models
+
+# Custom branding configuration
+useCustomBranding: true
+customDisplayName: "Acme"
+
+# Other configuration...
+authConfig:
+ authority: https://auth.example.com
+ clientId: your-client-id
+ adminGroup: acme-admins
+ jwtGroupsProperty: cognito:groups
+```
+
+### Assets Prepared
+```
+lib/user-interface/react/public/branding/custom/
+├── favicon.ico # Acme company icon
+├── logo.svg # Acme company logo
+└── login.png # Acme branded welcome image
+```
+
+### Custom Theme
+```typescript
+// lib/user-interface/react/src/theme-custom.ts (excerpt)
+const FONT_FAMILY = 'Roboto, Arial, sans-serif';
+const LIGHT_BUTTON_PRIMARY_BACKGROUND = '#0A3D62'; // Acme blue
+const LIGHT_TEXT_LINK = '#0A3D62';
+const LIGHT_TOPNAV_BACKGROUND = '#0A3D62';
+// ... more customizations
+```
+
+### Result
+After deployment, users see:
+- Browser tab: "Acme AI Chat Assistant" with Acme favicon
+- Top navigation: Acme logo and "Acme" branding
+- Login page: Acme welcome image
+- UI theme: Acme's corporate blue color scheme
+- All text references use "Acme" instead of "LISA"
diff --git a/lib/docs/config/model-management-api.md b/lib/docs/config/model-management-api.md
index 7d12ed3b9..8c9c5f314 100644
--- a/lib/docs/config/model-management-api.md
+++ b/lib/docs/config/model-management-api.md
@@ -32,7 +32,7 @@ curl -s -H "Authorization: Bearer " -X GET https://
},
"containerConfig": {
"image": {
- "baseImage": "vllm/vllm-openai:v0.5.0",
+ "baseImage": "vllm/vllm-openai:latest",
"type": "asset"
},
"sharedMemorySize": 2048,
@@ -118,7 +118,7 @@ POST https:///models
"streaming": true,
"containerConfig": {
"image": {
- "baseImage": "vllm/vllm-openai:v0.5.0",
+ "baseImage": "vllm/vllm-openai:latest",
"type": "asset"
},
"sharedMemorySize": 2048,
diff --git a/lib/docs/docConstruct.ts b/lib/docs/docConstruct.ts
index 98a9e9070..971d26141 100644
--- a/lib/docs/docConstruct.ts
+++ b/lib/docs/docConstruct.ts
@@ -84,7 +84,7 @@ export class LisaDocsConstruct extends Construct {
// Create API Gateway
const api = new RestApi(scope, 'DocsApi', {
description: 'API Gateway for S3 hosted website',
- endpointConfiguration: { types: [EndpointType.REGIONAL] },
+ endpointTypes: [EndpointType.REGIONAL],
deployOptions: {
stageName: 'LISA',
},
diff --git a/lib/mcp/mcp-server-api.ts b/lib/mcp/mcp-server-api.ts
index b39b4894c..c6cd2ab28 100644
--- a/lib/mcp/mcp-server-api.ts
+++ b/lib/mcp/mcp-server-api.ts
@@ -31,7 +31,7 @@ import { McpServerDeployer } from './mcp-server-deployer';
import { CreateMcpServerStateMachine } from './state-machine/create-mcp-server';
import { DeleteMcpServerStateMachine } from './state-machine/delete-mcp-server';
import { UpdateMcpServerStateMachine } from './state-machine/update-mcp-server';
-import { Bucket, HttpMethods } from 'aws-cdk-lib/aws-s3';
+import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from 'aws-cdk-lib/aws-s3';
import { RemovalPolicy } from 'aws-cdk-lib';
type McpServerApiProps = {
@@ -82,6 +82,7 @@ export class McpServerApi extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
const bucketAccessLogsBucket = Bucket.fromBucketArn(scope, 'BucketAccessLogsBucket',
@@ -92,6 +93,7 @@ export class McpServerApi extends Construct {
removalPolicy: config.removalPolicy,
autoDeleteObjects: config.removalPolicy === RemovalPolicy.DESTROY,
enforceSSL: true,
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
cors: [
{
allowedMethods: [HttpMethods.GET, HttpMethods.POST],
@@ -101,7 +103,8 @@ export class McpServerApi extends Construct {
},
],
serverAccessLogsBucket: bucketAccessLogsBucket,
- serverAccessLogsPrefix: 'logs/mcp-hosting-bucket/'
+ serverAccessLogsPrefix: 'logs/mcp-hosting-bucket/',
+ encryption: BucketEncryption.S3_MANAGED
});
// Get reference to REST API first (will be reused)
diff --git a/lib/metrics/metricsConstruct.ts b/lib/metrics/metricsConstruct.ts
index d86b75499..57b1f7158 100644
--- a/lib/metrics/metricsConstruct.ts
+++ b/lib/metrics/metricsConstruct.ts
@@ -34,7 +34,7 @@ import { BaseProps } from '../schema';
import { createLambdaRole } from '../core/utils';
import { Vpc } from '../networking/vpc';
import { LAMBDA_PATH } from '../util';
-import { Duration } from 'aws-cdk-lib';
+import { Duration, RemovalPolicy } from 'aws-cdk-lib';
/**
* Properties for MetricsApi Construct.
@@ -73,6 +73,7 @@ export class MetricsConstruct extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
// Store table name in SSM for cross-stack access
diff --git a/lib/models/docker-image-builder.ts b/lib/models/docker-image-builder.ts
index 0928dcbb2..6d484d244 100644
--- a/lib/models/docker-image-builder.ts
+++ b/lib/models/docker-image-builder.ts
@@ -25,8 +25,8 @@ import {
ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Code, Function } from 'aws-cdk-lib/aws-lambda';
-import { Duration, Stack } from 'aws-cdk-lib';
-import { Bucket } from 'aws-cdk-lib/aws-s3';
+import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
+import { BlockPublicAccess, Bucket, BucketEncryption } from 'aws-cdk-lib/aws-s3';
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
import { ISecurityGroup } from 'aws-cdk-lib/aws-ec2';
import { createCdkId } from '../core/utils';
@@ -60,7 +60,12 @@ export class DockerImageBuilder extends Construct {
const ec2DockerBucket = new Bucket(this, createCdkId([stackName, 'docker-image-builder-ec2-bucket']), {
enforceSSL: true,
+ encryption: BucketEncryption.S3_MANAGED,
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
serverAccessLogsBucket: bucketAccessLogsBucket,
+ serverAccessLogsPrefix: 'logs/docker-image-builder-bucket/',
+ removalPolicy: config.removalPolicy,
+ autoDeleteObjects: config.removalPolicy === RemovalPolicy.DESTROY,
});
const ecsModelPath = ECS_MODEL_PATH;
new BucketDeployment(this, createCdkId([stackName, 'docker-image-builder-ec2-dplmnt']), {
diff --git a/lib/models/guardrails-table.ts b/lib/models/guardrails-table.ts
index d5f5a3793..391d94bb3 100644
--- a/lib/models/guardrails-table.ts
+++ b/lib/models/guardrails-table.ts
@@ -14,6 +14,7 @@
limitations under the License.
*/
+import { RemovalPolicy } from 'aws-cdk-lib';
import { AttributeType, BillingMode, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
@@ -49,6 +50,7 @@ export class GuardrailsTable extends Construct {
billingMode: BillingMode.PAY_PER_REQUEST,
encryption: TableEncryption.AWS_MANAGED,
removalPolicy: removalPolicy,
+ deletionProtection: removalPolicy !== RemovalPolicy.DESTROY,
});
this.table.addGlobalSecondaryIndex({
diff --git a/lib/models/model-api.ts b/lib/models/model-api.ts
index 1cc3cd4bd..1aa265134 100644
--- a/lib/models/model-api.ts
+++ b/lib/models/model-api.ts
@@ -33,7 +33,7 @@ import {
} from 'aws-cdk-lib/aws-iam';
import { Code, Function, LayerVersion } from 'aws-cdk-lib/aws-lambda';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
-import { CustomResource, Duration } from 'aws-cdk-lib';
+import { CustomResource, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Provider } from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs';
@@ -132,6 +132,7 @@ export class ModelsApi extends Construct {
billingMode: BillingMode.PAY_PER_REQUEST,
encryption: TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
// Create SSM parameter for model table name
diff --git a/lib/models/state-machine/create-model.ts b/lib/models/state-machine/create-model.ts
index 4f22bacb6..a9d4718ef 100644
--- a/lib/models/state-machine/create-model.ts
+++ b/lib/models/state-machine/create-model.ts
@@ -197,6 +197,29 @@ export class CreateModelStateMachine extends Construct {
time: POLLING_TIMEOUT,
});
+ const pollModelReady = new LambdaInvoke(this, 'PollModelReady', {
+ lambdaFunction: new Function(this, 'PollModelReadyFunc', {
+ runtime: getPythonRuntime(),
+ handler: 'models.state_machine.create_model.handle_poll_model_ready',
+ code: Code.fromAsset(lambdaPath),
+ timeout: LAMBDA_TIMEOUT,
+ memorySize: LAMBDA_MEMORY,
+ role: role,
+ vpc: vpc.vpc,
+ vpcSubnets: vpc.subnetSelection,
+ securityGroups: securityGroups,
+ layers: lambdaLayers,
+ environment: environment,
+ }),
+ outputPath: OUTPUT_PATH,
+ });
+
+ const pollModelReadyChoice = new Choice(this, 'PollModelReadyChoice');
+
+ const waitBeforePollingModelReady = new Wait(this, 'WaitBeforePollingModelReady', {
+ time: POLLING_TIMEOUT,
+ });
+
const createSchedule = new LambdaInvoke(this, 'CreateSchedule', {
lambdaFunction: new Function(this, 'CreateScheduleFunc', {
runtime: getPythonRuntime(),
@@ -299,10 +322,17 @@ export class CreateModelStateMachine extends Construct {
});
pollCreateStackChoice
.when(Condition.booleanEquals('$.continue_polling_stack', true), waitBeforePollingCreateStack)
- .otherwise(createSchedule);
+ .otherwise(pollModelReady);
waitBeforePollingCreateStack.next(pollCreateStack);
- // Create schedule after stack is created
+ // Poll for model instances to be healthy before proceeding
+ pollModelReady.next(pollModelReadyChoice);
+ pollModelReadyChoice
+ .when(Condition.booleanEquals('$.continue_polling_capacity', true), waitBeforePollingModelReady)
+ .otherwise(createSchedule);
+ waitBeforePollingModelReady.next(pollModelReady);
+
+ // Create schedule after model is ready
createSchedule.next(addModelToLitellm);
// Check for guardrails and add them if present
diff --git a/lib/rag/ingestion/ingestion-image/Dockerfile b/lib/rag/ingestion/ingestion-image/Dockerfile
index 0c7c18c05..9fad16343 100644
--- a/lib/rag/ingestion/ingestion-image/Dockerfile
+++ b/lib/rag/ingestion/ingestion-image/Dockerfile
@@ -1,6 +1,22 @@
ARG BASE_IMAGE=public.ecr.aws/lambda/python:3.13
FROM ${BASE_IMAGE}
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
ARG BUILD_DIR=build
WORKDIR /workdir
diff --git a/lib/rag/ingestion/ingestion-image/requirements.txt b/lib/rag/ingestion/ingestion-image/requirements.txt
index e89238acc..0fd25a55d 100644
--- a/lib/rag/ingestion/ingestion-image/requirements.txt
+++ b/lib/rag/ingestion/ingestion-image/requirements.txt
@@ -4,11 +4,9 @@
# NumPy 2.x has pre-built wheels for Python 3.13
numpy>=2.1.0
-# AWS SDK - Version constrained by litellm[proxy]==1.80.9 in rest-api
-# Standardized to boto3==1.36.0 for compatibility across all components
-aioboto3==13.4.0
-aiobotocore==2.18.0
-boto3==1.36.0
+# AWS SDK - Version constrained by litellm[proxy]==1.81.3 in rest-api
+# Standardized to boto3==1.40.76 for compatibility across all components
+boto3==1.40.76
aiohttp==3.13.2
click==8.3.1
@@ -17,8 +15,8 @@ fastapi_utils==0.8.0
fastapi==0.124.2
gunicorn==23.0.0
langchain-community==0.4.1
-langchain-core==1.1.3
-langchain-text-splitters==1.0.0
+langchain-core==1.2.7
+langchain-text-splitters==1.1.0
loguru==0.7.3
mangum==0.19.0
opensearch-py==3.1.0
@@ -34,6 +32,6 @@ python-docx==1.2.0
requests-aws4auth==1.3.1
requests==2.32.5
text-generation==0.7.0
-# ASGI Server - Version constrained by litellm[proxy]==1.80.9 in rest-api
+# ASGI Server - Version constrained by litellm[proxy]==1.81.3 in rest-api
# Standardized to 0.38.0 for compatibility across all components
uvicorn==0.38.0
diff --git a/lib/rag/ingestion/ingestion-job-construct.ts b/lib/rag/ingestion/ingestion-job-construct.ts
index eed54da41..4a546c1fa 100644
--- a/lib/rag/ingestion/ingestion-job-construct.ts
+++ b/lib/rag/ingestion/ingestion-job-construct.ts
@@ -20,7 +20,7 @@
* - AWS Batch compute environment and job queue for processing documents
* - Lambda functions for handling scheduled ingestion and S3 events
*/
-import { Duration, Size, StackProps } from 'aws-cdk-lib';
+import { Duration, RemovalPolicy, Size, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { BaseProps, EcsSourceType } from '../../schema';
import * as logs from 'aws-cdk-lib/aws-logs';
@@ -75,7 +75,8 @@ export class IngestionJobConstruct extends Construct {
type: dynamodb.AttributeType.STRING
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
- removalPolicy: config.removalPolicy
+ removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
// GSI for querying by document ID
diff --git a/lib/rag/layer/requirements.txt b/lib/rag/layer/requirements.txt
index 0569dd077..8906929e6 100644
--- a/lib/rag/layer/requirements.txt
+++ b/lib/rag/layer/requirements.txt
@@ -1,22 +1,33 @@
-# Container-based Lambda - no size constraints!
-# All packages can be included since container images have 10GB limit
+# RAG Lambda Layer - Slimmed down for Lambda 250MB limit
+# Document processing dependencies moved to container-based batch ingestion:
+# - pypdf, lxml, python-docx (document parsing)
+# - tiktoken (token counting)
+# - langchain-text-splitters (chunking)
+#
+# This layer is used by Lambda functions for:
+# - Repository/Collection CRUD operations
+# - Similarity search (OpenSearch, PGVector)
+# - Ingestion job management (submitting to Batch, not processing)
+#
+# Heavy document processing happens in container: lib/rag/ingestion/ingestion-image/
-# Core RAG packages
-# psycopg2-binary==2.9.11 // provided by Common Layer
-langchain-text-splitters==1.0.0
+# Core langchain package for vector store base classes
+langchain-core==1.2.7
+
+# langchain-community for OpenSearchVectorSearch and PGVector classes
+# This package has many optional dependencies - we only need the core vectorstore functionality
langchain-community==0.4.1
-langchain-core==1.1.3
-# Required by langchain-community
-# Python 3.13+ requires numpy>=2.1.0
-numpy>=2.1.0
-# Database and search connectors
+# Database and search connectors (required for similarity search)
opensearch-py==3.1.0
pgvector==0.4.2
-pypdf==6.4.1
-lxml==6.0.2
-python-docx==1.2.0
requests-aws4auth==1.3.1
-tiktoken==0.12.0
+
+# Required by langchain-community for vector operations
+# NumPy 2.x has pre-built wheels - pin to specific version to avoid source builds
+numpy==2.1.0
+
+# psycopg2-binary provided by Common Layer
+# boto3/botocore provided by Lambda runtime
urllib3==2.6.1
diff --git a/lib/rag/ragConstruct.ts b/lib/rag/ragConstruct.ts
index c84075416..263c5b114 100644
--- a/lib/rag/ragConstruct.ts
+++ b/lib/rag/ragConstruct.ts
@@ -13,11 +13,11 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
-import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
+import { CfnOutput, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { IAuthorizer } from 'aws-cdk-lib/aws-apigateway';
-import { ISecurityGroup, IVpc, SubnetSelection } from 'aws-cdk-lib/aws-ec2';
-import { Code, Function, IFunction, ILayerVersion, LayerVersion } from 'aws-cdk-lib/aws-lambda';
-import { Bucket, HttpMethods } from 'aws-cdk-lib/aws-s3';
+import { ISecurityGroup } from 'aws-cdk-lib/aws-ec2';
+import { ILayerVersion, LayerVersion } from 'aws-cdk-lib/aws-lambda';
+import { BlockPublicAccess, Bucket, BucketEncryption, HttpMethods } from 'aws-cdk-lib/aws-s3';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { AttributeType, BillingMode, StreamViewType, Table, TableEncryption } from 'aws-cdk-lib/aws-dynamodb';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
@@ -28,12 +28,12 @@ import { ARCHITECTURE } from '../core';
import { Layer } from '../core/layers';
import { createCdkId } from '../core/utils';
import { Vpc } from '../networking/vpc';
-import { APP_MANAGEMENT_KEY, BaseProps, Config, RDSConfig } from '../schema';
+import { APP_MANAGEMENT_KEY, BaseProps, Config } from '../schema';
import { SecurityGroupEnum } from '../core/iam/SecurityGroups';
import { SecurityGroupFactory } from '../networking/vpc/security-group-factory';
import { Roles } from '../core/iam/roles';
import { VectorStoreCreatorStack as VectorStoreCreator } from './vector-store/vector-store-creator';
-import { AnyPrincipal, CfnServiceLinkedRole, Effect, IRole, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
+import { AnyPrincipal, CfnServiceLinkedRole, Effect, IRole, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam';
import { IAMClient, ListRolesCommand } from '@aws-sdk/client-iam';
import { RagRepositoryConfig, RagRepositoryType } from '../schema';
import { Domain, EngineVersion, IDomain } from 'aws-cdk-lib/aws-opensearchservice';
@@ -41,13 +41,10 @@ import { ISecret, Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { Credentials, DatabaseInstance, DatabaseInstanceEngine } from 'aws-cdk-lib/aws-rds';
import { LegacyIngestPipelineStateMachine } from './state_machine/legacy-ingest-pipeline';
import * as customResources from 'aws-cdk-lib/custom-resources';
-import DynamoDB from 'aws-sdk/clients/dynamodb';
+import { marshall } from '@aws-sdk/util-dynamodb';
import * as readlineSync from 'readline-sync';
-import { LAMBDA_PATH, RAG_LAYER_PATH } from '../util';
+import { RAG_LAYER_PATH } from '../util';
import { IngestionStack } from './ingestion/ingestion-stack';
-import * as child_process from 'child_process';
-import * as path from 'path';
-import { getPythonRuntime } from '../api-base/utils';
import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
export type LisaRagProps = {
@@ -107,7 +104,9 @@ export class LisaRagConstruct extends Construct {
},
],
serverAccessLogsBucket: bucketAccessLogsBucket,
- serverAccessLogsPrefix: 'logs/rag-bucket/'
+ serverAccessLogsPrefix: 'logs/rag-bucket/',
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
+ encryption: BucketEncryption.S3_MANAGED
});
const ragTableName = createCdkId([config.deploymentName, 'RagDocumentTable']);
@@ -123,6 +122,7 @@ export class LisaRagConstruct extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
docMetaTable.addGlobalSecondaryIndex({
indexName: 'document_index',
@@ -150,6 +150,7 @@ export class LisaRagConstruct extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
// Create Collections table
@@ -166,6 +167,7 @@ export class LisaRagConstruct extends Construct {
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
encryption: dynamodb.TableEncryption.AWS_MANAGED,
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
timeToLiveAttribute: 'ttl',
});
@@ -254,21 +256,8 @@ export class LisaRagConstruct extends Construct {
StringParameter.valueForStringParameter(scope, `${config.deploymentPrefix}/layerVersion/common`),
);
- // Pre-generate the tiktoken cache to ensure it does not attempt to fetch data from the internet at runtime.
- if (config.restApiConfig.imageConfig === undefined) {
- const cache_dir = path.join(RAG_LAYER_PATH, 'TIKTOKEN_CACHE');
- // Skip tiktoken cache generation in test environment
- if (process.env.NODE_ENV !== 'test') {
- try {
- child_process.execSync(`python3 scripts/cache-tiktoken-for-offline.py ${cache_dir}`, { stdio: 'inherit' });
- } catch (error) {
- console.warn('Failed to generate tiktoken cache:', error);
- // Continue execution even if cache generation fails
- }
- }
- }
-
// Build RAG Lambda layer
+ // Note: tiktoken and document processing deps moved to container-based batch ingestion
const ragLambdaLayer = new Layer(scope, 'RagLayer', {
config: config,
path: RAG_LAYER_PATH,
@@ -276,9 +265,6 @@ export class LisaRagConstruct extends Construct {
architecture: ARCHITECTURE,
autoUpgrade: true,
assetPath: config.lambdaLayerAssets?.ragLayerPath,
- afterBundle: (inputDir: string, outputDir: string) => [
- `cp -r ${inputDir}/TIKTOKEN_CACHE/* ${outputDir}/TIKTOKEN_CACHE/`
- ],
});
new StringParameter(scope, createCdkId([config.deploymentName, config.deploymentStage, 'RagLayer']), {
@@ -288,20 +274,6 @@ export class LisaRagConstruct extends Construct {
const layers = [commonLambdaLayer, ragLambdaLayer.layer];
- // Pre-generate the tiktoken cache to ensure it does not attempt to fetch data from the internet at runtime.
- if (config.restApiConfig.imageConfig === undefined) {
- const cache_dir = path.join(RAG_LAYER_PATH, 'TIKTOKEN_CACHE');
- // Skip tiktoken cache generation in test environment
- if (process.env.NODE_ENV !== 'test') {
- try {
- child_process.execSync(`python3 scripts/cache-tiktoken-for-offline.py ${cache_dir}`, { stdio: 'inherit' });
- } catch (error) {
- console.warn('Failed to generate tiktoken cache:', error);
- // Continue execution even if cache generation fails
- }
- }
- }
-
// create a security group for opensearch
const openSearchSg = SecurityGroupFactory.createSecurityGroup(
scope,
@@ -351,8 +323,8 @@ export class LisaRagConstruct extends Construct {
type: AttributeType.STRING
},
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
billingMode: BillingMode.PAY_PER_REQUEST,
- pointInTimeRecovery: true,
timeToLiveAttribute: 'ttl',
stream: StreamViewType.NEW_AND_OLD_IMAGES,
encryption: TableEncryption.AWS_MANAGED,
@@ -363,14 +335,14 @@ export class LisaRagConstruct extends Construct {
value: ragRepositoryConfigTable.tableArn
});
- // Create SSM parameter for vector store table name so other stacks can optionally reference it
+ // Create SSM parameter for vector store table name so other stacks can optionally reference it.
new StringParameter(scope, createCdkId(['RagVectorStoreTableName', 'Parameter']), {
parameterName: `${config.deploymentPrefix}/ragVectorStoreTableName`,
stringValue: ragRepositoryConfigTable.tableName,
description: 'RAG Vector Store (Repository Config) DynamoDB table name',
});
- // Create SSM parameter for collections table name so other stacks can optionally reference it
+ // Create SSM parameter for collections table name so other stacks can optionally reference it.
new StringParameter(scope, createCdkId(['RagCollectionsTableName', 'Parameter']), {
parameterName: `${config.deploymentPrefix}/ragCollectionsTableName`,
stringValue: collectionsTable.tableName,
@@ -508,7 +480,7 @@ export class LisaRagConstruct extends Construct {
principals: [new AnyPrincipal()],
}),
],
- removalPolicy: RemovalPolicy.DESTROY,
+ removalPolicy: config.removalPolicy,
securityGroups: [securityGroups.opensearch],
});
}
@@ -537,42 +509,47 @@ export class LisaRagConstruct extends Construct {
openSearchEndpointPs.node.addDependency(openSearchDomain);
openSearchEndpointPs.grantRead(lambdaRole);
} else if (ragConfig.type === RagRepositoryType.PGVECTOR && ragConfig.rdsConfig) {
- let rdsPasswordSecret: ISecret;
+ // Determine authentication method - default to IAM auth (iamRdsAuth = false)
+ const useIamAuth = config.iamRdsAuth ?? false;
+
+ let rdsSecret: ISecret;
let rdsConnectionInfoPs: StringParameter;
- // if dbHost and passwordSecretId are defined, then connect to DB with existing params
- if (!!ragConfig.rdsConfig.dbHost && !!ragConfig.rdsConfig.passwordSecretId) {
+ let pgvector_db: DatabaseInstance | undefined;
+
+ // if dbHost and passwordSecretId are defined, connect to existing DB
+ if (ragConfig.rdsConfig.dbHost && ragConfig.rdsConfig.passwordSecretId) {
rdsConnectionInfoPs = new StringParameter(this.scope, createCdkId([connectionParamName, ragConfig.repositoryId, 'StringParameter']), {
parameterName: `${config.deploymentPrefix}/${connectionParamName}/${ragConfig.repositoryId}`,
stringValue: JSON.stringify({
- ...(config.iamRdsAuth ? {} : {
- passwordSecretId: ragConfig.rdsConfig?.passwordSecretId
- }),
username: ragConfig.rdsConfig?.username,
dbHost: ragConfig.rdsConfig?.dbHost,
dbName: ragConfig.rdsConfig?.dbName,
dbPort: ragConfig.rdsConfig?.dbPort,
- type: RagRepositoryType.PGVECTOR
+ type: RagRepositoryType.PGVECTOR,
+ // Include passwordSecretId only when using password auth
+ ...(!useIamAuth ? { passwordSecretId: ragConfig.rdsConfig?.passwordSecretId } : {})
}),
description: 'Connection info for LISA Serve PGVector database',
});
- rdsPasswordSecret = Secret.fromSecretNameV2(
+ rdsSecret = Secret.fromSecretNameV2(
this.scope,
- createCdkId([config.deploymentName, ragConfig.repositoryId, 'RagRDSPwdSecret']),
+ createCdkId([config.deploymentName, ragConfig.repositoryId, 'RagRDSSecret']),
ragConfig.rdsConfig.passwordSecretId,
);
} else {
const username = ragConfig.rdsConfig.username;
const dbCreds = Credentials.fromGeneratedSecret(username);
- const pgvector_db = new DatabaseInstance(this.scope, createCdkId([ragConfig.repositoryId, 'PGVectorDB']), {
+ pgvector_db = new DatabaseInstance(this.scope, createCdkId([ragConfig.repositoryId, 'PGVectorDB']), {
engine: DatabaseInstanceEngine.POSTGRES,
vpc: vpc.vpc,
subnetGroup: vpc.subnetGroup,
credentials: dbCreds,
- iamAuthentication: true,
+ iamAuthentication: useIamAuth, // Enable IAM auth when iamRdsAuth is false
securityGroups: [securityGroups.pgvector],
- removalPolicy: RemovalPolicy.DESTROY,
+ removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
- rdsPasswordSecret = pgvector_db.secret!;
+ rdsSecret = pgvector_db.secret!;
rdsConnectionInfoPs = new StringParameter(this.scope, createCdkId([connectionParamName, ragConfig.repositoryId, 'StringParameter']), {
parameterName: `${config.deploymentPrefix}/${connectionParamName}/${ragConfig.repositoryId}`,
stringValue: JSON.stringify({
@@ -581,53 +558,98 @@ export class LisaRagConstruct extends Construct {
dbPort: ragConfig.rdsConfig.dbPort,
type: RagRepositoryType.PGVECTOR,
username: username,
- ...(config.iamRdsAuth ? {} : { passwordSecretId: rdsPasswordSecret.secretName }),
+ // Include passwordSecretId only when using password auth
+ ...(!useIamAuth ? { passwordSecretId: rdsSecret.secretName } : {})
}),
description: 'Connection info for LISA Serve PGVector database',
});
- if (config.iamRdsAuth) {
- // grant the role permissions to connect as the IAM role itself
- pgvector_db.grantConnect(lambdaRole, lambdaRole.roleName);
+ if (!useIamAuth) {
+ // Password auth: secret read access granted below (grantConnect requires IAM auth)
} else {
- // grant the role permissions to connect as the postgres user
- pgvector_db.grantConnect(lambdaRole);
+ // IAM auth: manually grant rds-db:connect permission
+ // Note: We do NOT use pgvector_db.grantConnect() due to CDK bug #11851
+ // The grantConnect method generates incorrect ARN format (uses rds: instead of rds-db:)
+ // Per AWS docs: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html
+ // The correct format is: arn:aws:rds-db:region:account-id:dbuser:DbiResourceId/db-user-name
+ lambdaRole.addToPrincipalPolicy(new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['rds-db:connect'],
+ resources: [
+ // Use wildcard for DbiResourceId since it's not available in CloudFormation
+ // Format: arn:aws:rds-db:region:account:dbuser:*/username
+ `arn:${config.partition}:rds-db:${config.region}:${config.accountNumber}:dbuser:*/${lambdaRole.roleName}`
+ ]
+ }));
}
+
+ // Update ragConfig with the endpoint address for use in AwsCustomResource
+ ragConfig.rdsConfig.dbHost = pgvector_db.dbInstanceEndpointAddress;
}
- if (config.iamRdsAuth) {
- // Create the lambda for generating DB users for IAM auth
- const createDbUserLambda = this.getIAMAuthLambda(config, ragConfig.repositoryId, ragConfig.rdsConfig!, rdsPasswordSecret, lambdaRole.roleName, vpc.vpc, [securityGroups.pgvector], vpc.subnetSelection);
+ if (!useIamAuth) {
+ // Password auth: grant secret read access
+ rdsSecret.grantRead(lambdaRole);
+ } else {
+ // Use the shared IAM auth setup Lambda from API Base stack
+ const iamAuthSetupFnArn = StringParameter.valueForStringParameter(
+ this.scope,
+ `${config.deploymentPrefix}/iamAuthSetupFnArn`
+ );
- const customResourceRole = new Role(this.scope, createCdkId(['CustomResourceRole', ragConfig.repositoryId]), {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- });
- createDbUserLambda.grantInvoke(customResourceRole);
-
- // run updateInstanceKmsConditionsLambda every deploy
- new AwsCustomResource(this.scope, createCdkId([ragConfig.repositoryId, 'CreateDbUserCustomResource']), {
- onCreate: {
- service: 'Lambda',
- action: 'invoke',
- physicalResourceId: PhysicalResourceId.of(createCdkId([ragConfig.repositoryId, 'CreateDbUserCustomResource'])),
- parameters: {
- FunctionName: createDbUserLambda.functionName,
- Payload: '{}'
- },
- },
- onUpdate: {
- service: 'Lambda',
- action: 'invoke',
- physicalResourceId: PhysicalResourceId.of(createCdkId([ragConfig.repositoryId, 'CreateDbUserCustomResource'])),
- parameters: {
- FunctionName: createDbUserLambda.functionName,
- Payload: '{}'
- },
+ // Get the IAM auth setup Lambda role ARN from SSM to grant it permissions
+ const iamAuthSetupRoleArn = StringParameter.valueForStringParameter(
+ this.scope,
+ `${config.deploymentPrefix}/iamAuthSetupRoleArn`
+ );
+
+ // Import the IAM auth setup role to grant it secret permissions
+ const iamAuthSetupRole = Role.fromRoleArn(
+ this.scope,
+ createCdkId([ragConfig.repositoryId, 'IamAuthSetupRoleRef']),
+ iamAuthSetupRoleArn
+ );
+
+ // Grant the IAM auth setup Lambda role permission to read the bootstrap secret
+ rdsSecret.grantRead(iamAuthSetupRole);
+
+ // Run the shared IAM auth setup Lambda on create and update
+ // Pass parameters via payload since the Lambda is shared
+ // Use Stack.of(this.scope).toJsonString() to properly resolve CDK tokens in the payload
+ const lambdaInvokeParams = {
+ service: 'Lambda',
+ action: 'invoke',
+ physicalResourceId: PhysicalResourceId.of(createCdkId([ragConfig.repositoryId, 'CreateDbUserCustomResource'])),
+ parameters: {
+ FunctionName: iamAuthSetupFnArn,
+ Payload: Stack.of(this.scope).toJsonString({
+ secretArn: rdsSecret.secretArn,
+ dbHost: ragConfig.rdsConfig!.dbHost,
+ dbPort: ragConfig.rdsConfig!.dbPort,
+ dbName: ragConfig.rdsConfig!.dbName,
+ dbUser: ragConfig.rdsConfig!.username,
+ iamName: lambdaRole.roleName,
+ })
},
- role: customResourceRole
+ };
+
+ const createDbUserResource = new AwsCustomResource(this.scope, createCdkId([ragConfig.repositoryId, 'CreateDbUserCustomResource']), {
+ onCreate: lambdaInvokeParams,
+ onUpdate: lambdaInvokeParams, // Also run on updates to ensure IAM user is created
+ policy: customResources.AwsCustomResourcePolicy.fromStatements([
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['lambda:InvokeFunction'],
+ resources: [iamAuthSetupFnArn],
+ })
+ ]),
});
- } else {
- rdsPasswordSecret.grantRead(lambdaRole);
+
+ // Ensure the RDS instance is fully available before running IAM auth setup
+ // (only when we created a new RDS instance)
+ if (pgvector_db) {
+ createDbUserResource.node.addDependency(pgvector_db);
+ }
}
rdsConnectionInfoPs.grantRead(lambdaRole);
@@ -639,8 +661,8 @@ export class LisaRagConstruct extends Construct {
const createOrUpdateParameters = {
TableName: ragRepositoryConfigTable.tableName,
- Item: this.toDynamoDBItem({
- repositoryId: ragConfig.repositoryId, // Partition key value
+ Item: marshall({
+ repositoryId: ragConfig.repositoryId,
status: 'CREATE_COMPLETE',
config: ragConfig,
legacy: true
@@ -665,7 +687,7 @@ export class LisaRagConstruct extends Construct {
action: 'deleteItem',
parameters: {
TableName: ragRepositoryConfigTable.tableName,
- Key: this.toDynamoDBItem({ repositoryId: ragConfig.repositoryId }),
+ Key: marshall({ repositoryId: ragConfig.repositoryId }),
},
},
policy: customResources.AwsCustomResourcePolicy.fromSdkCalls({
@@ -721,79 +743,6 @@ export class LisaRagConstruct extends Construct {
}
}
- getIAMAuthLambda (config: Config, repositoryId: string, rdsConfig: NonNullable, secret: ISecret, user: string, vpc: IVpc, securityGroups: ISecurityGroup[], vpcSubnets?: SubnetSelection): IFunction {
- // Create the IAM role for updating the database to allow IAM authentication
- const iamAuthLambdaRole = new Role(this.scope, createCdkId([repositoryId, 'IAMAuthLambdaRole']), {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- inlinePolicies: {
- 'EC2NetworkInterfaces': new PolicyDocument({
- statements: [
- new PolicyStatement({
- effect: Effect.ALLOW,
- actions: ['ec2:CreateNetworkInterface', 'ec2:DescribeNetworkInterfaces', 'ec2:DeleteNetworkInterface'],
- resources: ['*'],
- }),
- ],
- }),
- },
- });
-
- secret.grantRead(iamAuthLambdaRole);
-
- const commonLayer = this.getLambdaLayer(repositoryId, config);
- const lambdaPath = config.lambdaPath || LAMBDA_PATH;
-
- return new Function(this.scope, createCdkId([repositoryId, 'CreateDbUserLambda']), {
- runtime: getPythonRuntime(),
- handler: 'utilities.db_setup_iam_auth.handler',
- code: Code.fromAsset(lambdaPath),
- timeout: Duration.minutes(2),
- environment: {
- SECRET_ARN: secret.secretArn, // ARN of the RDS secret
- DB_HOST: rdsConfig.dbHost!,
- DB_PORT: String(rdsConfig.dbPort), // Default PostgreSQL port
- DB_NAME: rdsConfig.dbName, // Database name
- DB_USER: rdsConfig.username, // Admin user for RDS
- IAM_NAME: user, // IAM role for Lambda execution
- },
- role: iamAuthLambdaRole, // Lambda execution role
- layers: [commonLayer],
- vpc,
- securityGroups,
- vpcSubnets
- });
- }
-
- getLambdaLayer (repositoryId: string, config: Config): ILayerVersion {
- return LayerVersion.fromLayerVersionArn(
- this.scope,
- createCdkId([repositoryId, 'CommonLayerVersion']),
- StringParameter.valueForStringParameter(this.scope, `${config.deploymentPrefix}/layerVersion/common`),
- );
- }
-
- toDynamoDBItem (obj: Record): DynamoDB.PutItemInputAttributeMap {
- const dynamoItem: DynamoDB.PutItemInputAttributeMap = {};
-
- for (const [key, value] of Object.entries(obj)) {
- if (typeof value === 'string') {
- dynamoItem[key] = { S: value };
- } else if (typeof value === 'number') {
- dynamoItem[key] = { N: value.toString() };
- } else if (typeof value === 'boolean') {
- dynamoItem[key] = { BOOL: value };
- } else if (Array.isArray(value)) {
- dynamoItem[key] = { L: value.map((item) => this.toDynamoDBItem({ item })['item']) };
- } else if (typeof value === 'object' && value !== null) {
- dynamoItem[key] = { M: this.toDynamoDBItem(value) };
- } else if (value === null) {
- dynamoItem[key] = { NULL: true };
- }
- }
-
- return dynamoItem;
- }
-
/**
* This method links the OpenSearch Service role to the service-linked role if it exists.
* If the role doesn't exist, it will be created.
diff --git a/lib/rag/vector-store/state_machine/create-store.ts b/lib/rag/vector-store/state_machine/create-store.ts
index 185fb5ab7..2c78f227c 100644
--- a/lib/rag/vector-store/state_machine/create-store.ts
+++ b/lib/rag/vector-store/state_machine/create-store.ts
@@ -126,6 +126,15 @@ export class CreateStoreStateMachine extends Construct {
},
});
+ // Fail state to mark the state machine execution as failed
+ const failExecution = new sfn.Fail(this, 'FailExecution', {
+ cause: 'Vector store deployment failed',
+ error: 'DeploymentFailed',
+ });
+
+ // Chain failure status update to fail state
+ updateFailureStatus.next(failExecution);
+
// Check if this is a Bedrock KB repository to create default collections
const skipCollectionCreation = new sfn.Pass(this, 'SkipCollectionCreation');
diff --git a/lib/rag/vector-store/state_machine/delete-store.ts b/lib/rag/vector-store/state_machine/delete-store.ts
index 5d6419baa..9a21fb3bf 100644
--- a/lib/rag/vector-store/state_machine/delete-store.ts
+++ b/lib/rag/vector-store/state_machine/delete-store.ts
@@ -113,6 +113,15 @@ export class DeleteStoreStateMachine extends Construct {
':status': tasks.DynamoAttributeValue.fromString('$.checkResult.status'),
},
});
+
+ // Fail state to mark the state machine execution as failed
+ const failExecution = new sfn.Fail(this, 'FailExecution', {
+ cause: 'Vector store deletion failed',
+ error: 'DeletionFailed',
+ });
+
+ // Chain failure status update to fail state
+ updateFailureStatus.next(failExecution);
// Task to update the status of the vector store entry to 'COMPLETED' on successful deployment
const updateDeleteStatus = new tasks.DynamoUpdateItem(this, 'UpdateDeleteStatus', {
table: ragVectorStoreTable,
diff --git a/lib/rag/vector-store/vector-store-creator.ts b/lib/rag/vector-store/vector-store-creator.ts
index ffd4c3ce3..b5ea338f9 100644
--- a/lib/rag/vector-store/vector-store-creator.ts
+++ b/lib/rag/vector-store/vector-store-creator.ts
@@ -88,24 +88,66 @@ export class VectorStoreCreatorStack extends Construct {
}));
// IAM: manage roles created within the dynamic stacks and allow passing to services
+ //
+ // Security Strategy (Findings #1, #8, #13 - IAM Privilege Escalation Prevention):
+ //
+ // 1. Self-Targeting Prevention: Use ArnNotEquals condition to prevent the VectorStoreCreator
+ // role from modifying itself via AttachRolePolicy, DetachRolePolicy, PutRolePolicy, or
+ // DeleteRolePolicy actions. This prevents privilege escalation where the role could grant
+ // itself additional permissions.
+ //
+ // 2. Resource Pattern Restriction: Limit role creation and management actions to roles that
+ // follow the vector store naming pattern. This ensures the role can only manage roles
+ // created by the vector store deployer, not arbitrary IAM roles in the account.
+ //
+ // 3. CDK Bootstrap Role Assumption: AssumeRole is restricted to CDK bootstrap roles only,
+ // preventing assumption of roles with privilege escalation risks.
+ //
+ // Note: Tag-based conditions were considered but not used due to lack of support in all
+ // AWS regions. ARN pattern matching provides equivalent security with broader compatibility.
+ //
+ // Restrict permission mutation actions to prevent self-targeting (Security Finding #1, #8, #13)
cdkRole.addToPolicy(new iam.PolicyStatement({
actions: [
- 'iam:CreateRole',
- 'iam:DeleteRole',
'iam:AttachRolePolicy',
'iam:DetachRolePolicy',
'iam:PutRolePolicy',
'iam:DeleteRolePolicy',
- 'iam:TagRole',
- 'iam:UntagRole',
+ ],
+ resources: ['*'],
+ conditions: {
+ ArnNotEquals: {
+ // Prevent the role from modifying itself
+ 'iam:ResourceArn': cdkRole.roleArn
+ }
+ }
+ }));
+
+ // IAM: manage roles created by vector store deployer (restricted to naming pattern)
+ cdkRole.addToPolicy(new iam.PolicyStatement({
+ actions: [
+ 'iam:CreateRole',
+ 'iam:DeleteRole',
'iam:GetRole',
'iam:GetRolePolicy',
'iam:ListRolePolicies',
'iam:ListAttachedRolePolicies',
- 'iam:ListRoleTags',
+ 'iam:TagRole',
+ 'iam:UntagRole',
'iam:UpdateAssumeRolePolicy',
- 'iam:ListRoles'
+ 'iam:ListRoleTags',
+ ],
+ resources: [
+ // Roles created by vector store deployer follow this pattern:
+ // ${appName}-${deploymentName}-${deploymentStage}-vector-store-${repositoryId}-*
+ `arn:${config.partition}:iam::${config.accountNumber}:role/${config.appName}-${config.deploymentName}-${config.deploymentStage}-vector*`,
+ `arn:${config.partition}:iam::${config.accountNumber}:role/${config.deploymentName}-${config.appName}-${config.deploymentStage}-vector*`,
],
+ }));
+
+ // IAM: ListRoles requires wildcard resource (read-only operation)
+ cdkRole.addToPolicy(new iam.PolicyStatement({
+ actions: ['iam:ListRoles'],
resources: ['*'],
}));
@@ -146,6 +188,7 @@ export class VectorStoreCreatorStack extends Construct {
'deploymentName',
'deploymentStage',
'deploymentPrefix',
+ 'iamRdsAuth',
'partition',
'region',
'removalPolicy',
diff --git a/lib/schema/configSchema.ts b/lib/schema/configSchema.ts
index 9fea711f8..b7d2c1113 100644
--- a/lib/schema/configSchema.ts
+++ b/lib/schema/configSchema.ts
@@ -43,6 +43,7 @@ export type SecurityGroups = {
export enum ModelType {
TEXTGEN = 'textgen',
EMBEDDING = 'embedding',
+ VIDEOGEN = 'videogen',
}
/**
@@ -403,12 +404,15 @@ export const VALID_INSTANCE_KEYS = Ec2Metadata.getValidInstanceKeys() as [string
const ContainerHealthCheckConfigSchema = z.object({
command: z.array(z.string()).default(['CMD-SHELL', 'exit 0']).describe('The command to run for health checks'),
interval: z.number().default(10).describe('The time interval between health checks, in seconds.'),
- startPeriod: z.number().default(30).describe('The time to wait before starting the first health check, in seconds.'),
+ startPeriod: z.number().default(300).describe('The time to wait before starting the first health check, in seconds. Default 600s (10 min) to allow for large model loading.'),
timeout: z.number().default(5).describe('The maximum time allowed for each health check to complete, in seconds'),
- retries: z.number().default(2).describe('The number of times to retry a failed health check before considering the container as unhealthy.'),
+ retries: z.number().default(3).describe('The number of times to retry a failed health check before considering the container as unhealthy.'),
})
.describe('Configuration for container health checks');
+export { ContainerHealthCheckConfigSchema };
+export type ContainerHealthCheckConfig = z.infer;
+
export const ImageTarballAsset = z.object({
path: z.string(),
type: z.literal(EcsSourceType.TARBALL)
@@ -467,7 +471,7 @@ export const ContainerConfigSchema = z.object({
export type ContainerConfig = z.infer;
-const HealthCheckConfigSchema = z.object({
+export const LoadBalancerHealthCheckConfigSchema = z.object({
path: z.string().describe('Path for the health check.'),
interval: z.number().default(30).describe('Interval in seconds between health checks.'),
timeout: z.number().default(10).describe('Timeout in seconds for each health check.'),
@@ -476,16 +480,18 @@ const HealthCheckConfigSchema = z.object({
})
.describe('Health check configuration for the load balancer.');
+export type LoadBalancerHealthCheckConfig = z.infer;
+
export const LoadBalancerConfigSchema = z.object({
sslCertIamArn: z.string().nullish().default(null).describe('SSL certificate IAM ARN for load balancer.'),
- healthCheckConfig: HealthCheckConfigSchema,
+ healthCheckConfig: LoadBalancerHealthCheckConfigSchema,
domainName: z.string().nullish().default(null).describe('Domain name to use instead of the load balancer\'s default DNS name.'),
})
.describe('Configuration for load balancer settings.');
export const MetricConfigSchema = z.object({
- albMetricName: z.string().describe('Name of the ALB metric.'),
- targetValue: z.number().describe('Target value for the metric.'),
+ albMetricName: z.string().default('RequestCountPerTarget').describe('Name of the ALB metric.'),
+ targetValue: z.number().default(30).describe('Target value for the metric.'),
duration: z.number().default(60).describe('Duration in seconds for metric evaluation.'),
estimatedInstanceWarmup: z.number().min(0).default(180).describe('Estimated warm-up time in seconds until a newly launched instance can send metrics to CloudWatch.'),
})
@@ -625,6 +631,8 @@ export const EcsClusterConfigSchema = z
autoScalingConfig: AutoScalingConfigSchema,
loadBalancerConfig: LoadBalancerConfigSchema,
localModelCode: z.string().default('/opt/model-code'),
+ containerMemoryBuffer: z.number().default(1024 * 2)
+ .describe('Memory in MiB to reserve for the host OS/ECS agent. Container gets (instance memory - buffer). Default: 2048 MiB'),
modelHosting: z
.string()
.default('ecs')
@@ -634,15 +642,16 @@ export const EcsClusterConfigSchema = z
})
.refine(
(data) => {
- // 'textgen' type must have boolean streaming, 'embedding' type must have null streaming
+ // 'textgen' type must have boolean streaming, 'embedding' and 'videogen' types must have null streaming
const isValidForTextgen = data.modelType === 'textgen' && typeof data.streaming === 'boolean';
const isValidForEmbedding = data.modelType === 'embedding' && data.streaming === null;
+ const isValidForVideogen = data.modelType === 'videogen' && data.streaming === null;
- return isValidForTextgen || isValidForEmbedding;
+ return isValidForTextgen || isValidForEmbedding || isValidForVideogen;
},
{
message: `For 'textgen' models, 'streaming' must be true or false.
- For 'embedding' models, 'streaming' must not be set.`,
+ For 'embedding' and 'videogen' models, 'streaming' must not be set.`,
path: ['streaming'],
},
);
@@ -722,12 +731,12 @@ const FastApiContainerConfigSchema = z.object({
})
.refine(
(config) => {
- return !config.dbHost && !config.passwordSecretId;
+ return !config.dbHost;
},
{
message:
'We do not allow using an existing DB for LiteLLM because of its requirement in internal model management ' +
- 'APIs. Please do not define the dbHost or passwordSecretId fields for the FastAPI container DB config.',
+ 'APIs. Please do not define the dbHost field for the FastAPI container DB config.',
},
),
}).describe('Configuration schema for REST API.');
@@ -857,6 +866,8 @@ export const RawConfigObject = z.object({
deployChat: z.boolean().default(true).describe('Whether to deploy chat stacks.'),
deployDocs: z.boolean().default(true).describe('Whether to deploy docs stacks.'),
deployUi: z.boolean().default(true).describe('Whether to deploy UI stacks.'),
+ useCustomBranding: z.boolean().optional().describe('Whether to use custom branding assets in the UI.'),
+ customDisplayName: z.string().optional().describe('Custom display name to replace "LISA" branding in titles and descriptions. Requires "useCustomBranding" to be enabled.'),
deployMetrics: z.boolean().default(true).describe('Whether to deploy Metrics stack.'),
deployMcp: z.boolean().default(true).describe('Whether to deploy LISA MCP stack.'),
deployServe: z.boolean().default(true).describe('Whether to deploy LISA Serve stack.'),
@@ -870,7 +881,7 @@ export const RawConfigObject = z.object({
indexUrl: '',
trustedHost: '',
}).describe('Pypi configuration.'),
- baseImage: z.string().default('python:3.13-slim').describe('Base image used for LISA serve components'),
+ baseImage: z.string().default('public.ecr.aws/docker/library/python:3.13-slim').describe('Base image used for LISA serve components'),
nodejsImage: z.string().default('public.ecr.aws/lambda/nodejs:24').describe('Base image used for LISA NodeJS lambda deployments'),
condaUrl: z.string().default('').describe('Conda URL configuration'),
certificateAuthorityBundle: z.string().default('').describe('Certificate Authority Bundle file'),
@@ -921,7 +932,8 @@ export const RawConfigObject = z.object({
bootstrapRolePrefix: z.string().optional().describe('Prefix for CDK bootstrap role names. Useful when roles have custom prefixes like My_User_Roles_. Leave empty for standard role names.'),
litellmConfig: LiteLLMConfig,
convertInlinePoliciesToManaged: z.boolean().optional().default(false).describe('Convert inline policies to managed policies'),
- iamRdsAuth: z.boolean().optional().default(false).describe('Enable IAM authentication for RDS'),
+ iamRdsAuth: z.boolean().optional().default(false)
+ .describe('Enable IAM authentication for RDS. When true (default), IAM authentication is used and the bootstrap password is deleted after setup. When false, password-based authentication is used. WARNING: Switching from true to false after deployment is not supported - the master password is permanently deleted when IAM auth is enabled. This is a one-way migration.'),
});
export const RawConfigSchema = RawConfigObject
diff --git a/lib/schema/ragSchema.ts b/lib/schema/ragSchema.ts
index 4f5b21b2c..06f700d47 100644
--- a/lib/schema/ragSchema.ts
+++ b/lib/schema/ragSchema.ts
@@ -148,12 +148,13 @@ export type OpenSearchConfig =
export const RdsInstanceConfig = z.object({
username: z.string().default('postgres').describe('The username used for database connection.'),
- passwordSecretId: z.string().optional().describe('The SecretsManager Secret ID that stores the existing database password.'),
+ passwordSecretId: z.string().optional().describe('The SecretsManager Secret ID that stores the existing database password. Only used when iamRdsAuth is false.'),
dbHost: z.string().optional().describe('The database hostname for the existing database instance.'),
dbName: z.string().default('postgres').describe('The name of the database for the database instance.'),
dbPort: z.number().default(5432).describe('The port of the existing database instance or the port to be opened on the database instance.'),
}).describe('Configuration schema for RDS Instances needed for LiteLLM scaling or PGVector RAG operations.\n \n ' +
- 'The optional fields can be omitted to create a new database instance, otherwise fill in all fields to use an existing database instance.');
+ 'The optional fields can be omitted to create a new database instance, otherwise fill in all fields to use an existing database instance. ' +
+ 'By default, IAM authentication is used. Set iamRdsAuth to false in config to use password-based authentication.');
export type RdsConfig = z.infer;
@@ -163,7 +164,7 @@ export const RagRepositoryMetadata = MetadataSchema.extend({
customFields: z.record(z.string(), z.any()).optional().describe('Custom metadata fields for the repository.'),
});
-const BaseRagRepositoryConfigSchema = z.object({
+export const BaseRagRepositoryConfigSchema = z.object({
repositoryId: z.string()
.nonempty()
.regex(/^[a-z0-9-]{3,20}/, 'Only lowercase alphanumeric characters and \'-\' are supported.')
diff --git a/lib/schema/schema.ts b/lib/schema/schema.ts
index d9eac3b22..871077782 100644
--- a/lib/schema/schema.ts
+++ b/lib/schema/schema.ts
@@ -47,7 +47,7 @@ export const ConfigSchema = RawConfigSchema.transform((rawConfig) => {
} else if (rawConfig.region.includes('iso')) {
awsRegionArn = 'aws-iso';
} else if (rawConfig.region.includes('gov')) {
- awsRegionArn = 'aws-gov';
+ awsRegionArn = 'aws-us-gov';
} else {
awsRegionArn = 'aws';
}
diff --git a/lib/serve/ecs-model/embedding/instructor/Dockerfile b/lib/serve/ecs-model/embedding/instructor/Dockerfile
index d8443a5f1..bbde17aa5 100644
--- a/lib/serve/ecs-model/embedding/instructor/Dockerfile
+++ b/lib/serve/ecs-model/embedding/instructor/Dockerfile
@@ -1,6 +1,22 @@
-ARG BASE_IMAGE=python:3.13-slim
+ARG BASE_IMAGE=public.ecr.aws/docker/library/python:3.13-slim
FROM ${BASE_IMAGE}
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
#### POINT TO NEW PYPI CONFIG
ARG PYPI_INDEX_URL
ARG PYPI_TRUSTED_HOST
diff --git a/lib/serve/ecs-model/embedding/instructor/src/inference.py b/lib/serve/ecs-model/embedding/instructor/src/inference.py
index a8dc65043..fd5d88c7a 100644
--- a/lib/serve/ecs-model/embedding/instructor/src/inference.py
+++ b/lib/serve/ecs-model/embedding/instructor/src/inference.py
@@ -13,7 +13,7 @@
# limitations under the License.
"""Inference handler."""
-from typing import Any, Dict
+from typing import Any
import torch
from InstructorEmbedding import INSTRUCTOR
@@ -36,7 +36,7 @@ def model_fn(model_dir: str) -> INSTRUCTOR:
return model
-def predict_fn(data: Dict[str, Any], model: INSTRUCTOR) -> Any:
+def predict_fn(data: dict[str, Any], model: INSTRUCTOR) -> Any:
"""Get embeddings."""
instruction = data["instruction"]
text = data["text"]
diff --git a/lib/serve/ecs-model/embedding/tei/Dockerfile b/lib/serve/ecs-model/embedding/tei/Dockerfile
index cbcd04397..1409cba4c 100644
--- a/lib/serve/ecs-model/embedding/tei/Dockerfile
+++ b/lib/serve/ecs-model/embedding/tei/Dockerfile
@@ -1,6 +1,22 @@
ARG BASE_IMAGE=ghcr.io/huggingface/text-embeddings-inference:latest
FROM ${BASE_IMAGE}
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
##### DOWNLOAD MOUNTPOINTS S3
ARG MOUNTS3_DEB_URL
ARG MOUNTS3_DEB_SHA256
diff --git a/lib/serve/ecs-model/embedding/tei/src/entrypoint.sh b/lib/serve/ecs-model/embedding/tei/src/entrypoint.sh
index 3d0786aa3..0e6e7f9d6 100644
--- a/lib/serve/ecs-model/embedding/tei/src/entrypoint.sh
+++ b/lib/serve/ecs-model/embedding/tei/src/entrypoint.sh
@@ -4,37 +4,46 @@ set -e
# Environment variables for LISA deployment
declare -a vars=("S3_BUCKET_MODELS" "LOCAL_MODEL_PATH" "MODEL_NAME" "S3_MOUNT_POINT" "THREADS")
-# TEI Configuration Environment Variables (read natively by TEI):
-# Performance & Concurrency:
+# TEI Configuration Environment Variables
+# Based on official TEI documentation: https://huggingface.co/docs/text-embeddings-inference/cli_arguments
+#
+# PERFORMANCE & BATCHING (Critical for throughput):
# MAX_CONCURRENT_REQUESTS - Maximum concurrent requests (default: 512)
-# MAX_BATCH_TOKENS - Maximum tokens per batch (default: 16384)
-# MAX_BATCH_REQUESTS - Maximum requests per batch
-# MAX_CLIENT_BATCH_SIZE - Maximum client batch size (default: 1024)
-# TOKENIZATION_WORKERS - Number of tokenization workers
+# MAX_BATCH_TOKENS - Maximum tokens per batch (default: 16384) **CRITICAL for GPU utilization**
+# MAX_BATCH_REQUESTS - Maximum requests per batch (optional)
+# MAX_CLIENT_BATCH_SIZE - Maximum inputs per client request (default: 32)
+# TOKENIZATION_WORKERS - Number of tokenization workers (default: CPU cores)
#
-# Model Configuration:
+# MODEL CONFIGURATION:
# REVISION - Model revision/branch to use
-# DTYPE - Data type for model weights (float16, float32, etc.)
-# HUGGINGFACE_HUB_CACHE - Custom cache directory (default: /data)
-# HF_API_TOKEN - Hugging Face API token for private models
+# DTYPE - Data type for model weights (float16, float32)
+# POOLING - Pooling method (cls, mean, splade, last-token)
+# DEFAULT_PROMPT_NAME - Default prompt name from model config
+# DEFAULT_PROMPT - Default prompt text to prepend
+# DENSE_PATH - Path to Dense module for some models
+# SERVED_MODEL_NAME - Model name for OpenAI-compatible endpoints
#
-# Features:
-# AUTO_TRUNCATE - Enable automatic truncation (true/false, default: false)
+# INPUT HANDLING:
+# AUTO_TRUNCATE - Automatically truncate long inputs (true/false)
# PAYLOAD_LIMIT - Maximum payload size in bytes (default: 2000000)
#
-# Network & Security:
-# HOSTNAME - Server hostname (default: container hostname)
-# PORT - Server port (default: 8080, overridden by --port)
-# UDS_PATH - Unix domain socket path (default: /tmp/text-embeddings-inference-server)
-# API_KEY - API key for authentication
+# AUTHENTICATION & NETWORK:
+# HF_TOKEN - Hugging Face API token for private models
+# API_KEY - API key for request authorization
+# HOSTNAME - Server hostname (default: 0.0.0.0)
+# PORT - Server port (default: 3000, overridden to 8080)
+# UDS_PATH - Unix domain socket path
# CORS_ALLOW_ORIGIN - CORS origin configuration
#
-# Output & Observability:
-# JSON_OUTPUT - Enable JSON output (true/false, overridden by --json-output)
-# OTLP_ENDPOINT - OpenTelemetry endpoint for metrics
+# OBSERVABILITY:
+# JSON_OUTPUT - Enable JSON output (true/false)
+# OTLP_ENDPOINT - OpenTelemetry endpoint for tracing
+# OTLP_SERVICE_NAME - Service name for OpenTelemetry
+# PROMETHEUS_PORT - Prometheus metrics port (default: 9000)
+# DISABLE_SPANS - Disable tracing spans
#
-# Custom LISA Environment Variables:
-# TEI_POOLING - Pooling method (mean, cls, max, mean_sqrt_len) - not available as native env var
+# STORAGE:
+# HUGGINGFACE_HUB_CACHE - Custom cache directory
# Check the necessary environment variables
for var in "${vars[@]}"; do
@@ -55,38 +64,126 @@ mkdir -p ${LOCAL_MODEL_PATH}
# Use rsync with S3_MOUNT_POINT
ls ${S3_MOUNT_POINT}/${MODEL_NAME} | xargs -n1 -P${THREADS} -I% rsync -Pa --exclude "*.bin" ${S3_MOUNT_POINT}/${MODEL_NAME}/% ${LOCAL_MODEL_PATH}/
-# Build additional arguments for TEI (only for parameters not supported via env vars)
+# Build CLI arguments from environment variables
+# TEI reads some env vars natively, but we explicitly pass them as CLI args for clarity
ADDITIONAL_ARGS=""
-# Pooling configuration (not available as env var)
-if [[ -n "${TEI_POOLING}" ]]; then
- ADDITIONAL_ARGS+=" --pooling ${TEI_POOLING}"
- echo "Using pooling method: ${TEI_POOLING}"
+echo "Building TEI CLI arguments from environment variables..."
+
+# Performance & Batching
+if [[ -n "${MAX_CONCURRENT_REQUESTS}" ]]; then
+ ADDITIONAL_ARGS+=" --max-concurrent-requests ${MAX_CONCURRENT_REQUESTS}"
+ echo " --max-concurrent-requests ${MAX_CONCURRENT_REQUESTS}"
fi
-# Start the webserver
-# TEI natively reads these environment variables:
-# - MAX_CONCURRENT_REQUESTS
-# - MAX_BATCH_TOKENS
-# - MAX_BATCH_REQUESTS
-# - MAX_CLIENT_BATCH_SIZE
-# - REVISION
-# - TOKENIZATION_WORKERS
-# - DTYPE
-# - AUTO_TRUNCATE
-# - PAYLOAD_LIMIT
-# - HUGGINGFACE_HUB_CACHE
-# - HF_API_TOKEN
-# - HOSTNAME
-# - PORT
-# - UDS_PATH
-# - API_KEY
-# - JSON_OUTPUT
-# - OTLP_ENDPOINT
-# - CORS_ALLOW_ORIGIN
+if [[ -n "${MAX_BATCH_TOKENS}" ]]; then
+ ADDITIONAL_ARGS+=" --max-batch-tokens ${MAX_BATCH_TOKENS}"
+ echo " --max-batch-tokens ${MAX_BATCH_TOKENS}"
+fi
+
+if [[ -n "${MAX_BATCH_REQUESTS}" ]]; then
+ ADDITIONAL_ARGS+=" --max-batch-requests ${MAX_BATCH_REQUESTS}"
+ echo " --max-batch-requests ${MAX_BATCH_REQUESTS}"
+fi
+
+if [[ -n "${MAX_CLIENT_BATCH_SIZE}" ]]; then
+ ADDITIONAL_ARGS+=" --max-client-batch-size ${MAX_CLIENT_BATCH_SIZE}"
+ echo " --max-client-batch-size ${MAX_CLIENT_BATCH_SIZE}"
+fi
+
+if [[ -n "${TOKENIZATION_WORKERS}" ]]; then
+ ADDITIONAL_ARGS+=" --tokenization-workers ${TOKENIZATION_WORKERS}"
+ echo " --tokenization-workers ${TOKENIZATION_WORKERS}"
+fi
+
+# Model Configuration
+if [[ -n "${REVISION}" ]]; then
+ ADDITIONAL_ARGS+=" --revision ${REVISION}"
+ echo " --revision ${REVISION}"
+fi
+
+if [[ -n "${DTYPE}" ]]; then
+ ADDITIONAL_ARGS+=" --dtype ${DTYPE}"
+ echo " --dtype ${DTYPE}"
+fi
+
+if [[ -n "${POOLING}" ]]; then
+ ADDITIONAL_ARGS+=" --pooling ${POOLING}"
+ echo " --pooling ${POOLING}"
+fi
+
+if [[ -n "${DEFAULT_PROMPT_NAME}" ]]; then
+ ADDITIONAL_ARGS+=" --default-prompt-name ${DEFAULT_PROMPT_NAME}"
+ echo " --default-prompt-name ${DEFAULT_PROMPT_NAME}"
+fi
+
+if [[ -n "${DEFAULT_PROMPT}" ]]; then
+ ADDITIONAL_ARGS+=" --default-prompt \"${DEFAULT_PROMPT}\""
+ echo " --default-prompt \"${DEFAULT_PROMPT}\""
+fi
+
+if [[ -n "${DENSE_PATH}" ]]; then
+ ADDITIONAL_ARGS+=" --dense-path ${DENSE_PATH}"
+ echo " --dense-path ${DENSE_PATH}"
+fi
+if [[ -n "${SERVED_MODEL_NAME}" ]]; then
+ ADDITIONAL_ARGS+=" --served-model-name ${SERVED_MODEL_NAME}"
+ echo " --served-model-name ${SERVED_MODEL_NAME}"
+fi
+
+# Input Handling
+if [[ "${AUTO_TRUNCATE}" == "true" ]]; then
+ ADDITIONAL_ARGS+=" --auto-truncate"
+ echo " --auto-truncate"
+fi
+
+if [[ -n "${PAYLOAD_LIMIT}" ]]; then
+ ADDITIONAL_ARGS+=" --payload-limit ${PAYLOAD_LIMIT}"
+ echo " --payload-limit ${PAYLOAD_LIMIT}"
+fi
+
+# Authentication
+if [[ -n "${HF_TOKEN}" ]]; then
+ ADDITIONAL_ARGS+=" --hf-token ${HF_TOKEN}"
+ echo " --hf-token [REDACTED]"
+fi
+
+if [[ -n "${API_KEY}" ]]; then
+ ADDITIONAL_ARGS+=" --api-key ${API_KEY}"
+ echo " --api-key [REDACTED]"
+fi
+
+# Observability
+if [[ -n "${OTLP_ENDPOINT}" ]]; then
+ ADDITIONAL_ARGS+=" --otlp-endpoint ${OTLP_ENDPOINT}"
+ echo " --otlp-endpoint ${OTLP_ENDPOINT}"
+fi
+
+if [[ -n "${OTLP_SERVICE_NAME}" ]]; then
+ ADDITIONAL_ARGS+=" --otlp-service-name ${OTLP_SERVICE_NAME}"
+ echo " --otlp-service-name ${OTLP_SERVICE_NAME}"
+fi
+
+if [[ -n "${PROMETHEUS_PORT}" ]]; then
+ ADDITIONAL_ARGS+=" --prometheus-port ${PROMETHEUS_PORT}"
+ echo " --prometheus-port ${PROMETHEUS_PORT}"
+fi
+
+if [[ "${DISABLE_SPANS}" == "true" ]]; then
+ ADDITIONAL_ARGS+=" --disable-spans"
+ echo " --disable-spans"
+fi
+
+# CORS
+if [[ -n "${CORS_ALLOW_ORIGIN}" ]]; then
+ ADDITIONAL_ARGS+=" --cors-allow-origin ${CORS_ALLOW_ORIGIN}"
+ echo " --cors-allow-origin ${CORS_ALLOW_ORIGIN}"
+fi
+
+# Start the webserver
echo "Starting TEI with args: ${ADDITIONAL_ARGS}"
echo "TEI environment variables:"
-env | grep -E "^(MAX_CONCURRENT_REQUESTS|MAX_BATCH_TOKENS|MAX_BATCH_REQUESTS|MAX_CLIENT_BATCH_SIZE|REVISION|TOKENIZATION_WORKERS|DTYPE|AUTO_TRUNCATE|PAYLOAD_LIMIT|HUGGINGFACE_HUB_CACHE|HF_API_TOKEN|HOSTNAME|PORT|UDS_PATH|API_KEY|JSON_OUTPUT|OTLP_ENDPOINT|CORS_ALLOW_ORIGIN|TEI_POOLING)=" || echo "No TEI environment variables set"
+env | grep -E "^(MAX_CONCURRENT_REQUESTS|MAX_BATCH_TOKENS|MAX_BATCH_REQUESTS|MAX_CLIENT_BATCH_SIZE|TOKENIZATION_WORKERS|REVISION|DTYPE|POOLING|DEFAULT_PROMPT|DENSE_PATH|SERVED_MODEL_NAME|AUTO_TRUNCATE|PAYLOAD_LIMIT|HF_TOKEN|API_KEY|OTLP_ENDPOINT|PROMETHEUS_PORT|CORS_ALLOW_ORIGIN)=" || echo "No TEI environment variables set"
text-embeddings-router --model-id $LOCAL_MODEL_PATH --port 8080 --json-output ${ADDITIONAL_ARGS}
diff --git a/lib/serve/ecs-model/textgen/tgi/Dockerfile b/lib/serve/ecs-model/textgen/tgi/Dockerfile
index b272baa68..a6ca7a891 100644
--- a/lib/serve/ecs-model/textgen/tgi/Dockerfile
+++ b/lib/serve/ecs-model/textgen/tgi/Dockerfile
@@ -1,6 +1,22 @@
ARG BASE_IMAGE=ghcr.io/huggingface/text-generation-inference:latest
FROM ${BASE_IMAGE}
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
##### DOWNLOAD MOUNTPOINTS S3
ARG MOUNTS3_DEB_URL
RUN apt update -y && apt install -y wget rsync && \
diff --git a/lib/serve/ecs-model/vllm/Dockerfile b/lib/serve/ecs-model/vllm/Dockerfile
index dedaa69c8..ad3d91d06 100644
--- a/lib/serve/ecs-model/vllm/Dockerfile
+++ b/lib/serve/ecs-model/vllm/Dockerfile
@@ -1,13 +1,36 @@
-ARG BASE_IMAGE=python:3.13-slim
+ARG BASE_IMAGE=public.ecr.aws/deep-learning-containers/vllm:0.13-gpu-py312
FROM ${BASE_IMAGE}
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
##### DOWNLOAD MOUNTPOINTS S3
ARG MOUNTS3_DEB_URL
ARG MOUNTS3_DEB_SHA256
-RUN apt update -y && apt install -y wget rsync && \
- wget ${MOUNTS3_DEB_URL} && \
- apt install -y ./mount-s3.deb && \
- rm mount-s3.deb
+RUN if command -v apt-get >/dev/null 2>&1; then \
+ apt update -y && apt install -y wget rsync && \
+ wget ${MOUNTS3_DEB_URL} && apt install -y ./mount-s3.deb && \
+ rm mount-s3.deb && rm -rf /var/lib/apt/lists/*; \
+ elif command -v yum >/dev/null 2>&1; then \
+ yum install -y wget rsync && wget ${MOUNTS3_DEB_URL} && \
+ yum install -y ./mount-s3.rpm && yum clean all && rm mount-s3.rpm; \
+ elif command -v apk >/dev/null 2>&1; then \
+ apk add --no-cache wget rsync && wget ${MOUNTS3_DEB_URL} && \
+ apk add --allow-untrusted ./mount-s3.apk && rm mount-s3.apk; \
+ fi
COPY src/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
diff --git a/lib/serve/ecs-model/vllm/src/entrypoint.sh b/lib/serve/ecs-model/vllm/src/entrypoint.sh
index ae834a7ef..c23a0452a 100644
--- a/lib/serve/ecs-model/vllm/src/entrypoint.sh
+++ b/lib/serve/ecs-model/vllm/src/entrypoint.sh
@@ -176,7 +176,71 @@ if [[ -n "${VLLM_TENSOR_PARALLEL_SIZE}" ]] && [[ ${VLLM_TENSOR_PARALLEL_SIZE} -g
fi
# Start the webserver
-# vLLM natively reads VLLM_* environment variables for configuration
+# vLLM reads some VLLM_* environment variables natively, but many require CLI args
+# Map environment variables to CLI arguments for full control
+
+echo "Building vLLM CLI arguments from environment variables..."
+
+# GPU memory utilization (0.0-1.0)
+if [[ -n "${VLLM_GPU_MEMORY_UTILIZATION}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --gpu-memory-utilization ${VLLM_GPU_MEMORY_UTILIZATION}"
+ echo " --gpu-memory-utilization ${VLLM_GPU_MEMORY_UTILIZATION}"
+fi
+
+# Max model length (context window)
+if [[ -n "${VLLM_MAX_MODEL_LEN}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --max-model-len ${VLLM_MAX_MODEL_LEN}"
+ echo " --max-model-len ${VLLM_MAX_MODEL_LEN}"
+fi
+
+# Max number of batched tokens per iteration
+if [[ -n "${VLLM_MAX_NUM_BATCHED_TOKENS}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --max-num-batched-tokens ${VLLM_MAX_NUM_BATCHED_TOKENS}"
+ echo " --max-num-batched-tokens ${VLLM_MAX_NUM_BATCHED_TOKENS}"
+fi
+
+# Max number of sequences (concurrent requests)
+if [[ -n "${VLLM_MAX_NUM_SEQS}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --max-num-seqs ${VLLM_MAX_NUM_SEQS}"
+ echo " --max-num-seqs ${VLLM_MAX_NUM_SEQS}"
+fi
+
+# Enable prefix caching
+if [[ "${VLLM_ENABLE_PREFIX_CACHING}" == "true" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --enable-prefix-caching"
+ echo " --enable-prefix-caching"
+fi
+
+# Enable chunked prefill
+if [[ "${VLLM_ENABLE_CHUNKED_PREFILL}" == "true" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --enable-chunked-prefill"
+ echo " --enable-chunked-prefill"
+fi
+
+# Data type (auto, half, float16, bfloat16, float, float32)
+if [[ -n "${VLLM_DTYPE}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --dtype ${VLLM_DTYPE}"
+ echo " --dtype ${VLLM_DTYPE}"
+fi
+
+# Tensor parallel size (for multi-GPU)
+if [[ -n "${VLLM_TENSOR_PARALLEL_SIZE}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --tensor-parallel-size ${VLLM_TENSOR_PARALLEL_SIZE}"
+ echo " --tensor-parallel-size ${VLLM_TENSOR_PARALLEL_SIZE}"
+fi
+
+# Quantization method
+if [[ -n "${VLLM_QUANTIZATION}" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --quantization ${VLLM_QUANTIZATION}"
+ echo " --quantization ${VLLM_QUANTIZATION}"
+fi
+
+# Trust remote code (for custom models)
+if [[ "${VLLM_TRUST_REMOTE_CODE}" == "true" ]]; then
+ ADDITIONAL_ARGS="${ADDITIONAL_ARGS} --trust-remote-code"
+ echo " --trust-remote-code"
+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/mcp-workbench/Dockerfile b/lib/serve/mcp-workbench/Dockerfile
index 1e0efb480..ce52e7b3f 100644
--- a/lib/serve/mcp-workbench/Dockerfile
+++ b/lib/serve/mcp-workbench/Dockerfile
@@ -1,4 +1,4 @@
-ARG BASE_IMAGE=python:3.13-slim
+ARG BASE_IMAGE=public.ecr.aws/docker/library/python:3.13-slim
FROM ${BASE_IMAGE}
ARG RCLONE_VERSION=v1.71.0
@@ -15,12 +15,30 @@ ENV RCLONE_SOURCE=$RCLONE_SOURCE
WORKDIR /workspace
-RUN apt-get update && apt-get install -y \
- curl \
- fuse3 \
- unzip \
- xz-utils \
- && rm -rf /var/lib/apt/lists/*
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
+# Install dependencies
+RUN if command -v apt-get >/dev/null 2>&1; then \
+ apt-get update && apt-get install -y curl fuse3 unzip xz-utils && rm -rf /var/lib/apt/lists/*; \
+ elif command -v yum >/dev/null 2>&1; then \
+ yum install -y curl fuse3 unzip xz && yum clean all; \
+ elif command -v apk >/dev/null 2>&1; then \
+ apk add --no-cache curl fuse3 unzip xz; \
+ fi
# Install s6-overlay
ADD $S6_OVERLAY_NOARCH_SOURCE /tmp/
diff --git a/lib/serve/mcp-workbench/pyproject.toml b/lib/serve/mcp-workbench/pyproject.toml
index cfcd89b9e..de687c4f6 100644
--- a/lib/serve/mcp-workbench/pyproject.toml
+++ b/lib/serve/mcp-workbench/pyproject.toml
@@ -14,17 +14,15 @@ dependencies = [
"pyyaml>=6.0.2",
"click==8.3.1",
"starlette>=0.40.0,<0.51.0",
- "uvicorn>=0.31.1,<0.39.0",
- "aioboto3==13.4.0",
- "aiobotocore==2.18.0",
+ "uvicorn>=0.31.1,<0.32.0",
"aiohttp==3.13.2",
- "boto3==1.36.0",
+ "boto3==1.40.76",
"cryptography==46.0.3",
- "gunicorn==23.0.0",
- "pydantic==2.12.5",
- "PyJWT==2.10.1",
+ "gunicorn>=23.0.0,<24.0.0",
+ "pydantic>=2.5.0,<3.0.0",
+ "PyJWT>=2.10.1,<3.0.0",
"requests==2.32.5",
- "fastapi==0.124.2",
+ "fastapi>=0.120.1",
"fastapi_utils==0.8.0",
"loguru==0.7.3"
]
@@ -49,7 +47,7 @@ where = ["src"]
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --tb=short"
-testpaths = ["tests"]
+testpaths = ["../../test/mcp-workbench"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
diff --git a/lib/serve/mcp-workbench/requirements.txt b/lib/serve/mcp-workbench/requirements.txt
index 56d7f6eba..460f36cb6 100644
--- a/lib/serve/mcp-workbench/requirements.txt
+++ b/lib/serve/mcp-workbench/requirements.txt
@@ -1,2 +1,2 @@
## Add additional requirements to this file
-## boto3==1.36.0
+## boto3==1.40.76
diff --git a/lib/serve/mcp-workbench/src/examples/sample_tools/calculator_tool.py b/lib/serve/mcp-workbench/src/examples/sample_tools/calculator_tool.py
index 31085baea..7780c7f5c 100644
--- a/lib/serve/mcp-workbench/src/examples/sample_tools/calculator_tool.py
+++ b/lib/serve/mcp-workbench/src/examples/sample_tools/calculator_tool.py
@@ -23,6 +23,7 @@
Both methods allow you to create tools that can be called by AI models to perform specific tasks.
"""
+from collections.abc import Callable
from typing import Annotated
from mcpworkbench.core.base_tool import BaseTool
@@ -45,7 +46,7 @@ class CalculatorTool(BaseTool):
4. Define the actual tool function with proper type annotations
"""
- def __init__(self):
+ def __init__(self) -> None:
"""
Initialize the tool with metadata.
@@ -57,7 +58,7 @@ def __init__(self):
name="calculator", description="Performs basic arithmetic operations (add, subtract, multiply, divide)"
)
- async def execute(self):
+ async def execute(self) -> Callable:
"""
Return the callable function that implements the tool's functionality.
@@ -71,7 +72,7 @@ async def calculate(
operator: Annotated[str, "add, subtract, multiply, or divide"],
left_operand: Annotated[float, "The first number"],
right_operand: Annotated[float, "The second number"],
- ):
+ ) -> dict[str, float | str]:
"""
Execute the calculator operation.
diff --git a/lib/serve/mcp-workbench/src/examples/sample_tools/text_utils.py b/lib/serve/mcp-workbench/src/examples/sample_tools/text_utils.py
index 81871c978..e662a043d 100644
--- a/lib/serve/mcp-workbench/src/examples/sample_tools/text_utils.py
+++ b/lib/serve/mcp-workbench/src/examples/sample_tools/text_utils.py
@@ -33,7 +33,7 @@
name="text_length",
description="Count the number of characters in a text string",
)
-async def count_characters(text: Annotated[str, "The text string to analyze"]):
+async def count_characters(text: Annotated[str, "The text string to analyze"]) -> dict[str, int | str]:
"""Count the number of characters in the given text."""
return {
"text": text,
@@ -50,7 +50,7 @@ async def count_characters(text: Annotated[str, "The text string to analyze"]):
def transform_text(
text: Annotated[str, "The text string to transform"],
transformation: Annotated[str, "Type of transformation: 'upper', 'lower', 'title', or 'capitalize'"],
-):
+) -> dict[str, str]:
"""Transform the given text according to the specified transformation."""
if transformation == "upper":
result = text.upper()
@@ -70,6 +70,6 @@ def transform_text(
name="text_reverse",
description="Reverse the characters in a text string",
)
-def reverse_text(text: Annotated[str, "The text string to reverse"]):
+def reverse_text(text: Annotated[str, "The text string to reverse"]) -> dict[str, str]:
"""Reverse the characters in the given text."""
return {"original": text, "reversed": text[::-1]}
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/adapters/tool_adapter.py b/lib/serve/mcp-workbench/src/mcpworkbench/adapters/tool_adapter.py
index b97b76a52..72c8be790 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/adapters/tool_adapter.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/adapters/tool_adapter.py
@@ -17,7 +17,7 @@
import asyncio
import logging
from abc import ABC, abstractmethod
-from typing import Any, Dict
+from typing import Any
from ..core.base_tool import BaseTool
from ..core.tool_discovery import ToolInfo, ToolType
@@ -32,7 +32,7 @@ def __init__(self, tool_info: ToolInfo):
self.tool_info = tool_info
@abstractmethod
- async def execute(self, arguments: Dict[str, Any]) -> Any:
+ async def execute(self, arguments: dict[str, Any]) -> Any:
"""Execute the tool with the given arguments."""
pass
@@ -60,7 +60,7 @@ def __init__(self, tool_info: ToolInfo):
super().__init__(tool_info)
self.tool_instance: BaseTool = tool_info.tool_instance
- async def execute(self, arguments: Dict[str, Any]) -> Any:
+ async def execute(self, arguments: dict[str, Any]) -> Any:
"""Execute the BaseTool instance."""
try:
# Call the tool's execute method
@@ -84,7 +84,7 @@ def __init__(self, tool_info: ToolInfo):
super().__init__(tool_info)
self.function = tool_info.tool_instance
- async def execute(self, arguments: Dict[str, Any]) -> Any:
+ async def execute(self, arguments: dict[str, Any]) -> Any:
"""Execute the decorated function."""
try:
# Check if the function is async
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/cli.py b/lib/serve/mcp-workbench/src/mcpworkbench/cli.py
index 726e1a4ca..99eab6476 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/cli.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/cli.py
@@ -18,7 +18,6 @@
import re
import sys
from pathlib import Path
-from typing import Optional
import click
import yaml
@@ -38,7 +37,7 @@
def load_config_from_file(config_path: str) -> dict:
"""Load configuration from YAML file."""
try:
- with open(config_path, "r") as f:
+ with open(config_path) as f:
return yaml.safe_load(f) or {}
except FileNotFoundError:
logger.error(f"Configuration file not found: {config_path}")
@@ -79,16 +78,16 @@ def merge_config(file_config: dict, cli_overrides: dict) -> dict:
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose logging")
@click.option("--debug", is_flag=True, help="Enable debug logging")
def main(
- config: Optional[Path],
- tools_dir: Optional[Path],
- host: Optional[str],
- port: Optional[int],
- exit_route: Optional[str],
- rescan_route: Optional[str],
- cors_origins: Optional[str],
+ config: Path | None,
+ tools_dir: Path | None,
+ host: str | None,
+ port: int | None,
+ exit_route: str | None,
+ rescan_route: str | None,
+ cors_origins: str | None,
verbose: bool,
debug: bool,
-):
+) -> None:
"""MCP Workbench - A dynamic host for Python files used as MCP tools."""
# Set logging level
@@ -106,14 +105,14 @@ def main(
file_config = load_config_from_file(str(config))
# Prepare CLI overrides
- cli_overrides = {}
+ cli_overrides: dict[str, str | list[str]] = {}
if tools_dir:
cli_overrides["tools_dir"] = str(tools_dir)
if host:
cli_overrides["host"] = host
if port:
- cli_overrides["port"] = port
+ cli_overrides["port"] = str(port)
if exit_route:
cli_overrides["exit_route"] = exit_route
if rescan_route:
@@ -122,8 +121,8 @@ def main(
# Handle CORS origins
if cors_origins:
cleaned_origins = re.sub(r'^([\s"]+)?(.+?)([\s"]*)?$', r"\2", cors_origins)
- origins = [origin.strip() for origin in cleaned_origins.split(",")]
- cli_overrides["cors_origins"] = origins
+ origins_list: list[str] = [origin.strip() for origin in cleaned_origins.split(",")]
+ cli_overrides["cors_origins"] = origins_list
# Merge configurations
merged_config = merge_config(file_config, cli_overrides)
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/config/models.py b/lib/serve/mcp-workbench/src/mcpworkbench/config/models.py
index 51a209463..47280856b 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/config/models.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/config/models.py
@@ -14,7 +14,6 @@
"""Configuration models for MCP Workbench."""
-from typing import List, Optional
from pydantic import BaseModel, Field
@@ -22,11 +21,11 @@
class CORSConfig(BaseModel):
"""CORS configuration settings."""
- allow_origins: List[str] = Field(default=["*"], description="Allowed origins for CORS")
- allow_methods: List[str] = Field(default=["GET", "POST", "OPTIONS"], description="Allowed HTTP methods")
- allow_headers: List[str] = Field(default=["*"], description="Allowed headers")
+ allow_origins: list[str] = Field(default=["*"], description="Allowed origins for CORS")
+ allow_methods: list[str] = Field(default=["GET", "POST", "OPTIONS"], description="Allowed HTTP methods")
+ allow_headers: list[str] = Field(default=["*"], description="Allowed headers")
allow_credentials: bool = Field(default=True, description="Allow credentials in CORS requests")
- expose_headers: List[str] = Field(default=[], description="Headers to expose to the browser")
+ expose_headers: list[str] = Field(default=[], description="Headers to expose to the browser")
max_age: int = Field(default=600, description="Maximum age for CORS preflight cache")
@@ -41,8 +40,8 @@ class ServerConfig(BaseModel):
tools_directory: str = Field(..., description="Directory containing tool files")
# Management tool settings
- exit_route_path: Optional[str] = Field(default=None, description="Enable exit_server MCP tool when set")
- rescan_route_path: Optional[str] = Field(default=None, description="Enable rescan_tools MCP tool when set")
+ exit_route_path: str | None = Field(default=None, description="Enable exit_server MCP tool when set")
+ rescan_route_path: str | None = Field(default=None, description="Enable rescan_tools MCP tool when set")
# CORS settings
cors_settings: CORSConfig = Field(default_factory=CORSConfig, description="CORS configuration")
@@ -83,8 +82,15 @@ def from_dict(cls, data: dict) -> "ServerConfig":
if not isinstance(config_data["cors_settings"], dict):
config_data["cors_settings"] = {}
- # Set the origins
- config_data["cors_settings"]["allow_origins"] = cors_origins
+ # Convert cors_origins to a list if it's a string (comma-separated)
+ if isinstance(cors_origins, str):
+ origins_list = [origin.strip() for origin in cors_origins.split(",") if origin.strip()]
+ config_data["cors_settings"]["allow_origins"] = origins_list
+ elif isinstance(cors_origins, list):
+ config_data["cors_settings"]["allow_origins"] = cors_origins
+ else:
+ # Fallback to default
+ config_data["cors_settings"]["allow_origins"] = ["*"]
# Handle cors_settings
if "cors_settings" in config_data and isinstance(config_data["cors_settings"], dict):
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/core/annotations.py b/lib/serve/mcp-workbench/src/mcpworkbench/core/annotations.py
index c971b6f05..d10dcc85c 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/core/annotations.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/core/annotations.py
@@ -14,11 +14,14 @@
"""Annotations for function-based MCP tools."""
+from collections.abc import Callable
from functools import wraps
-from typing import Any, Callable, Dict
+from typing import Any, cast, TypeVar
+F = TypeVar("F", bound=Callable[..., Any])
-def mcp_tool(name: str, description: str):
+
+def mcp_tool(name: str, description: str) -> Callable[[F], F]:
"""
Decorator to mark a function as an MCP tool.
@@ -30,14 +33,14 @@ def mcp_tool(name: str, description: str):
The decorated function with MCP tool metadata
"""
- def decorator(func: Callable) -> Callable:
+ def decorator(func: F) -> F:
# Store metadata as function attributes
- func._mcp_tool_name = name
- func._mcp_tool_description = description
- func._is_mcp_tool = True
+ func._mcp_tool_name = name # type: ignore[attr-defined]
+ func._mcp_tool_description = description # type: ignore[attr-defined]
+ func._is_mcp_tool = True # type: ignore[attr-defined]
@wraps(func)
- async def wrapper(*args, **kwargs):
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
# If the function is not already async, we need to handle it
if hasattr(func, "__code__") and func.__code__.co_flags & 0x80: # CO_COROUTINE
return await func(*args, **kwargs)
@@ -45,22 +48,22 @@ async def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# Copy metadata to wrapper
- wrapper._mcp_tool_name = name
- wrapper._mcp_tool_description = description
- wrapper._is_mcp_tool = True
- wrapper._original_func = func
+ wrapper._mcp_tool_name = name # type: ignore[attr-defined]
+ wrapper._mcp_tool_description = description # type: ignore[attr-defined]
+ wrapper._is_mcp_tool = True # type: ignore[attr-defined]
+ wrapper._original_func = func # type: ignore[attr-defined]
- return wrapper
+ return cast(F, wrapper)
return decorator
def is_mcp_tool(func: Callable) -> bool:
"""Check if a function is marked as an MCP tool."""
- return hasattr(func, "_is_mcp_tool") and func._is_mcp_tool
+ return hasattr(func, "_is_mcp_tool") and getattr(func, "_is_mcp_tool", False)
-def get_tool_metadata(func: Callable) -> Dict[str, Any]:
+def get_tool_metadata(func: Callable) -> dict[str, Any]:
"""Get the MCP tool metadata from a decorated function."""
if not is_mcp_tool(func):
raise ValueError("Function is not marked as an MCP tool")
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/core/base_tool.py b/lib/serve/mcp-workbench/src/mcpworkbench/core/base_tool.py
index ccb3fc983..0e087d028 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/core/base_tool.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/core/base_tool.py
@@ -26,8 +26,9 @@
# limitations under the License."""Base tool class and related data structures."""
from abc import ABC, abstractmethod
+from collections.abc import Callable
from enum import Enum
-from typing import Any, Callable, Optional, Union
+from typing import Any
from pydantic import BaseModel, Field
@@ -49,15 +50,13 @@ class ToolInfo(BaseModel):
module_name: str = Field(..., description="Python module name")
# For class-based tools
- class_name: Optional[str] = Field(default=None, description="Class name for class-based tools")
+ class_name: str | None = Field(default=None, description="Class name for class-based tools")
# For function-based tools
- function_name: Optional[str] = Field(default=None, description="Function name for function-based tools")
+ function_name: str | None = Field(default=None, description="Function name for function-based tools")
# Tool instance or function reference (not serialized)
- tool_instance: Optional[Union[Any, Callable]] = Field(
- default=None, exclude=True, description="Tool instance or function"
- )
+ tool_instance: Any | Callable | None = Field(default=None, exclude=True, description="Tool instance or function")
class BaseTool(ABC):
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_discovery.py b/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_discovery.py
index 01159e0df..40013c341 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_discovery.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_discovery.py
@@ -20,7 +20,7 @@
import logging
import sys
from pathlib import Path
-from typing import Dict, List
+from typing import Any
from pydantic import BaseModel
@@ -33,11 +33,11 @@
class RescanResult(BaseModel):
"""Result of a tool directory rescan."""
- tools_added: List[str] = []
- tools_updated: List[str] = []
- tools_removed: List[str] = []
+ tools_added: list[str] = []
+ tools_updated: list[str] = []
+ tools_removed: list[str] = []
total_tools: int = 0
- errors: List[str] = []
+ errors: list[str] = []
class ToolDiscovery:
@@ -51,8 +51,8 @@ def __init__(self, tools_directory: str):
tools_directory: Path to directory containing tool files
"""
self.tools_directory = Path(tools_directory)
- self.loaded_modules: Dict[str, any] = {}
- self.current_tools: Dict[str, ToolInfo] = {}
+ self.loaded_modules: dict[str, Any] = {}
+ self.current_tools: dict[str, ToolInfo] = {}
if not self.tools_directory.exists():
raise ValueError(f"Tools directory does not exist: {tools_directory}")
@@ -60,7 +60,7 @@ def __init__(self, tools_directory: str):
if not self.tools_directory.is_dir():
raise ValueError(f"Tools directory is not a directory: {tools_directory}")
- def discover_tools(self) -> List[ToolInfo]:
+ def discover_tools(self) -> list[ToolInfo]:
"""
Discover all tools in the tools directory.
@@ -125,7 +125,7 @@ def rescan_tools(self) -> RescanResult:
return result
- def _reload_modules(self):
+ def _reload_modules(self) -> None:
"""Reload all previously loaded modules to pick up file changes."""
modules_to_reload = []
@@ -149,7 +149,7 @@ def _reload_modules(self):
except KeyError:
pass
- def _discover_tools_in_file(self, file_path: Path) -> List[ToolInfo]:
+ def _discover_tools_in_file(self, file_path: Path) -> list[ToolInfo]:
"""
Discover tools in a single Python file.
@@ -159,7 +159,7 @@ def _discover_tools_in_file(self, file_path: Path) -> List[ToolInfo]:
Returns:
List of tools found in the file
"""
- tools = []
+ tools: list[ToolInfo] = []
try:
# Create module name from file path
@@ -192,7 +192,7 @@ def _discover_tools_in_file(self, file_path: Path) -> List[ToolInfo]:
return tools
- def _find_class_based_tools(self, module, file_path: Path, module_name: str) -> List[ToolInfo]:
+ def _find_class_based_tools(self, module: Any, file_path: Path, module_name: str) -> list[ToolInfo]:
"""Find BaseTool subclasses in the module."""
tools = []
@@ -218,7 +218,7 @@ def _find_class_based_tools(self, module, file_path: Path, module_name: str) ->
instance = obj(name=tool_name, description=tool_description)
else:
# Custom constructor - try to instantiate with no args
- instance = obj()
+ instance = obj() # type: ignore[call-arg]
# Get tool metadata
tool_name = getattr(instance, "name", name.lower())
@@ -242,7 +242,7 @@ def _find_class_based_tools(self, module, file_path: Path, module_name: str) ->
return tools
- def _find_function_based_tools(self, module, file_path: Path, module_name: str) -> List[ToolInfo]:
+ def _find_function_based_tools(self, module: Any, file_path: Path, module_name: str) -> list[ToolInfo]:
"""Find @mcp_tool decorated functions in the module."""
tools = []
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_registry.py b/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_registry.py
index 4541cfb2f..b04a8aa56 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_registry.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/core/tool_registry.py
@@ -16,7 +16,6 @@
import logging
import threading
-from typing import Dict, List, Optional
from .base_tool import ToolInfo
@@ -26,9 +25,9 @@
class ToolRegistry:
"""Thread-safe registry for managing discovered tools."""
- def __init__(self):
+ def __init__(self) -> None:
"""Initialize the tool registry."""
- self._tools: Dict[str, ToolInfo] = {}
+ self._tools: dict[str, ToolInfo] = {}
self._lock = threading.RLock()
def register_tool(self, tool_info: ToolInfo) -> None:
@@ -42,7 +41,7 @@ def register_tool(self, tool_info: ToolInfo) -> None:
self._tools[tool_info.name] = tool_info
logger.info(f"Registered tool: {tool_info.name}")
- def register_tools(self, tools: List[ToolInfo]) -> None:
+ def register_tools(self, tools: list[ToolInfo]) -> None:
"""
Register multiple tools in the registry.
@@ -71,7 +70,7 @@ def unregister_tool(self, tool_name: str) -> bool:
return True
return False
- def get_tool(self, tool_name: str) -> Optional[ToolInfo]:
+ def get_tool(self, tool_name: str) -> ToolInfo | None:
"""
Get a tool by name.
@@ -84,7 +83,7 @@ def get_tool(self, tool_name: str) -> Optional[ToolInfo]:
with self._lock:
return self._tools.get(tool_name)
- def list_tools(self) -> List[ToolInfo]:
+ def list_tools(self) -> list[ToolInfo]:
"""
Get a list of all registered tools.
@@ -94,7 +93,7 @@ def list_tools(self) -> List[ToolInfo]:
with self._lock:
return list(self._tools.values())
- def list_tool_names(self) -> List[str]:
+ def list_tool_names(self) -> list[str]:
"""
Get a list of all registered tool names.
@@ -110,7 +109,7 @@ def clear(self) -> None:
self._tools.clear()
logger.info("Cleared all tools from registry")
- def update_registry(self, new_tools: List[ToolInfo]) -> None:
+ def update_registry(self, new_tools: list[ToolInfo]) -> None:
"""
Update the registry with a new set of tools.
This replaces all existing tools.
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py b/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py
index 4cee09f04..e78fd5ecc 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/server/auth.py
@@ -19,7 +19,7 @@
from datetime import datetime
from pathlib import Path
from time import time
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
import jwt
@@ -62,12 +62,13 @@ def is_idp_used() -> bool:
raise RuntimeError("No crypto support for JWT.")
-def get_oidc_metadata(cert_path: Optional[str] = None) -> Dict[str, Any]:
+def get_oidc_metadata(cert_path: str | None = None) -> dict[str, Any]:
"""Get OIDC endpoints and metadata from authority."""
authority = os.environ.get("AUTHORITY")
resp = requests.get(f"{authority}/.well-known/openid-configuration", verify=cert_path or True, timeout=30)
resp.raise_for_status()
- return resp.json() # type: ignore
+ result: dict[str, Any] = resp.json()
+ return result
def get_jwks_client() -> jwt.PyJWKClient:
@@ -87,11 +88,11 @@ def get_jwks_client() -> jwt.PyJWKClient:
def id_token_is_valid(
id_token: str, client_id: str, authority: str, jwks_client: jwt.PyJWKClient
-) -> Optional[Dict[str, Any]]:
+) -> dict[str, Any] | None:
"""Check whether an ID token is valid and return decoded data."""
try:
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
- data: Dict[str, Any] = jwt.decode(
+ data: dict[str, Any] = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
@@ -124,7 +125,7 @@ def is_user_in_group(jwt_data: dict[str, Any], group: str, jwt_groups_property:
return group in current_node
-def get_authorization_token(headers: Dict[str, str], header_name: str = "Authorization") -> str:
+def get_authorization_token(headers: dict[str, str], header_name: str = "Authorization") -> str:
"""Get Bearer token from Authorization headers if it exists."""
if header_name in headers:
return headers.get(header_name, "").removeprefix("Bearer").strip()
@@ -132,10 +133,10 @@ def get_authorization_token(headers: Dict[str, str], header_name: str = "Authori
class LoggingMiddleware(BaseHTTPMiddleware):
- def __init__(self, app: ASGIApp, dispatch: DispatchFunction | None = None):
+ def __init__(self, app: ASGIApp, dispatch: DispatchFunction | None = None) -> None:
super().__init__(app, dispatch)
- async def dispatch(self, request, call_next):
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
response = await call_next(request)
response.headers["Custom"] = "Example"
return response
@@ -144,22 +145,22 @@ async def dispatch(self, request, call_next):
class OIDCHTTPBearer(BaseHTTPMiddleware):
"""OIDC based bearer token authenticator."""
- def __init__(self, app: ASGIApp, dispatch: DispatchFunction | None = None):
+ def __init__(self, app: ASGIApp, dispatch: DispatchFunction | None = None) -> None:
super().__init__(app, dispatch)
self._token_authorizer = ApiTokenAuthorizer()
self._management_token_authorizer = ManagementTokenAuthorizer()
self._jwks_client = get_jwks_client()
- async def dispatch(self, request: Request, call_next) -> Response:
+ async def dispatch(self, request: Request, call_next: Any) -> Response:
"""Verify the provided bearer token or API Key. API Key will take precedence over the bearer token."""
if request.method == "OPTIONS":
return await call_next(request)
valid = False
- if self._token_authorizer.is_valid_api_token(request.headers):
+ if self._token_authorizer.is_valid_api_token(dict(request.headers)):
logger.info("looks like a valid api token")
valid = True
- elif self._management_token_authorizer.is_valid_api_token(request.headers):
+ elif self._management_token_authorizer.is_valid_api_token(dict(request.headers)):
logger.info("looks like a valid mgmt token")
valid = True
else:
@@ -205,7 +206,7 @@ def _get_token_info(self, token: str) -> Any:
ddb_response = self._token_table.get_item(Key={"token": token}, ReturnConsumedCapacity="NONE")
return ddb_response.get("Item", None)
- def is_valid_api_token(self, headers: Dict[str, str]) -> bool:
+ def is_valid_api_token(self, headers: dict[str, str]) -> bool:
"""Return if API Token from request headers is valid if found."""
for header_name in API_KEY_HEADER_NAMES:
token = get_authorization_token(headers, header_name)
@@ -248,7 +249,7 @@ def _refreshTokens(self) -> None:
self._secret_tokens = secret_tokens
self._last_run = current_time
- def is_valid_api_token(self, headers: Dict[str, str]) -> bool:
+ def is_valid_api_token(self, headers: dict[str, str]) -> bool:
"""Return if API Token from request headers is valid if found."""
self._refreshTokens()
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 2868c3f99..7ecb4eda3 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/server/mcp_server.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/server/mcp_server.py
@@ -19,11 +19,12 @@
import logging
import sys
from datetime import datetime
-from typing import Any, Dict, List
+from typing import Any
from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
+from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
@@ -51,7 +52,7 @@ def __init__(self, config: ServerConfig, tool_discovery: ToolDiscovery, tool_reg
self.config = config
self.tool_discovery = tool_discovery
self.tool_registry = tool_registry
- self.registered_tools: Dict[str, Any] = {}
+ self.registered_tools: dict[str, Any] = {}
# Create FastMCP application
self.app = FastMCP("mcpworkbench")
@@ -60,20 +61,20 @@ def __init__(self, config: ServerConfig, tool_discovery: ToolDiscovery, tool_reg
# Register built-in management tools
self._register_management_tools()
- def _register_management_tools(self):
+ def _register_management_tools(self) -> None:
"""Register built-in management tools - now removed as they are HTTP routes."""
# Management functionality moved to HTTP GET endpoints
pass
- def _add_management_routes(self, app: Starlette):
+ def _add_management_routes(self, app: Starlette) -> None:
if self.config.exit_route_path:
- async def exit_endpoint(request):
+ async def exit_endpoint(request: Request) -> JSONResponse:
"""HTTP GET endpoint to gracefully shutdown the server."""
logger.info("Exit requested via HTTP endpoint")
# Schedule shutdown after response is sent
- async def delayed_shutdown():
+ async def delayed_shutdown() -> None:
await asyncio.sleep(1)
logger.info("Shutting down server...")
sys.exit(0)
@@ -91,7 +92,7 @@ async def delayed_shutdown():
if self.config.rescan_route_path:
- async def rescan_endpoint(request):
+ async def rescan_endpoint(request: Request) -> JSONResponse:
"""HTTP GET endpoint to rescan tools directory and reload tools."""
try:
logger.info("Rescanning tools directory via HTTP...")
@@ -134,12 +135,12 @@ async def rescan_endpoint(request):
app.add_route(self.config.rescan_route_path, rescan_endpoint, methods=["GET"])
- def _create_starlette_app(self):
+ def _create_starlette_app(self) -> Starlette:
"""Create Starlette application with MCP and HTTP routes."""
mcp_app = self.app.http_app(path="/", transport="streamable-http", stateless_http=True)
- async def health_check(request):
+ async def health_check(request: Request) -> JSONResponse:
"""Health check endpoint for Docker health checks."""
return JSONResponse({"status": "healthy", "service": "mcpworkbench"})
@@ -163,7 +164,7 @@ async def health_check(request):
return Starlette(routes=routes, lifespan=mcp_app.lifespan)
- async def _register_discovered_tools(self, tools: List[ToolInfo]):
+ async def _register_discovered_tools(self, tools: list[ToolInfo]) -> None:
"""Register discovered tools with FastMCP."""
for tool_info in tools:
try:
@@ -171,7 +172,7 @@ async def _register_discovered_tools(self, tools: List[ToolInfo]):
except Exception as e:
logger.error(f"Failed to register tool {tool_info.name}: {e}")
- async def _register_single_tool(self, tool_info: ToolInfo):
+ async def _register_single_tool(self, tool_info: ToolInfo) -> None:
"""Register a single discovered tool with FastMCP."""
if tool_info.tool_type == ToolType.CLASS_BASED:
await self._register_class_tool(tool_info)
@@ -180,7 +181,7 @@ async def _register_single_tool(self, tool_info: ToolInfo):
else:
logger.error(f"Unknown tool type for {tool_info.name}: {tool_info.tool_type}")
- async def _register_class_tool(self, tool_info: ToolInfo):
+ async def _register_class_tool(self, tool_info: ToolInfo) -> None:
"""Register a class-based tool with FastMCP."""
if not isinstance(tool_info.tool_instance, BaseTool):
raise ValueError(f"Class tool {tool_info.name} instance must be a BaseTool")
@@ -198,7 +199,7 @@ async def _register_class_tool(self, tool_info: ToolInfo):
self.registered_tools[tool_info.name] = tool_info
logger.debug(f"Registered class-based tool: {tool_info.name}")
- async def _register_function_tool(self, tool_info: ToolInfo):
+ async def _register_function_tool(self, tool_info: ToolInfo) -> None:
"""Register a function-based tool with FastMCP."""
if not callable(tool_info.tool_instance):
raise ValueError(f"Function tool {tool_info.name} instance must be callable")
@@ -213,7 +214,7 @@ async def _register_function_tool(self, tool_info: ToolInfo):
wrapper_func = function
else:
# Wrap sync function to be async
- async def async_wrapper(**kwargs):
+ async def async_wrapper(**kwargs: Any) -> Any:
return function(**kwargs)
wrapper_func = async_wrapper
@@ -227,7 +228,7 @@ async def async_wrapper(**kwargs):
self.registered_tools[tool_info.name] = tool_info
logger.debug(f"Registered function-based tool: {tool_info.name}")
- async def discover_and_register_tools(self):
+ async def discover_and_register_tools(self) -> list[ToolInfo]:
"""Discover and register initial tools."""
logger.info("Discovering initial tools...")
tools = self.tool_discovery.discover_tools()
@@ -240,7 +241,7 @@ async def discover_and_register_tools(self):
return tools
- async def start(self):
+ async def start(self) -> None:
"""Start the server."""
# Discover and register tools
await self.discover_and_register_tools()
@@ -271,7 +272,7 @@ async def start(self):
server = uvicorn.Server(config)
await server.serve()
- def run(self):
+ def run(self) -> None:
"""Run the server (blocking)."""
# Use a more robust approach to handle event loops
asyncio.run(self.start())
diff --git a/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py b/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py
index df53d5a16..603d026bf 100644
--- a/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py
+++ b/lib/serve/mcp-workbench/src/mcpworkbench/server/middleware.py
@@ -16,8 +16,9 @@
import logging
import sys
+from collections.abc import Callable
from datetime import datetime
-from typing import Callable
+from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.cors import CORSMiddleware as StarletteCORSMiddleware
@@ -34,7 +35,7 @@
class CORSMiddleware(StarletteCORSMiddleware):
"""CORS middleware wrapper for configuration compatibility."""
- def __init__(self, app, cors_config: CORSConfig):
+ def __init__(self, app: Any, cors_config: CORSConfig) -> None:
super().__init__(
app,
allow_origins=cors_config.allow_origins,
@@ -49,7 +50,7 @@ def __init__(self, app, cors_config: CORSConfig):
class ExitRouteMiddleware(BaseHTTPMiddleware):
"""Middleware to handle application exit requests."""
- def __init__(self, app, exit_path: str):
+ def __init__(self, app: Any, exit_path: str) -> None:
super().__init__(app)
self.exit_path = exit_path.rstrip("/")
@@ -76,7 +77,7 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response:
# Continue with normal request processing
return await call_next(request)
- async def _delayed_exit(self):
+ async def _delayed_exit(self) -> None:
"""Exit the application after a short delay."""
import asyncio # noqa: PLC0415
@@ -88,7 +89,7 @@ async def _delayed_exit(self):
class RescanMiddleware(BaseHTTPMiddleware):
"""Middleware to handle tool rescanning requests."""
- def __init__(self, app, rescan_path: str, tool_discovery: ToolDiscovery, tool_registry: ToolRegistry):
+ def __init__(self, app: Any, rescan_path: str, tool_discovery: ToolDiscovery, tool_registry: ToolRegistry) -> None:
super().__init__(app)
self.rescan_path = rescan_path.rstrip("/")
self.tool_discovery = tool_discovery
diff --git a/lib/serve/mcp-workbench/test_install.py b/lib/serve/mcp-workbench/test_install.py
index 089396ef1..77dac6ad6 100644
--- a/lib/serve/mcp-workbench/test_install.py
+++ b/lib/serve/mcp-workbench/test_install.py
@@ -20,12 +20,13 @@
import subprocess
import sys
+from typing import Any
from mcpworkbench.core.annotations import mcp_tool
from mcpworkbench.core.base_tool import BaseTool
-def test_cli_available():
+def test_cli_available() -> bool:
"""Test that the CLI command is available."""
try:
@@ -45,15 +46,15 @@ def test_cli_available():
return False
-def test_basic_functionality():
+def test_basic_functionality() -> bool:
"""Test basic functionality works."""
try:
class TestTool(BaseTool):
- def __init__(self):
+ def __init__(self) -> None:
super().__init__("test", "A test tool")
- async def execute(self, **kwargs):
+ async def execute(self, **kwargs: Any) -> dict[str, str]: # type: ignore[override]
return {"result": "test successful"}
# Test tool instantiation
@@ -64,7 +65,7 @@ async def execute(self, **kwargs):
# Test annotation
@mcp_tool(name="test_func", description="Test function")
- def test_func():
+ def test_func() -> str:
return "annotated test successful"
assert hasattr(test_func, "_is_mcp_tool")
@@ -77,7 +78,7 @@ def test_func():
return False
-def main():
+def main() -> bool:
"""Run all installation tests."""
print("Testing MCP Workbench installation...")
print("=" * 50)
diff --git a/lib/serve/mcpWorkbenchConstruct.ts b/lib/serve/mcpWorkbenchConstruct.ts
index 7620d98b5..dbf99e011 100644
--- a/lib/serve/mcpWorkbenchConstruct.ts
+++ b/lib/serve/mcpWorkbenchConstruct.ts
@@ -31,6 +31,7 @@ import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import { ECSCluster, ECSTasks } from '../api-base/ecsCluster';
import { Ec2Service } from 'aws-cdk-lib/aws-ecs';
+import { BlockPublicAccess, BucketEncryption } from 'aws-cdk-lib/aws-s3';
export type McpWorkbenchConstructProps = {
restApiId: string;
@@ -71,7 +72,10 @@ export class McpWorkbenchConstruct extends Construct {
const workbenchBucket = this.createWorkbenchBucket(scope, config);
this.createWorkbenchApi(restApi, config, vpc, securityGroups, workbenchBucket, lambdaLayers, authorizer);
- this.createWorkbenchService(apiCluster, config, vpc);
+
+ if (config.deployMcpWorkbench) {
+ this.createWorkbenchService(apiCluster, config, vpc);
+ }
}
private createWorkbenchApi (restApi: IRestApi, config: Config, vpc: Vpc, securityGroups: ISecurityGroup[], workbenchBucket: s3.Bucket, lambdaLayers: lambda.ILayerVersion[], authorizer?: IAuthorizer) {
@@ -188,7 +192,9 @@ export class McpWorkbenchConstruct extends Construct {
enforceSSL: true,
serverAccessLogsBucket: bucketAccessLogsBucket,
serverAccessLogsPrefix: 'logs/mcpworkbench-bucket/',
- eventBridgeEnabled: true
+ eventBridgeEnabled: true,
+ blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
+ encryption: BucketEncryption.S3_MANAGED
});
}
diff --git a/lib/serve/rest-api/.coveragerc b/lib/serve/rest-api/.coveragerc
new file mode 100644
index 000000000..5d1b54764
--- /dev/null
+++ b/lib/serve/rest-api/.coveragerc
@@ -0,0 +1,18 @@
+[run]
+omit =
+ # Exclude __init__ files
+ */src/*/__init__.py
+ */src/*/*/__init__.py
+ */src/*/*/*/__init__.py
+ */src/*/*/*/*/__init__.py
+ # Exclude FastAPI endpoint wrappers (thin wrappers around handlers)
+ */src/api/endpoints/v1/*.py
+ */src/api/endpoints/v2/*.py
+ # Exclude main application file (requires full integration test)
+ */src/main.py
+ # Exclude model adapters (require actual model endpoints)
+ */src/lisa_serve/ecs/textgen/*.py
+ */src/lisa_serve/ecs/embedding/*.py
+ */src/lisa_serve/base/*.py
+ # Exclude routes (thin wrappers)
+ */src/api/routes.py
diff --git a/lib/serve/rest-api/Dockerfile b/lib/serve/rest-api/Dockerfile
index 39d00791a..1da725049 100644
--- a/lib/serve/rest-api/Dockerfile
+++ b/lib/serve/rest-api/Dockerfile
@@ -1,15 +1,33 @@
-ARG BASE_IMAGE=python:3.13-slim
+ARG BASE_IMAGE=public.ecr.aws/docker/library/python:3.13-slim
FROM ${BASE_IMAGE}
ARG PRISMA_CACHE_DIR=PRISMA_CACHE
ENV PRISMA_CACHE_DIR=$PRISMA_CACHE_DIR
+# Apply SSH security hardening - disable weak ciphers (3DES-CBC, etc.)
+RUN mkdir -p /etc/ssh && \
+ echo "" >> /etc/ssh/ssh_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/ssh_config && \
+ echo "Host *" >> /etc/ssh/ssh_config && \
+ echo " Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/ssh_config && \
+ echo " KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/ssh_config && \
+ if [ -f /etc/ssh/sshd_config ]; then \
+ echo "" >> /etc/ssh/sshd_config && \
+ echo "# LISA Security Hardening - Disable weak ciphers" >> /etc/ssh/sshd_config && \
+ echo "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "MACs hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com" >> /etc/ssh/sshd_config && \
+ echo "KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512" >> /etc/ssh/sshd_config; \
+ fi
+
# Install build dependencies for madoka package
-RUN apt-get update && apt-get install -y \
- gcc \
- g++ \
- make \
- && rm -rf /var/lib/apt/lists/*
+RUN if command -v apt-get >/dev/null 2>&1; then \
+ apt-get update && apt-get install -y gcc g++ make procps && rm -rf /var/lib/apt/lists/*; \
+ elif command -v yum >/dev/null 2>&1; then \
+ yum install -y gcc gcc-c++ make procps-ng && yum clean all; \
+ elif command -v apk >/dev/null 2>&1; then \
+ apk add --no-cache gcc g++ make musl-dev procps; \
+ fi
# Copy LiteLLM config directly out of the LISA config.yaml file
ARG LITELLM_CONFIG
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 3bcb353c0..68f7ad1e4 100644
--- a/lib/serve/rest-api/src/api/endpoints/v1/models.py
+++ b/lib/serve/rest-api/src/api/endpoints/v1/models.py
@@ -15,7 +15,6 @@
"""Model information routes."""
import logging
-from typing import List, Optional
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
@@ -54,7 +53,7 @@ async def describe_model(
@router.get(f"/{RestApiResource.DESCRIBE_MODELS}")
async def describe_models(
- model_types: Optional[List[ModelType]] = Query(
+ model_types: list[ModelType] | None = Query(
None,
description="The types of models to list. If not provided, all types will be listed.",
alias="modelTypes",
@@ -71,7 +70,7 @@ async def describe_models(
@router.get(f"/{RestApiResource.LIST_MODELS}")
async def list_models(
- model_types: Optional[List[ModelType]] = Query(
+ model_types: list[ModelType] | None = Query(
None,
description="The types of models to list. If not provided, all types will be listed.",
alias="modelTypes",
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 16eddbfbb..bc6e0f2d4 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,20 +14,20 @@
"""Model invocation routes."""
+import fnmatch
import json
import logging
import os
+import uuid
from collections.abc import Iterator
-from typing import Union
import boto3
-import requests
+from auth import Authorizer, 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 ....auth import Authorizer, extract_user_groups_from_jwt
-from ....utils.guardrails import (
+from utils.guardrails import (
create_guardrail_json_response,
create_guardrail_streaming_response,
extract_guardrail_response,
@@ -35,7 +35,7 @@
get_model_guardrails,
is_guardrail_violation,
)
-from ....utils.metrics import publish_metrics_event
+from utils.metrics import publish_metrics_event
# 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
@@ -63,11 +63,22 @@
# 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",
@@ -85,12 +96,57 @@
LITELLM_KEY = os.environ["LITELLM_KEY"]
secrets_manager = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"])
+s3_client = boto3.client("s3", region_name=os.environ["AWS_REGION"])
+s3_bucket_name = os.environ.get("GENERATED_IMAGES_S3_BUCKET_NAME", "")
logger = logging.getLogger(__name__)
router = APIRouter()
+def _generate_presigned_video_url(key: str, content_type: str = "video/mp4") -> str:
+ """Generate a presigned URL for video content stored in S3."""
+ url: str = s3_client.generate_presigned_url(
+ "get_object",
+ Params={
+ "Bucket": s3_bucket_name,
+ "Key": key,
+ "ResponseContentType": content_type,
+ "ResponseCacheControl": "no-cache",
+ "ResponseContentDisposition": "inline",
+ },
+ ExpiresIn=3600, # URL expires in 1 hour
+ )
+ 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.
@@ -130,7 +186,7 @@ async def apply_guardrails_to_request(params: dict, model_id: str, jwt_data: dic
def handle_guardrail_violation_response(
- response: requests.Response, model_id: str, params: dict, is_streaming: bool
+ response: Response, model_id: str, params: dict, is_streaming: bool
) -> Response | None:
"""
Handle guardrail violation errors in LiteLLM responses.
@@ -179,7 +235,7 @@ def handle_guardrail_violation_response(
return None
-def generate_response(iterator: Iterator[Union[str, bytes]]) -> Iterator[str]:
+def generate_response(iterator: Iterator[str | bytes]) -> Iterator[str]:
"""For streaming responses, generate strings instead of bytes objects so that clients recognize the LLM output."""
for line in iterator:
if isinstance(line, bytes):
@@ -188,7 +244,7 @@ def generate_response(iterator: Iterator[Union[str, bytes]]) -> Iterator[str]:
yield f"{line}\n\n"
-def generate_response_with_guardrail_handling(iterator: Iterator[Union[str, bytes]], model: str) -> Iterator[str]:
+def generate_response_with_guardrail_handling(iterator: Iterator[str | bytes], model: str) -> Iterator[str]:
"""
Generate streaming responses with guardrail violation error handling.
@@ -227,8 +283,7 @@ def generate_response_with_guardrail_handling(iterator: Iterator[Union[str, byte
if guardrail_response:
# Stream the guardrail response
created = int(chunk_data.get("created", 0))
- for chunk in create_guardrail_streaming_response(guardrail_response, model, created):
- yield chunk
+ yield from create_guardrail_streaming_response(guardrail_response, model, created)
return # Stop streaming after guardrail response
else:
# Could not extract guardrail response, pass through the error
@@ -248,7 +303,7 @@ def generate_response_with_guardrail_handling(iterator: Iterator[Union[str, byte
yield f"{line}\n\n"
-@router.api_route("/{api_path:path}", methods=["GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE", "HEAD"])
+@router.api_route("/{api_path:path}", methods=["GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"])
async def litellm_passthrough(request: Request, api_path: str) -> Response:
"""
Pass requests directly to LiteLLM. LiteLLM and deployed models will respond here directly.
@@ -261,10 +316,13 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response:
headers = dict(request.headers.items())
authorizer = Authorizer()
- require_admin = api_path not in OPENAI_ROUTES
+ require_admin = not is_openai_route(api_path)
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")
+ raise HTTPException(
+ status_code=HTTP_401_UNAUTHORIZED,
+ message="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
# model management, LiteLLM requires an admin key, and that forces all requests to require a key as well. To avoid
@@ -274,19 +332,126 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response:
http_method = request.method
if http_method == "GET" or http_method == "DELETE":
- response = requests.request(method=http_method, url=litellm_path, headers=headers)
- return JSONResponse(response.json(), status_code=response.status_code)
- # not a GET or DELETE request, so expect a JSON payload as part of the request
+
+ response = requests_request(method=http_method, url=litellm_path, headers=headers)
+
+ # Check content type to handle binary responses (e.g., video content)
+ content_type = response.headers.get("content-type", "").lower()
+
+ # If it's JSON, parse and return as JSON
+ if "application/json" in content_type or "text/json" in content_type:
+ try:
+ return JSONResponse(response.json(), status_code=response.status_code)
+ except (ValueError, json.JSONDecodeError):
+ # If JSON parsing fails, fall through to return raw content
+ 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:
+ try:
+ # Extract video ID from path (e.g., videos/video_abc123/content -> video_abc123)
+ path_parts = api_path.split("/")
+ video_id = path_parts[-2] if len(path_parts) >= 2 else str(uuid.uuid4())
+
+ # Generate a unique S3 key for the video
+ file_extension = ".mp4" # Default to mp4
+ if "video/webm" in content_type:
+ file_extension = ".webm"
+ elif "video/quicktime" in content_type:
+ file_extension = ".mov"
+
+ s3_key = f"videos/{video_id}{file_extension}"
+
+ # Upload video to S3
+ s3_client.put_object(
+ Bucket=s3_bucket_name,
+ Key=s3_key,
+ Body=response.content,
+ ContentType=content_type,
+ )
+
+ # Generate presigned URL
+ presigned_url = _generate_presigned_video_url(s3_key)
+
+ # Return JSON response with presigned URL
+ return JSONResponse(
+ {
+ "url": presigned_url,
+ "s3_key": s3_key,
+ "content_type": content_type,
+ },
+ status_code=200,
+ )
+ except Exception as e:
+ logger.error(f"Error storing video to S3: {e}")
+ # Fall through to return raw content if S3 storage fails
+
+ # For other binary content (image, etc.) or non-JSON, return raw response
+ return Response(
+ content=response.content,
+ status_code=response.status_code,
+ headers=dict(response.headers),
+ 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)
+ content_type = request.headers.get("content-type", "").lower()
+ is_multipart = "multipart/form-data" in content_type
+ is_video_endpoint = "video" in api_path.lower()
+ is_image_endpoint = "image" in api_path.lower()
+
+ # Handle multipart/form-data requests (video generation with image references, image edits)
+ if is_multipart and (is_video_endpoint or is_image_endpoint):
+ try:
+ # Parse the form data
+ form = await request.form()
+
+ # Build files dict for requests library
+ files = {}
+ data = {}
+
+ for field_name, field_value in form.items():
+ # Check if it's a file field
+ if hasattr(field_value, "read"):
+ # 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)
+ 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
+ response = requests_request(
+ method=http_method, url=litellm_path, data=data, files=files, headers=forward_headers
+ )
+
+ if response.status_code != 200:
+ 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()
# Apply guardrails for chat/completions requests
if api_path in ["chat/completions", "v1/chat/completions"]:
model_id = params.get("model")
- if model_id:
+ if model_id and jwt_data:
await apply_guardrails_to_request(params, model_id, jwt_data)
if params.get("stream", False): # if a streaming request
- response = requests.request(method=http_method, url=litellm_path, json=params, headers=headers, stream=True)
+
+ response = requests_request(method=http_method, url=litellm_path, json=params, headers=headers, stream=True)
# Check for guardrail violations
model_id = params.get("model", "")
@@ -307,9 +472,13 @@ async def litellm_passthrough(request: Request, api_path: str) -> Response:
status_code=response.status_code,
)
else:
- return StreamingResponse(generate_response(response.iter_lines()), status_code=response.status_code)
+ return StreamingResponse(
+ 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)
+
+ response = requests_request(method=http_method, url=litellm_path, json=params, headers=headers)
# Check for guardrail violations
model_id = params.get("model", "")
diff --git a/lib/serve/rest-api/src/api/routes.py b/lib/serve/rest-api/src/api/routes.py
index 08e052796..a2ff9289f 100644
--- a/lib/serve/rest-api/src/api/routes.py
+++ b/lib/serve/rest-api/src/api/routes.py
@@ -17,12 +17,11 @@
import logging
import os
+from api.endpoints.v2 import litellm_passthrough
+from auth import Authorizer
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
-from ..auth import Authorizer
-from .endpoints.v2 import litellm_passthrough
-
logger = logging.getLogger(__name__)
router = APIRouter()
diff --git a/lib/serve/rest-api/src/auth.py b/lib/serve/rest-api/src/auth.py
index 6fee3708b..80091fd5c 100644
--- a/lib/serve/rest-api/src/auth.py
+++ b/lib/serve/rest-api/src/auth.py
@@ -22,19 +22,19 @@
from datetime import datetime
from enum import Enum
from pathlib import Path
-from typing import Any, Dict, Optional
+from typing import Any
import boto3
import jwt
import requests
+from auth_provider import get_authorization_provider
from cachetools import TTLCache
from cachetools.keys import hashkey
from fastapi import HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from loguru import logger
from starlette.status import HTTP_401_UNAUTHORIZED
-
-from .utils.decorators import singleton
+from utils.decorators import singleton
TOKEN_EXPIRATION_NAME = "tokenExpiration" # nosec B105
TOKEN_TABLE_NAME = "TOKEN_TABLE_NAME" # nosec B105
@@ -71,12 +71,13 @@ def values(cls) -> list[str]:
raise RuntimeError("No crypto support for JWT.")
-def get_oidc_metadata(cert_path: Optional[str] = None) -> Dict[str, Any]:
+def get_oidc_metadata(cert_path: str | None = None) -> dict[str, Any]:
"""Get OIDC endpoints and metadata from authority."""
authority = os.environ.get("AUTHORITY")
resp = requests.get(f"{authority}/.well-known/openid-configuration", verify=cert_path or True, timeout=30)
resp.raise_for_status()
- return resp.json() # type: ignore
+ result: dict[str, Any] = resp.json()
+ return result
def get_jwks_client() -> jwt.PyJWKClient:
@@ -96,11 +97,11 @@ def get_jwks_client() -> jwt.PyJWKClient:
def id_token_is_valid(
id_token: str, client_id: str, authority: str, jwks_client: jwt.PyJWKClient
-) -> Optional[Dict[str, Any]]:
+) -> dict[str, Any] | None:
"""Check whether an ID token is valid and return decoded data."""
try:
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
- data: Dict[str, Any] = jwt.decode(
+ data: dict[str, Any] = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
@@ -133,7 +134,7 @@ def is_user_in_group(jwt_data: dict[str, Any], group: str, jwt_groups_property:
return group in current_node
-def extract_user_groups_from_jwt(jwt_data: Optional[Dict[str, Any]]) -> list[str]:
+def extract_user_groups_from_jwt(jwt_data: dict[str, Any] | None) -> list[str]:
"""
Extract user groups from JWT data using the JWT_GROUPS_PROP environment variable.
@@ -160,7 +161,7 @@ def extract_user_groups_from_jwt(jwt_data: Optional[Dict[str, Any]]) -> list[str
# Traverse the property path to find groups
props = jwt_groups_property.split(".")
- current_node = jwt_data
+ current_node: Any = jwt_data
for prop in props:
if isinstance(current_node, dict) and prop in current_node:
@@ -171,13 +172,14 @@ def extract_user_groups_from_jwt(jwt_data: Optional[Dict[str, Any]]) -> list[str
# current_node should now be the groups list
if isinstance(current_node, list):
- return current_node
+ groups: list[str] = current_node
+ return groups
else:
logger.warning(f"Expected list of groups but got {type(current_node)}")
return []
-def get_authorization_token(headers: Dict[str, str], header_name: str = AuthHeaders.AUTHORIZATION) -> str:
+def get_authorization_token(headers: dict[str, str], header_name: str = AuthHeaders.AUTHORIZATION) -> str:
"""Get Bearer token from Authorization headers if it exists."""
if header_name in headers:
return headers.get(header_name, "").removeprefix("Bearer").strip()
@@ -187,19 +189,19 @@ def get_authorization_token(headers: Dict[str, str], header_name: str = AuthHead
class OIDCHTTPBearer(HTTPBearer):
"""OIDC based bearer token authenticator."""
- def __init__(self, authority: Optional[str] = None, client_id: Optional[str] = None, **kwargs: Dict[str, Any]):
+ def __init__(self, authority: str | None = None, client_id: str | None = None, **kwargs: dict[str, Any]):
super().__init__(**kwargs)
self.authority = authority or os.environ.get("AUTHORITY", "")
self.client_id = client_id or os.environ.get("CLIENT_ID", "")
self.jwks_client = get_jwks_client()
- async def id_token_is_valid(self, request: Request) -> Optional[Dict[str, Any]]:
+ async def id_token_is_valid(self, request: Request) -> dict[str, Any] | None:
"""Check whether an ID token is valid and return decoded data."""
http_auth_creds = await super().__call__(request)
id_token = http_auth_creds.credentials
try:
signing_key = self.jwks_client.get_signing_key_from_jwt(id_token)
- data: Dict[str, Any] = jwt.decode(
+ data: dict[str, Any] = jwt.decode(
id_token,
signing_key.key,
algorithms=["RS256"],
@@ -237,7 +239,7 @@ def _get_token_info(self, token_hash: str) -> Any:
ddb_response = self._token_table.get_item(Key={"token": token_hash}, ReturnConsumedCapacity="NONE")
return ddb_response.get("Item", None)
- async def is_valid_api_token(self, headers: Dict[str, str]) -> Optional[Dict[str, Any]]:
+ async def is_valid_api_token(self, headers: dict[str, str]) -> dict[str, Any] | None:
"""Return token info if API Token from request headers is valid, else None."""
for header_name in AuthHeaders.values():
@@ -268,7 +270,8 @@ async def is_valid_api_token(self, headers: Dict[str, str]) -> Optional[Dict[str
continue
# Token is valid - return the token info
- return token_info
+ result: dict[str, Any] = dict(token_info)
+ return result
return None
@@ -277,11 +280,11 @@ class ManagementTokenAuthorizer:
"""Class for checking Management tokens against a SecretsManager secret."""
def __init__(self) -> None:
- self._cache = TTLCache(maxsize=1, ttl=300)
+ self._cache: TTLCache = TTLCache(maxsize=1, ttl=300)
self._cache_lock = threading.RLock()
self._local = threading.local()
- def _get_secrets_client(self):
+ def _get_secrets_client(self) -> Any:
"""Get thread-local secrets manager client."""
if not hasattr(self._local, "secrets_manager"):
self._local.secrets_manager = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"])
@@ -293,10 +296,11 @@ def get_management_tokens(self) -> list[str]:
with self._cache_lock:
if cache_key in self._cache:
- return self._cache[cache_key]
+ cached_tokens: list[str] = self._cache[cache_key]
+ return cached_tokens
logger.info("Updating management tokens cache")
- secret_tokens = []
+ secret_tokens: list[str] = []
secret_id = os.environ.get("MANAGEMENT_KEY_NAME")
secrets_manager = self._get_secrets_client()
@@ -315,7 +319,7 @@ def get_management_tokens(self) -> list[str]:
return secret_tokens
- async def is_valid_api_token(self, headers: Dict[str, str]) -> bool:
+ async def is_valid_api_token(self, headers: dict[str, str]) -> bool:
"""Return if API Token from request headers is valid if found."""
secret_tokens = await asyncio.to_thread(self.get_management_tokens)
token = get_authorization_token(headers)
@@ -336,12 +340,13 @@ def __init__(self) -> None:
self.token_authorizer = ApiTokenAuthorizer()
self.management_token_authorizer = ManagementTokenAuthorizer()
self.oidc_authorizer = OIDCHTTPBearer(authority=self.authority, client_id=self.client_id)
+ self.auth_provider = get_authorization_provider()
- async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]:
+ async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None:
jwt_data = await self.authenticate_request(request)
return jwt_data
- async def authenticate_request(self, request: Request) -> Optional[Dict[str, Any]]:
+ async def authenticate_request(self, request: Request) -> dict[str, Any] | None:
"""Authenticate request and return JWT data if valid, else None. Invalid requests throw an exception"""
logger.trace(f"Authenticating request: {request.method} {request.url.path}")
@@ -356,7 +361,7 @@ async def authenticate_request(self, request: Request) -> Optional[Dict[str, Any
# Then try management tokens
logger.trace("Try Management Auth Token...")
- if await self.management_token_authorizer.is_valid_api_token(request.headers):
+ if await self.management_token_authorizer.is_valid_api_token(dict(request.headers)):
logger.trace("Valid Management token")
return None
@@ -383,9 +388,7 @@ def _log_access_attempt(
else:
logger.warning(log_msg)
- async def can_access(
- self, request: Request, require_admin: bool, jwt_data: Optional[Dict[str, Any]] = None
- ) -> bool:
+ async def can_access(self, request: Request, require_admin: bool, jwt_data: dict[str, Any] | None = None) -> bool:
"""Return whether the user is authorized to access the endpoint."""
endpoint = f"{request.method} {request.url.path}"
@@ -399,8 +402,8 @@ async def can_access(
user_id = token_info.get("username", "api-token")
groups = token_info.get("groups", [])
- # Check if user has admin group
- is_admin_user = self.admin_group in groups
+ # Use auth provider for admin check
+ is_admin_user = self.auth_provider.check_admin_access(user_id, groups)
if require_admin and not is_admin_user:
has_access = False
@@ -421,25 +424,27 @@ async def can_access(
auth_method = "OIDC"
user_id = jwt_data.get("sub", jwt_data.get("username", "unknown"))
+ # Use auth provider for admin and app access checks
+ is_admin_user = self.auth_provider.check_admin_access_jwt(jwt_data, self.jwt_groups_property)
+ has_app_access = self.auth_provider.check_app_access_jwt(jwt_data, self.jwt_groups_property)
+
# If user is admin, always allow access
- if is_user_in_group(jwt_data, self.admin_group, self.jwt_groups_property):
+ if is_admin_user:
has_access = True
reason = "Admin user"
# If admin is required but user is not admin, deny access
elif require_admin:
has_access = False
reason = "Admin required"
- # For non-admin requests, check user group
+ # For non-admin requests, check app access
else:
- has_access = self.user_group == "" or is_user_in_group(
- jwt_data=jwt_data, group=self.user_group, jwt_groups_property=self.jwt_groups_property
- )
+ has_access = has_app_access
reason = "Valid user group" if has_access else "Invalid user group"
self._log_access_attempt(request, auth_method, user_id, endpoint, has_access, reason)
return has_access
- def _set_token_context(self, request: Request, token_info: Dict[str, Any]) -> None:
+ def _set_token_context(self, request: Request, token_info: dict[str, Any]) -> None:
"""Store token info in request state for later access."""
request.state.api_token_info = token_info
request.state.username = token_info.get("username", "api-token")
diff --git a/lib/serve/rest-api/src/auth_provider.py b/lib/serve/rest-api/src/auth_provider.py
new file mode 100644
index 000000000..311a18f91
--- /dev/null
+++ b/lib/serve/rest-api/src/auth_provider.py
@@ -0,0 +1,203 @@
+# 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.
+
+"""Authorization provider abstraction for pluggable auth implementations."""
+import os
+from abc import ABC, abstractmethod
+from typing import Any
+
+from loguru import logger
+
+
+class AuthorizationProvider(ABC):
+ """Abstract base class for authorization providers.
+
+ This abstraction allows swapping between different authorization backends
+ (e.g., OIDC group-based, BRASS bindle lock) without changing the consuming code.
+ """
+
+ @abstractmethod
+ def check_admin_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if a user has admin access.
+
+ Parameters
+ ----------
+ username : str
+ The username to check admin access for
+ groups : list[str] | None
+ Optional list of groups the user belongs to (used by group-based providers)
+
+ Returns
+ -------
+ bool
+ True if user has admin access, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def check_app_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if a user has general application access.
+
+ Parameters
+ ----------
+ username : str
+ The username to check app access for
+ groups : list[str] | None
+ Optional list of groups the user belongs to (used by group-based providers)
+
+ Returns
+ -------
+ bool
+ True if user has app access, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def check_admin_access_jwt(self, jwt_data: dict[str, Any], jwt_groups_property: str) -> bool:
+ """Check if a user has admin access using JWT data.
+
+ Parameters
+ ----------
+ jwt_data : dict[str, Any]
+ The decoded JWT data
+ jwt_groups_property : str
+ The property path to extract groups from JWT
+
+ Returns
+ -------
+ bool
+ True if user has admin access, False otherwise
+ """
+ pass
+
+ @abstractmethod
+ def check_app_access_jwt(self, jwt_data: dict[str, Any], jwt_groups_property: str) -> bool:
+ """Check if a user has app access using JWT data.
+
+ Parameters
+ ----------
+ jwt_data : dict[str, Any]
+ The decoded JWT data
+ jwt_groups_property : str
+ The property path to extract groups from JWT
+
+ Returns
+ -------
+ bool
+ True if user has app access, False otherwise
+ """
+ pass
+
+
+def _get_property_path(data: dict[str, Any], property_path: str) -> list[str] | None:
+ """Extract a value from nested dict using dot-notation path."""
+ if not property_path:
+ return None
+ props = property_path.split(".")
+ current_node: Any = data
+ for prop in props:
+ if isinstance(current_node, dict) and prop in current_node:
+ current_node = current_node[prop]
+ else:
+ return None
+ if isinstance(current_node, list):
+ return current_node
+ return None
+
+
+class OIDCAuthorizationProvider(AuthorizationProvider):
+ """OIDC group-based authorization provider.
+
+ Uses JWT group claims to determine admin and app access.
+ """
+
+ def __init__(self, admin_group: str | None = None, user_group: str | None = None):
+ """Initialize the OIDC authorization provider.
+
+ Parameters
+ ----------
+ admin_group : str | None
+ The admin group name. If not provided, uses ADMIN_GROUP env var.
+ user_group : str | None
+ The user group name. If not provided, uses USER_GROUP env var.
+ """
+ self.admin_group = admin_group or os.environ.get("ADMIN_GROUP", "")
+ self.user_group = user_group or os.environ.get("USER_GROUP", "")
+
+ def check_admin_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if user has admin access based on group membership."""
+ if not groups:
+ logger.debug(f"No groups provided for user {username}")
+ return False
+
+ is_admin = self.admin_group in groups
+ logger.info(f"User {username} admin check: groups={groups}, admin_group={self.admin_group}, result={is_admin}")
+ return is_admin
+
+ def check_app_access(self, username: str, groups: list[str] | None = None) -> bool:
+ """Check if user has app access based on group membership."""
+ if not self.user_group:
+ return True
+
+ if not groups:
+ logger.debug(f"No groups provided for user {username}")
+ return False
+
+ has_access = self.user_group in groups
+ logger.info(
+ f"User {username} app access check: groups={groups}, user_group={self.user_group}, result={has_access}"
+ )
+ return has_access
+
+ def check_admin_access_jwt(self, jwt_data: dict[str, Any], jwt_groups_property: str) -> bool:
+ """Check if user has admin access using JWT data."""
+ groups = _get_property_path(jwt_data, jwt_groups_property) or []
+ return self.admin_group in groups
+
+ def check_app_access_jwt(self, jwt_data: dict[str, Any], jwt_groups_property: str) -> bool:
+ """Check if user has app access using JWT data."""
+ if not self.user_group:
+ return True
+ groups = _get_property_path(jwt_data, jwt_groups_property) or []
+ return self.user_group in groups
+
+
+# Singleton instance for the authorization provider
+_auth_provider: AuthorizationProvider | None = None
+
+
+def get_authorization_provider() -> AuthorizationProvider:
+ """Get the configured authorization provider instance.
+
+ Returns
+ -------
+ AuthorizationProvider
+ The authorization provider instance (OIDC-based for LISA)
+ """
+ global _auth_provider
+ if _auth_provider is None:
+ _auth_provider = OIDCAuthorizationProvider()
+ return _auth_provider
+
+
+def set_authorization_provider(provider: AuthorizationProvider) -> None:
+ """Set a custom authorization provider (useful for testing).
+
+ Parameters
+ ----------
+ provider : AuthorizationProvider
+ The authorization provider to use
+ """
+ global _auth_provider
+ _auth_provider = provider
diff --git a/lib/serve/rest-api/src/entrypoint.sh b/lib/serve/rest-api/src/entrypoint.sh
index 63180044f..b96de0a7c 100644
--- a/lib/serve/rest-api/src/entrypoint.sh
+++ b/lib/serve/rest-api/src/entrypoint.sh
@@ -48,7 +48,7 @@ if [ "${DEBUG}" = "true" ]; then
GUNICORN_LOG_LEVEL="debug"
PRISMA_LOG_LEVEL="info,query"
else
- LOG_LEVEL="INFO"
+ LOG_LEVEL="${LITELLM_LOG_LEVEL:-WARNING}"
GUNICORN_LOG_LEVEL="info"
PRISMA_LOG_LEVEL="warn"
fi
@@ -56,12 +56,40 @@ fi
# Configure LiteLLM logging
export LITELLM_LOG=${LOG_LEVEL}
export LITELLM_JSON_LOGS=${LITELLM_JSON_LOGS:-false}
-export LITELLM_DISABLE_HEALTH_CHECK_LOGS=${LITELLM_DISABLE_HEALTH_CHECK_LOGS:-false}
+export LITELLM_DISABLE_HEALTH_CHECK_LOGS=${LITELLM_DISABLE_HEALTH_CHECK_LOGS:-true}
# Configure Prisma logging
export PRISMA_LOG_LEVEL=${PRISMA_LOG_LEVEL}
+# Wait for database to be reachable before starting LiteLLM
+# This prevents startup errors from race conditions
+if [ -n "$DATABASE_HOST" ] && [ -n "$DATABASE_PORT" ]; then
+ echo "🔍 Checking database connectivity..."
+ echo " - Host: $DATABASE_HOST"
+ echo " - Port: $DATABASE_PORT"
+
+ MAX_RETRIES=30
+ RETRY_INTERVAL=2
+ retry_count=0
+
+ while [ $retry_count -lt $MAX_RETRIES ]; do
+ if timeout 5 bash -c "echo > /dev/tcp/$DATABASE_HOST/$DATABASE_PORT" 2>/dev/null; then
+ echo "✅ Database is reachable"
+ break
+ fi
+ retry_count=$((retry_count + 1))
+ echo " - Waiting for database... (attempt $retry_count/$MAX_RETRIES)"
+ sleep $RETRY_INTERVAL
+ done
+
+ if [ $retry_count -eq $MAX_RETRIES ]; then
+ echo "⚠️ Database not reachable after $MAX_RETRIES attempts, proceeding anyway..."
+ fi
+fi
+
# Start LiteLLM in the background with better error handling
+# Note: For IAM RDS authentication, LiteLLM handles token refresh natively
+# when IAM_TOKEN_DB_AUTH=true is set (configured via CDK environment variables)
echo "🚀 Starting LiteLLM server..."
echo " - Config file: litellm_config.yaml"
echo " - Port: 4000 (internal)"
@@ -69,9 +97,19 @@ echo " - Database: Prisma with auto-push enabled"
echo " - Debug mode: ${DEBUG:-false}"
echo " - Log level: $LOG_LEVEL"
echo " - Prisma log level: $PRISMA_LOG_LEVEL"
+if [ "$IAM_TOKEN_DB_AUTH" = "true" ]; then
+ echo " - IAM Auth: enabled (tokens auto-refresh)"
+ echo " - Database User: $DATABASE_USER"
+fi
# Start LiteLLM and capture its PID
-litellm -c litellm_config.yaml --use_prisma_db_push > litellm.log 2>&1 &
+# Note: Transient DB connection errors may appear during IAM token refresh cycles
+# These are expected with LiteLLM < 1.81 and the service recovers automatically
+# Set LITELLM_LOG_LEVEL=INFO to see all logs, or DEBUG for verbose output
+# Use --num_workers to increase parallelism for embedding requests
+LITELLM_WORKERS=${LITELLM_WORKERS:-4}
+echo " - LiteLLM workers: $LITELLM_WORKERS"
+litellm -c litellm_config.yaml --use_prisma_db_push --num_workers "$LITELLM_WORKERS" > litellm.log 2>&1 &
LITELLM_PID=$!
echo " - LiteLLM PID: $LITELLM_PID"
@@ -96,6 +134,9 @@ echo " - Workers: $THREADS"
echo " - Timeout: 600 seconds"
echo " - Log level: $GUNICORN_LOG_LEVEL"
+# Set PYTHONPATH to include src directory so imports work correctly
+export PYTHONPATH="/app/src:${PYTHONPATH:-}"
+
exec gunicorn -k uvicorn.workers.UvicornWorker -t 600 -w "$THREADS" -b "$HOST:$PORT" \
--log-level "$GUNICORN_LOG_LEVEL" \
"src.main:app"
diff --git a/lib/serve/rest-api/src/handlers/embeddings.py b/lib/serve/rest-api/src/handlers/embeddings.py
index 73f9adb36..f6fcdde43 100644
--- a/lib/serve/rest-api/src/handlers/embeddings.py
+++ b/lib/serve/rest-api/src/handlers/embeddings.py
@@ -14,17 +14,32 @@
"""Embedding route handlers."""
import logging
-from typing import Any, Dict
+from typing import Any
-from ..utils.request_utils import validate_and_prepare_llm_request
-from ..utils.resources import RestApiResource
+from utils.request_utils import RegistryProtocol, validate_and_prepare_llm_request
+from utils.resources import RestApiResource
logger = logging.getLogger(__name__)
-async def handle_embeddings(request_data: Dict[str, Any]) -> Dict[str, Any]:
- """Handle for embeddings endpoint."""
- model, model_kwargs, text = await validate_and_prepare_llm_request(request_data, RestApiResource.EMBEDDINGS)
+async def handle_embeddings(request_data: dict[str, Any], registry: RegistryProtocol | None = None) -> dict[str, Any]:
+ """Handle for embeddings endpoint.
+
+ Parameters
+ ----------
+ request_data : dict[str, Any]
+ Request data
+ registry : RegistryProtocol | None
+ Optional registry for dependency injection (testing)
+
+ Returns
+ -------
+ dict[str, Any]
+ Embeddings response
+ """
+ model, model_kwargs, text = await validate_and_prepare_llm_request(
+ request_data, RestApiResource.EMBEDDINGS, registry
+ )
response = await model.embed_query(text=text, model_kwargs=model_kwargs)
return response.dict() # type: ignore
diff --git a/lib/serve/rest-api/src/handlers/generation.py b/lib/serve/rest-api/src/handlers/generation.py
index 313b3781f..a796c98b0 100644
--- a/lib/serve/rest-api/src/handlers/generation.py
+++ b/lib/serve/rest-api/src/handlers/generation.py
@@ -12,20 +12,43 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Generation route handlers."""
+"""Generation route handlers - refactored for testability."""
import json
import logging
-from typing import Any, AsyncGenerator, Dict, List, Tuple
+from collections.abc import AsyncGenerator
+from typing import Any
-from ..utils.request_utils import handle_stream_exceptions, validate_and_prepare_llm_request
-from ..utils.resources import RestApiResource
+from services.text_processing import (
+ map_openai_params_to_lisa,
+ parse_model_provider_from_string,
+ render_context_from_messages,
+)
+from utils.request_utils import (
+ handle_stream_exceptions,
+ RegistryProtocol,
+ validate_and_prepare_llm_request,
+)
+from utils.resources import RestApiResource
logger = logging.getLogger(__name__)
-async def handle_generate(request_data: Dict[str, Any]) -> Dict[str, Any]:
- """Handle for generate endpoint."""
- model, model_kwargs, text = await validate_and_prepare_llm_request(request_data, RestApiResource.GENERATE)
+async def handle_generate(request_data: dict[str, Any], registry: RegistryProtocol | None = None) -> dict[str, Any]:
+ """Handle for generate endpoint.
+
+ Parameters
+ ----------
+ request_data : dict[str, Any]
+ Request data
+ registry : RegistryProtocol | None
+ Optional registry for dependency injection (testing)
+
+ Returns
+ -------
+ dict[str, Any]
+ Generation response
+ """
+ model, model_kwargs, text = await validate_and_prepare_llm_request(request_data, RestApiResource.GENERATE, registry)
try:
response = await model.generate(text=text, model_kwargs=model_kwargs)
return response.dict() # type: ignore
@@ -35,55 +58,63 @@ async def handle_generate(request_data: Dict[str, Any]) -> Dict[str, Any]:
@handle_stream_exceptions
-async def handle_generate_stream(request_data: Dict[str, Any]) -> AsyncGenerator[str, None]:
- """Handle for generate_stream endpoint."""
- model, model_kwargs, text = await validate_and_prepare_llm_request(request_data, RestApiResource.GENERATE_STREAM)
- async for response in model.generate_stream(text=text, model_kwargs=model_kwargs):
- yield f"data:{json.dumps(response.dict(exclude_none=True))}\n\n"
-
+async def handle_generate_stream(
+ request_data: dict[str, Any], registry: RegistryProtocol | None = None
+) -> AsyncGenerator[str]:
+ """Handle for generate_stream endpoint.
-def render_context(messages_list: List[Dict[str, str]]) -> str:
- """Provide context string for LLM from previous messages."""
- out_str = "\n\n".join([message["content"] for message in messages_list])
- return out_str
+ Parameters
+ ----------
+ request_data : dict[str, Any]
+ Request data
+ registry : RegistryProtocol | None
+ Optional registry for dependency injection (testing)
-
-def parse_model_provider_names(model_string: str) -> Tuple[str, str]:
- """Parse out the model name and its provider name from the combined name of the two.
-
- Format is assumed to be `${model_name} (${provider_name})` and neither of the model_name or provider_name have
- a space in them. Requests using the OpenAI text generation APIs will require that model names follow this format.
+ Yields
+ ------
+ str
+ Streaming response chunks
"""
- model_parts = model_string.split()
- model_name = model_parts[0].strip()
- provider = model_parts[1].replace("(", "").replace(")", "").strip()
- return model_name, provider
+ model, model_kwargs, text = await validate_and_prepare_llm_request(
+ request_data, RestApiResource.GENERATE_STREAM, registry
+ )
+ async for response in model.generate_stream(text=text, model_kwargs=model_kwargs):
+ yield f"data:{json.dumps(response.dict(exclude_none=True))}\n\n"
@handle_stream_exceptions
async def handle_openai_generate_stream(
- request_data: Dict[str, Any], is_text_completion: bool = False
-) -> AsyncGenerator[str, None]:
- """Handle for openai_generate_stream endpoint."""
- # map OpenAI API settings (keys) with corresponding TGI model settings (values). Any unsupported options ignored.
- request_mapping = {
- "echo": "return_full_text",
- "frequency_penalty": "repetition_penalty",
- "max_tokens": "max_new_tokens",
- "seed": "seed",
- "stop": "stop_sequences",
- "temperature": "temperature",
- "top_p": "top_p",
- }
- mapped_kwargs = {
- request_mapping[k]: request_data[k] for k in request_mapping if k in request_data and request_data[k]
- }
+ request_data: dict[str, Any], is_text_completion: bool = False, registry: RegistryProtocol | None = None
+) -> AsyncGenerator[str]:
+ """Handle for openai_generate_stream endpoint.
+
+ Parameters
+ ----------
+ request_data : dict[str, Any]
+ Request data
+ is_text_completion : bool
+ Whether this is a text completion request
+ registry : RegistryProtocol | None
+ Optional registry for dependency injection (testing)
+ Yields
+ ------
+ str
+ Streaming response chunks
+ """
+ # Map OpenAI parameters to LISA parameters
+ mapped_kwargs = map_openai_params_to_lisa(request_data)
+
+ # Extract text based on completion type
if is_text_completion:
text = request_data["prompt"] # text is already a string
else:
- text = render_context(request_data["messages"]) # text must be converted from a list to a string
- model_name, provider = parse_model_provider_names(request_data["model"])
+ text = render_context_from_messages(request_data["messages"]) # convert list to string
+
+ # Parse model and provider
+ model_name, provider = parse_model_provider_from_string(request_data["model"])
+
+ # Build LISA request
lisa_request_data = {
"modelName": model_name,
"provider": provider,
@@ -91,12 +122,20 @@ async def handle_openai_generate_stream(
"streaming": request_data.get("stream", False),
"modelKwargs": mapped_kwargs,
}
+
model, model_kwargs, text = await validate_and_prepare_llm_request(
- lisa_request_data, RestApiResource.GENERATE_STREAM
+ lisa_request_data, RestApiResource.GENERATE_STREAM, registry
)
+
async for response in model.openai_generate_stream(
text=text, model_kwargs=model_kwargs, is_text_completion=is_text_completion
):
yield f"data:{json.dumps(response.dict(exclude_none=True))}\n\n"
+
if is_text_completion:
yield "data: [DONE]\n\n"
+
+
+# Keep backward compatibility - these are now just aliases to the service functions
+render_context = render_context_from_messages
+parse_model_provider_names = parse_model_provider_from_string
diff --git a/lib/serve/rest-api/src/handlers/models.py b/lib/serve/rest-api/src/handlers/models.py
index ffdd95ed4..cc159e17e 100644
--- a/lib/serve/rest-api/src/handlers/models.py
+++ b/lib/serve/rest-api/src/handlers/models.py
@@ -12,114 +12,116 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""Model route handlers."""
+"""Model route handlers - refactored for testability."""
import logging
-import time
-from collections import defaultdict
-from typing import Any, DefaultDict, Dict, List
+from typing import Any, DefaultDict
from fastapi import HTTPException
-
-from ..utils.cache_manager import get_registered_models_cache
-from ..utils.resources import ModelType
+from services.model_service import ModelService
+from utils.cache_manager import get_registered_models_cache
+from utils.resources import ModelType
logger = logging.getLogger(__name__)
-async def handle_list_models(model_types: List[ModelType]) -> Dict[ModelType, Dict[str, List[str]]]:
+def _get_model_service() -> ModelService:
+ """Factory function to create ModelService with current cache.
+
+ This allows for dependency injection in tests.
+ """
+ return ModelService(get_registered_models_cache())
+
+
+async def handle_list_models(
+ model_types: list[ModelType], model_service: ModelService | None = None
+) -> dict[ModelType, dict[str, list[str]]]:
"""Handle for list_models endpoint.
Parameters
----------
model_types : List[ModelType]
- Model types to list.
-
- registered_models_cache : Dict[str, Dict[str, Any]]
- Registered models cache.
+ Model types to list
+ model_service : ModelService | None
+ Optional model service for dependency injection (testing)
Returns
-------
Dict[ModelType, Dict[str, List[str]]]
- List of model names by model type and model provider.
+ List of model names by model type and model provider
"""
- registered_models_cache = get_registered_models_cache()
- response = {model_type: registered_models_cache[model_type] for model_type in model_types}
+ service = model_service or _get_model_service()
+ return service.list_models(model_types)
- return response
-
-async def handle_openai_list_models() -> Dict[str, Any]:
+async def handle_openai_list_models(model_service: ModelService | None = None) -> dict[str, Any]:
"""Handle for list_models endpoint.
+ Parameters
+ ----------
+ model_service : ModelService | None
+ Optional model service for dependency injection (testing)
+
Returns
-------
- Dict[str, Union[str, Any]
- OpenAI-compatible response object to list Models. This only returns Text Generation models.
+ Dict[str, Any]
+ OpenAI-compatible response object to list Models
"""
- registered_models_cache = get_registered_models_cache()
-
- model_payload: List[Dict[str, Any]] = []
- for provider, models in registered_models_cache[ModelType.TEXTGEN].items():
- model_payload.extend(
- {"id": f"{model} ({provider})", "object": "model", "created": int(time.time()), "owned_by": "LISA"}
- for model in models
- )
+ service = model_service or _get_model_service()
+ return service.list_models_openai_format()
- response = {"data": model_payload, "object": "list"}
- return response
-
-async def handle_describe_model(provider: str, model_name: str) -> Dict[str, Any]:
+async def handle_describe_model(
+ provider: str, model_name: str, model_service: ModelService | None = None
+) -> dict[str, Any]:
"""Handle for describe_model endpoint.
Parameters
----------
provider : str
- Model provider name.
-
+ Model provider name
model_name : str
- Model name.
+ Model name
+ model_service : ModelService | None
+ Optional model service for dependency injection (testing)
Returns
-------
Dict[str, Any]
- Model metadata.
+ Model metadata
+
+ Raises
+ ------
+ HTTPException
+ If model metadata not found
"""
- model_key = f"{provider}.{model_name}"
- registered_models_cache = get_registered_models_cache()
- metadata = registered_models_cache["metadata"].get(model_key)
+ service = model_service or _get_model_service()
+ metadata = service.get_model_metadata(provider, model_name)
+
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, message=error_message)
+ raise HTTPException(status_code=404, detail=error_message)
- return metadata # type: ignore
+ return metadata
-async def handle_describe_models(model_types: List[ModelType]) -> DefaultDict[str, DefaultDict[str, Dict[str, Any]]]:
+async def handle_describe_models(
+ model_types: list[ModelType], model_service: ModelService | None = None
+) -> DefaultDict[str, DefaultDict[str, dict[str, Any]]]:
"""Handle for describe_models endpoint.
Parameters
----------
model_types : List[ModelType]
- Model types to list.
+ Model types to list
+ model_service : ModelService | None
+ Optional model service for dependency injection (testing)
Returns
-------
DefaultDict[str, DefaultDict[str, Dict[str, Any]]]
- Model metadata by model type, model provider, and model name.
+ Model metadata by model type, model provider, and model name
"""
- registered_models = await handle_list_models(model_types)
- registered_models_cache = get_registered_models_cache()
- response: DefaultDict[str, DefaultDict[str, Dict[str, Any]]] = defaultdict(lambda: defaultdict(dict))
-
- for model_type, providers in registered_models.items():
- response[model_type] = {} # type: ignore
- providers = providers or {}
- for provider, model_names in providers.items():
- response[model_type][provider] = [
- registered_models_cache["metadata"][f"{provider}.{model_name}"] for model_name in model_names
- ] # type: ignore
-
- return response
+ service = model_service or _get_model_service()
+ return service.describe_models(model_types)
diff --git a/lib/serve/rest-api/src/lisa_serve/base/base.py b/lib/serve/rest-api/src/lisa_serve/base/base.py
index 1e6d1fdfb..964b8de99 100644
--- a/lib/serve/rest-api/src/lisa_serve/base/base.py
+++ b/lib/serve/rest-api/src/lisa_serve/base/base.py
@@ -15,7 +15,8 @@
"""Base model adapters and responses."""
import re
from abc import ABC, abstractmethod
-from typing import Any, AsyncGenerator, Dict, List, Optional
+from collections.abc import AsyncGenerator
+from typing import Any
from pydantic import BaseModel, Field
@@ -27,30 +28,30 @@
class EmbedQueryResponse(BaseModel):
"""Response for embed_query method."""
- embeddings: List[List[float]] = Field(..., description="Batch of text embeddings.")
+ embeddings: list[list[float]] = Field(..., description="Batch of text embeddings.")
class GenerateResponse(BaseModel):
"""Response for generate method."""
generatedText: str = Field(..., description="Generated text.")
- generatedTokens: Optional[int] = Field(..., description="Number of generated tokens.")
- finishReason: Optional[str] = Field(None, description="Reason for finishing text generation.")
+ generatedTokens: int | None = Field(..., description="Number of generated tokens.")
+ finishReason: str | None = Field(None, description="Reason for finishing text generation.")
class Token(BaseModel):
"""Token for generate_stream method."""
text: str = Field(..., description="Token text.")
- special: Optional[bool] = Field(None, description="Whether token is a special token.")
+ special: bool | None = Field(None, description="Whether token is a special token.")
class GenerateStreamResponse(BaseModel):
"""Response for generate_stream method."""
token: Token
- generatedTokens: Optional[int] = Field(..., description="Number of generated tokens.")
- finishReason: Optional[str] = Field(None, description="Reason for finishing text generation.")
+ generatedTokens: int | None = Field(..., description="Number of generated tokens.")
+ finishReason: str | None = Field(None, description="Reason for finishing text generation.")
class OpenAIChatCompletionsDelta(BaseModel):
@@ -66,7 +67,7 @@ class OpenAIChatCompletionsChoice(BaseModel):
delta: OpenAIChatCompletionsDelta = Field(
..., description="A chat completion delta generated by streamed model responses."
)
- finish_reason: Optional[str] = Field(..., description="The reason the model stopped generating tokens.")
+ finish_reason: str | None = Field(..., description="The reason the model stopped generating tokens.")
index: int = Field(..., description="The index of the choice in the list of choices.")
@@ -97,7 +98,7 @@ class OpenAICompletionsChoice(BaseModel):
"""Text choice object from Completions endpoint."""
text: str = Field(..., description="A chat completion delta generated by streamed model responses.")
- finish_reason: Optional[str] = Field(..., description="The reason the model stopped generating tokens.")
+ finish_reason: str | None = Field(..., description="The reason the model stopped generating tokens.")
index: int = Field(..., description="The index of the choice in the list of choices.")
@@ -141,12 +142,12 @@ class EmbeddingModelAdapter(ABC):
Endpoint URL.
"""
- def __init__(self, *, model_name: str, endpoint_url: Optional[str] = None) -> None:
+ def __init__(self, *, model_name: str, endpoint_url: str | None = None) -> None:
self.model_name = model_name
self.endpoint_url = endpoint_url
@abstractmethod
- def embed_query(self, *, text: str, model_kwargs: Dict[str, Any]) -> EmbedQueryResponse:
+ def embed_query(self, *, text: str, model_kwargs: dict[str, Any]) -> EmbedQueryResponse:
"""Embed query.
Parameters
@@ -177,12 +178,12 @@ class TextGenModelAdapter(ABC):
Endpoint URL.
"""
- def __init__(self, *, model_name: str, endpoint_url: Optional[str] = None) -> None:
+ def __init__(self, *, model_name: str, endpoint_url: str | None = None) -> None:
self.model_name = model_name
self.endpoint_url = endpoint_url
@abstractmethod
- def generate(self, *, text: str, model_kwargs: Dict[str, Any]) -> GenerateResponse:
+ def generate(self, *, text: str, model_kwargs: dict[str, Any]) -> GenerateResponse:
"""Text generation.
Parameters
@@ -209,8 +210,8 @@ def generate_stream(
self,
*,
text: str,
- model_kwargs: Dict[str, Any],
- ) -> AsyncGenerator[GenerateStreamResponse, None]:
+ model_kwargs: dict[str, Any],
+ ) -> AsyncGenerator[GenerateStreamResponse]:
"""Text generation with token streaming.
Parameters
diff --git a/lib/serve/rest-api/src/lisa_serve/ecs/embedding/instructor.py b/lib/serve/rest-api/src/lisa_serve/ecs/embedding/instructor.py
index b4b110808..2bd532b19 100644
--- a/lib/serve/rest-api/src/lisa_serve/ecs/embedding/instructor.py
+++ b/lib/serve/rest-api/src/lisa_serve/ecs/embedding/instructor.py
@@ -13,7 +13,7 @@
# limitations under the License.
"""Model adapter and kwargs validator for ECS embedding instructor model endpoints."""
-from typing import Any, Dict
+from typing import Any
from aiohttp import ClientSession
from loguru import logger
@@ -53,7 +53,7 @@ def __init__(self, *, model_name: str, endpoint_url: str) -> None:
# PyTorch DLC has the endpoint at path /predictions/model
self.endpoint_url = f"{self.endpoint_url.rstrip('/')}/predictions/model" # type: ignore
- async def embed_query(self, *, text: str, model_kwargs: Dict[str, Any]) -> EmbedQueryResponse: # type: ignore
+ async def embed_query(self, *, text: str, model_kwargs: dict[str, Any]) -> EmbedQueryResponse: # type: ignore
"""Embed data.
Parameters
diff --git a/lib/serve/rest-api/src/lisa_serve/ecs/embedding/tei.py b/lib/serve/rest-api/src/lisa_serve/ecs/embedding/tei.py
index 6ca2c6858..df2e2f21f 100644
--- a/lib/serve/rest-api/src/lisa_serve/ecs/embedding/tei.py
+++ b/lib/serve/rest-api/src/lisa_serve/ecs/embedding/tei.py
@@ -13,7 +13,7 @@
# limitations under the License.
"""Model adapter and kwargs validator for ECS embedding instructor model endpoints."""
-from typing import Any, Dict, Union
+from typing import Any
from aiohttp import ClientSession
from loguru import logger
@@ -55,7 +55,7 @@ def __init__(self, *, model_name: str, endpoint_url: str) -> None:
self.endpoint_url = endpoint_url.rstrip("/")
- async def embed_query(self, *, text: Union[str, list[str]], model_kwargs: Dict[str, Any]) -> EmbedQueryResponse: # type: ignore # noqa: E501
+ async def embed_query(self, *, text: str | list[str], model_kwargs: dict[str, Any]) -> EmbedQueryResponse: # type: ignore # noqa: E501
"""Embed data.
Parameters
diff --git a/lib/serve/rest-api/src/lisa_serve/ecs/textgen/tgi.py b/lib/serve/rest-api/src/lisa_serve/ecs/textgen/tgi.py
index bcd92224e..29a4dcde6 100644
--- a/lib/serve/rest-api/src/lisa_serve/ecs/textgen/tgi.py
+++ b/lib/serve/rest-api/src/lisa_serve/ecs/textgen/tgi.py
@@ -15,7 +15,8 @@
"""Model adapter and kwargs validator for ECS text generation TGI model endpoints."""
import time
import uuid
-from typing import Any, AsyncGenerator, Dict, List, Optional
+from collections.abc import AsyncGenerator
+from typing import Any
from loguru import logger
from pydantic import BaseModel, confloat, Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt
@@ -82,15 +83,15 @@ class EcsTextGenTgiValidator(BaseModel):
"""
max_new_tokens: NonNegativeInt = 50
- top_k: Optional[NonNegativeInt] = None
- top_p: Optional[confloat(gt=0.0, lt=1.0)] = None # type: ignore
- typical_p: Optional[confloat(gt=0.0, lt=1.0)] = None # type: ignore
- temperature: Optional[NonNegativeFloat] = None
- repetition_penalty: Optional[PositiveFloat] = None
+ top_k: NonNegativeInt | None = None
+ top_p: confloat(gt=0.0, lt=1.0) | None = None # type: ignore
+ typical_p: confloat(gt=0.0, lt=1.0) | None = None # type: ignore
+ temperature: NonNegativeFloat | None = None
+ repetition_penalty: PositiveFloat | None = None
return_full_text: bool = False
- truncate: Optional[PositiveInt] = None
- stop_sequences: List[str] = Field(default_factory=list)
- seed: Optional[PositiveInt] = None
+ truncate: PositiveInt | None = None
+ stop_sequences: list[str] = Field(default_factory=list)
+ seed: PositiveInt | None = None
do_sample: bool = False
watermark: bool = False
@@ -113,7 +114,7 @@ def __init__(self, *, model_name: str, endpoint_url: str) -> None:
# Define client
self.client = AsyncClient(endpoint_url, timeout=60)
- async def generate(self, *, text: str, model_kwargs: Dict[str, Any]) -> GenerateResponse: # type: ignore
+ async def generate(self, *, text: str, model_kwargs: dict[str, Any]) -> GenerateResponse: # type: ignore
"""Text generation.
Parameters
@@ -143,8 +144,8 @@ async def generate(self, *, text: str, model_kwargs: Dict[str, Any]) -> Generate
return response
async def generate_stream(
- self, *, text: str, model_kwargs: Dict[str, Any]
- ) -> AsyncGenerator[GenerateStreamResponse, None]:
+ self, *, text: str, model_kwargs: dict[str, Any]
+ ) -> AsyncGenerator[GenerateStreamResponse]:
"""Text generation with token streaming.
Parameters
@@ -174,8 +175,8 @@ async def generate_stream(
yield response
async def openai_generate_stream(
- self, *, text: str, model_kwargs: Dict[str, Any], is_text_completion: bool
- ) -> AsyncGenerator[GenerateStreamResponse, None]:
+ self, *, text: str, model_kwargs: dict[str, Any], is_text_completion: bool
+ ) -> AsyncGenerator[GenerateStreamResponse]:
"""Text generation with token streaming, conforming to the OpenAI API specification.
Parameters
diff --git a/lib/serve/rest-api/src/lisa_serve/registry/index.py b/lib/serve/rest-api/src/lisa_serve/registry/index.py
index 77e23af74..eb2dfc992 100644
--- a/lib/serve/rest-api/src/lisa_serve/registry/index.py
+++ b/lib/serve/rest-api/src/lisa_serve/registry/index.py
@@ -13,14 +13,14 @@
# limitations under the License.
"""Model registry."""
-from typing import Any, Dict
+from typing import Any
class ModelRegistry:
"""Registry for model providers."""
def __init__(self) -> None:
- self.registry: Dict[str, Any] = {}
+ self.registry: dict[str, Any] = {}
def register(self, *, provider: str, adapter: Any, validator: Any) -> None:
"""Register the adapter and validator for the model provider.
@@ -38,7 +38,7 @@ def register(self, *, provider: str, adapter: Any, validator: Any) -> None:
"""
self.registry[provider] = {"adapter": adapter, "validator": validator}
- def get_assets(self, provider: str) -> Dict[str, Any]:
+ def get_assets(self, provider: str) -> dict[str, Any]:
"""Get model registry entry."""
try:
model_assets = self.registry[provider]
diff --git a/lib/serve/rest-api/src/main.py b/lib/serve/rest-api/src/main.py
index a0816e1bc..dfa6d0031 100644
--- a/lib/serve/rest-api/src/main.py
+++ b/lib/serve/rest-api/src/main.py
@@ -16,21 +16,22 @@
import json
import os
import sys
-import time
from contextlib import asynccontextmanager
-from typing import Any, Dict
-from uuid import uuid4
-from aiobotocore.session import get_session
-from fastapi import FastAPI, Request
+import boto3
+from api.routes import router
+from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import JSONResponse, Response
+from lisa_serve.registry import registry
from loguru import logger
-
-from .api.routes import router
-from .lisa_serve.registry import registry
-from .utils.cache_manager import set_registered_models_cache
-from .utils.resources import ModelType, RestApiResource
+from middleware import (
+ process_request_middleware,
+ register_exception_handlers,
+ security_middleware,
+ validate_input_middleware,
+)
+from services.model_registration import ModelRegistrationService
+from utils.cache_manager import set_registered_models_cache
logger.remove()
logger_level = os.environ.get("LOG_LEVEL", "INFO")
@@ -62,58 +63,20 @@ async def lifespan(app: FastAPI): # type: ignore
task_logger = logger.bind(event=event)
task_logger.debug("Start task", status="START")
- new_models: Dict[str, Dict[str, Any]] = {
- ModelType.EMBEDDING: {},
- ModelType.TEXTGEN: {},
- RestApiResource.EMBEDDINGS: {},
- RestApiResource.GENERATE: {},
- RestApiResource.GENERATE_STREAM: {},
- "metadata": {},
- "endpointUrls": {},
- }
+ # Create model registration service
+ registration_service = ModelRegistrationService(registry)
+
try:
verify_path = os.getenv("SSL_CERT_FILE") or None
- session = get_session()
- async with session.create_client("ssm", region_name=os.environ["AWS_REGION"], verify=verify_path) as client:
- response = await client.get_parameter(Name=os.environ["REGISTERED_MODELS_PS_NAME"])
+ # Use synchronous boto3 client - this runs once at startup so async isn't needed
+ # This avoids aiobotocore dependency which has version conflicts with litellm's boto3
+ ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], verify=verify_path)
+ response = ssm_client.get_parameter(Name=os.environ["REGISTERED_MODELS_PS_NAME"])
+
registered_models = json.loads(response["Parameter"]["Value"])
- for model in registered_models:
- provider = model["provider"]
- # provider format is `modelHosting.modelType.inferenceContainer`, example: "ecs.textgen.tgi"
- [_, _, inference_container] = provider.split(".")
- model_name = model["modelName"]
- model_type = model["modelType"]
-
- if inference_container not in ["tgi", "tei", "instructor"]: # stopgap for supporting new containers for v2
- continue # not implementing new providers inside the existing cache; cache is on deprecation path
-
- # Get default model kwargs
- validator = registry.get_assets(provider)["validator"]
- model_kwargs = validator().dict()
-
- # Get model endpoint URL
- model_key = f"{provider}.{model_name}"
- new_models["endpointUrls"][model_key] = model["endpointUrl"]
-
- # Get other model metadata to expose to endpoints
- new_models["metadata"][model_key] = {
- "provider": provider,
- "modelName": model_name,
- "modelType": model_type,
- "modelKwargs": model_kwargs,
- }
- if "streaming" in model:
- new_models["metadata"][model_key]["streaming"] = model["streaming"]
-
- # Make list of registered accessible either by ModelType and by RestApiResource
- if model_type == ModelType.EMBEDDING:
- new_models[RestApiResource.EMBEDDINGS].setdefault(provider, []).append(model_name)
- new_models[ModelType.EMBEDDING].setdefault(provider, []).append(model_name)
- elif model_type == ModelType.TEXTGEN:
- new_models[RestApiResource.GENERATE].setdefault(provider, []).append(model_name)
- new_models[ModelType.TEXTGEN].setdefault(provider, []).append(model_name)
- if model["streaming"]:
- new_models[RestApiResource.GENERATE_STREAM].setdefault(provider, []).append(model_name)
+
+ # Register all models using the service
+ new_models = registration_service.register_models(registered_models)
# Update the global cache
set_registered_models_cache(new_models)
@@ -125,6 +88,10 @@ async def lifespan(app: FastAPI): # type: ignore
app = FastAPI(lifespan=lifespan)
+
+# Register exception handlers first (before routes)
+register_exception_handlers(app)
+
app.include_router(router)
@@ -144,38 +111,25 @@ async def lifespan(app: FastAPI): # type: ignore
@app.middleware("http")
-async def process_request(request: Request, call_next: Any) -> Any:
- """Middleware for processing all HTTP requests."""
- event = "process_request"
- request_id = str(uuid4()) # Unique ID for this request
- tic = time.time()
-
- with logger.contextualize(request_id=request_id, endpoint=request.url.path):
- try:
- task_logger = logger.bind(event=event)
- task_logger.debug("Start task", status="START")
-
- # Attempt to call the next request handler
- response = await call_next(request)
-
- # If response is successful, log the finish status
- duration = time.time() - tic
- task_logger.debug(f"Finish task (took {duration:.2f} seconds)", status="FINISH")
-
- except Exception as e:
- # In case of an exception, log the error and prepare a generic response
- duration = time.time() - tic
- task_logger.exception(
- f"Error occurred during processing: {e} (took {duration:.2f} seconds)",
- status="ERROR",
- )
- response = JSONResponse(
- status_code=500,
- content={"detail": "Internal server error"},
- )
-
- # Add the unique request ID to the response headers
- if response is not None and isinstance(response, Response):
- response.headers["X-Request-ID"] = request_id
-
- return response
+async def validate_input(request, call_next): # type: ignore
+ """Middleware for validating all HTTP request inputs."""
+ return await validate_input_middleware(request, call_next)
+
+
+@app.middleware("http")
+async def process_request(request, call_next): # type: ignore
+ """Middleware for processing all HTTP requests (logging)."""
+ return await process_request_middleware(request, call_next)
+
+
+@app.middleware("http")
+async def security_check(request, call_next): # type: ignore
+ """Security middleware for input validation.
+
+ This middleware runs FIRST (before request logging) to validate:
+ - HTTP method is allowed
+ - No null bytes in path, query, or body
+ - Request body is valid JSON for POST/PUT/PATCH
+ - Request size is within limits (model proxy endpoints are exempt)
+ """
+ return await security_middleware(request, call_next)
diff --git a/lib/serve/rest-api/src/middleware/__init__.py b/lib/serve/rest-api/src/middleware/__init__.py
new file mode 100644
index 000000000..f43d4bda7
--- /dev/null
+++ b/lib/serve/rest-api/src/middleware/__init__.py
@@ -0,0 +1,26 @@
+# 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.
+
+"""Middleware modules."""
+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__ = [
+ "process_request_middleware",
+ "register_exception_handlers",
+ "security_middleware",
+ "validate_input_middleware",
+]
diff --git a/lib/serve/rest-api/src/middleware/exception_handlers.py b/lib/serve/rest-api/src/middleware/exception_handlers.py
new file mode 100644
index 000000000..b43259aa7
--- /dev/null
+++ b/lib/serve/rest-api/src/middleware/exception_handlers.py
@@ -0,0 +1,161 @@
+# 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.
+
+"""Exception handlers for the serve API.
+
+This module provides exception handlers that return proper HTTP status codes
+with generic error messages, preventing internal details from being exposed.
+"""
+
+import traceback
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from loguru import logger
+
+
+async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
+ """Handle unhandled exceptions with a generic error response.
+
+ This handler catches all unhandled exceptions and returns a generic
+ 500 error message without exposing internal details like stack traces,
+ file paths, or variable names.
+
+ Args:
+ request: The incoming FastAPI request
+ exc: The unhandled exception
+
+ Returns:
+ JSONResponse with generic error message and 500 status code
+ """
+ # Log the full exception details internally for debugging
+ logger.error(
+ f"Unhandled exception for {request.method} {request.url.path}: "
+ f"{type(exc).__name__}: {exc}\n{traceback.format_exc()}"
+ )
+
+ return JSONResponse(
+ status_code=500,
+ content={
+ "error": "Internal Server Error",
+ "message": "An unexpected error occurred",
+ },
+ )
+
+
+async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
+ """Handle FastAPI HTTPException with appropriate status codes.
+
+ This handler processes HTTPException instances and returns the
+ appropriate status code with a sanitized error message.
+
+ Args:
+ request: The incoming FastAPI request
+ exc: The HTTPException raised
+
+ Returns:
+ JSONResponse with error details and appropriate status code
+ """
+ # Log the exception for monitoring
+ logger.warning(f"HTTP exception for {request.method} {request.url.path}: {exc.status_code} - {exc.detail}")
+
+ # Map status codes to generic error types
+ error_types = {
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 405: "Method Not Allowed",
+ 409: "Conflict",
+ 413: "Payload Too Large",
+ 422: "Unprocessable Entity",
+ 429: "Too Many Requests",
+ 500: "Internal Server Error",
+ 502: "Bad Gateway",
+ 503: "Service Unavailable",
+ 504: "Gateway Timeout",
+ }
+
+ error_type = error_types.get(exc.status_code, "Error")
+
+ # For 500 errors, use a generic message to avoid leaking internal details
+ if exc.status_code >= 500:
+ message = "An unexpected error occurred"
+ else:
+ # For client errors, we can use the detail if it's a simple string
+ # but sanitize it to avoid exposing internal paths or stack traces
+ detail = str(exc.detail) if exc.detail else error_type
+ # Remove any potential file paths or stack trace indicators
+ if "/" in detail and (".py" in detail or "line" in detail.lower()):
+ message = error_type
+ else:
+ message = detail
+
+ response = JSONResponse(
+ status_code=exc.status_code,
+ content={
+ "error": error_type,
+ "message": message,
+ },
+ )
+
+ # Add headers from the exception if present
+ if exc.headers:
+ for key, value in exc.headers.items():
+ response.headers[key] = value
+
+ return response
+
+
+async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
+ """Handle Pydantic validation errors with a generic message.
+
+ This handler processes RequestValidationError instances and returns
+ a 422 status code with a generic validation error message, without
+ exposing internal field names or validation details.
+
+ Args:
+ request: The incoming FastAPI request
+ exc: The RequestValidationError raised
+
+ Returns:
+ JSONResponse with generic validation error and 422 status code
+ """
+ # Log the full validation errors internally for debugging
+ logger.warning(f"Validation error for {request.method} {request.url.path}: {exc.errors()}")
+
+ return JSONResponse(
+ status_code=422,
+ content={
+ "error": "Unprocessable Entity",
+ "message": "Request validation failed",
+ },
+ )
+
+
+def register_exception_handlers(app: FastAPI) -> None:
+ """Register all exception handlers for the serve API.
+
+ This function registers exception handlers for:
+ - Generic exceptions (500 with generic message)
+ - HTTPException (appropriate status code with sanitized message)
+ - RequestValidationError (422 with generic message)
+
+ Args:
+ app: The FastAPI application instance
+ """
+ app.add_exception_handler(Exception, generic_exception_handler)
+ app.add_exception_handler(HTTPException, http_exception_handler)
+ app.add_exception_handler(RequestValidationError, validation_exception_handler)
diff --git a/lib/serve/rest-api/src/middleware/input_validation.py b/lib/serve/rest-api/src/middleware/input_validation.py
new file mode 100644
index 000000000..68fbe13cb
--- /dev/null
+++ b/lib/serve/rest-api/src/middleware/input_validation.py
@@ -0,0 +1,177 @@
+# 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.
+
+"""Input validation middleware for FastAPI REST API."""
+from collections.abc import Callable
+from typing import Any
+
+from fastapi import Request, Response
+from fastapi.responses import JSONResponse
+from loguru import logger
+
+# Maximum request size: 10MB
+# This allows for large prompts, image uploads, and other content
+# Token limits are enforced by LiteLLM and the models themselves
+DEFAULT_MAX_REQUEST_SIZE = 10 * 1024 * 1024
+
+
+def contains_null_bytes(data: str) -> bool:
+ """
+ Check if a string contains null bytes.
+
+ Null bytes (\\x00) can be used to bypass input validation or cause
+ unexpected behavior in string processing.
+
+ Args:
+ data: String to check for null bytes
+
+ Returns:
+ True if null bytes are found, False otherwise
+ """
+ return "\x00" in data
+
+
+async def validate_input_middleware(
+ request: Request, call_next: Callable[[Request], Any], max_request_size: int = DEFAULT_MAX_REQUEST_SIZE
+) -> Response:
+ """
+ Middleware to validate request input before processing.
+
+ This middleware provides security protections against:
+ - Null byte injection attacks
+ - Oversized payload attacks
+ - Invalid HTTP methods
+
+ Args:
+ request: The incoming FastAPI request
+ call_next: The next middleware or route handler
+ max_request_size: Maximum allowed request body size in bytes (default: 10MB)
+
+ Returns:
+ Error response if validation fails, otherwise calls next handler
+ """
+ event = "validate_input"
+ task_logger = logger.bind(event=event)
+
+ # 1. Validate HTTP method
+ valid_methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
+ if request.method not in valid_methods:
+ task_logger.warning(
+ f"Invalid HTTP method: {request.method}",
+ status="ERROR",
+ )
+ return JSONResponse(
+ status_code=405,
+ content={
+ "error": "Method Not Allowed",
+ "message": f"HTTP method {request.method} is not allowed",
+ },
+ )
+
+ # 2. Validate path for null bytes
+ if contains_null_bytes(str(request.url.path)):
+ task_logger.warning(
+ "Null byte detected in path",
+ status="ERROR",
+ )
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in request path",
+ },
+ )
+
+ # 3. Validate path parameters for null bytes
+ for key, value in request.path_params.items():
+ if contains_null_bytes(key) or contains_null_bytes(str(value)):
+ task_logger.warning(
+ f"Null byte detected in path parameter: {key}",
+ status="ERROR",
+ )
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in path parameters",
+ },
+ )
+
+ # 4. Validate query parameters for null bytes
+ for key, value in request.query_params.items():
+ if contains_null_bytes(key) or contains_null_bytes(str(value)):
+ task_logger.warning(
+ f"Null byte detected in query parameter: {key}",
+ status="ERROR",
+ )
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in query parameters",
+ },
+ )
+
+ # 5. Check request size and validate body for null bytes
+ # Only check body for methods that typically have a body
+ if request.method in {"POST", "PUT", "PATCH"}:
+ # Read the body
+ body = await request.body()
+
+ # Check size
+ body_size = len(body)
+ if body_size > max_request_size:
+ task_logger.warning(
+ f"Request size {body_size} bytes exceeds maximum {max_request_size} bytes",
+ status="ERROR",
+ )
+ return JSONResponse(
+ status_code=413,
+ content={
+ "error": "Payload Too Large",
+ "message": f"Request body size exceeds maximum allowed size of {max_request_size} bytes",
+ },
+ )
+
+ # Check for null bytes in body
+ if body:
+ try:
+ body_str = body.decode("utf-8")
+ if contains_null_bytes(body_str):
+ task_logger.warning(
+ "Null byte detected in request body",
+ status="ERROR",
+ )
+ return JSONResponse(
+ status_code=400,
+ content={
+ "error": "Bad Request",
+ "message": "Invalid characters detected in request body",
+ },
+ )
+ except UnicodeDecodeError:
+ # If body is not valid UTF-8, it might be binary data (e.g., file upload)
+ # In this case, we skip null byte validation
+ pass
+
+ # Important: We need to make the body available again for the route handler
+ # FastAPI's Request.body() can only be called once, so we need to store it
+ async def receive() -> dict[str, Any]:
+ return {"type": "http.request", "body": body}
+
+ request._receive = receive
+
+ # All validations passed, call the next handler
+ response = await call_next(request)
+ return response
diff --git a/lib/serve/rest-api/src/middleware/request_middleware.py b/lib/serve/rest-api/src/middleware/request_middleware.py
new file mode 100644
index 000000000..9227542a0
--- /dev/null
+++ b/lib/serve/rest-api/src/middleware/request_middleware.py
@@ -0,0 +1,81 @@
+# 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.
+
+"""Request processing middleware."""
+import time
+from collections.abc import Callable
+from typing import Any
+from uuid import uuid4
+
+from fastapi import Request, Response
+from fastapi.responses import JSONResponse
+from loguru import logger
+from utils.header_sanitizer import get_real_client_ip, get_sanitized_headers_from_request
+
+
+async def process_request_middleware(request: Request, call_next: Callable[[Request], Any]) -> Any:
+ """Middleware for processing all HTTP requests.
+
+ Parameters
+ ----------
+ request : Request
+ The incoming request
+ call_next : Callable
+ The next middleware or route handler
+
+ Returns
+ -------
+ Response
+ The response with added request ID header
+ """
+ event = "process_request"
+ request_id = str(uuid4()) # Unique ID for this request
+ tic = time.time()
+
+ # Get real client IP for logging (sanitized)
+ client_ip = get_real_client_ip(request)
+
+ with logger.contextualize(request_id=request_id, endpoint=request.url.path, client_ip=client_ip):
+ try:
+ task_logger = logger.bind(event=event)
+ task_logger.debug("Start task", status="START")
+
+ # Sanitize headers before any logging that might include them
+ # This prevents log injection attacks via user-controlled headers
+ _ = get_sanitized_headers_from_request(request)
+
+ # Attempt to call the next request handler
+ response = await call_next(request)
+
+ # If response is successful, log the finish status
+ duration = time.time() - tic
+ task_logger.debug(f"Finish task (took {duration:.2f} seconds)", status="FINISH")
+
+ except Exception as e:
+ # In case of an exception, log the error and prepare a generic response
+ duration = time.time() - tic
+ task_logger.exception(
+ f"Error occurred during processing: {e} (took {duration:.2f} seconds)",
+ status="ERROR",
+ )
+ response = JSONResponse(
+ status_code=500,
+ content={"detail": "Internal server error"},
+ )
+
+ # Add the unique request ID to the response headers
+ if response is not None and isinstance(response, Response):
+ response.headers["X-Request-ID"] = request_id
+
+ return response
diff --git a/lib/serve/rest-api/src/middleware/security_middleware.py b/lib/serve/rest-api/src/middleware/security_middleware.py
new file mode 100644
index 000000000..f940e38e2
--- /dev/null
+++ b/lib/serve/rest-api/src/middleware/security_middleware.py
@@ -0,0 +1,211 @@
+# 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.
+
+"""Security middleware for the serve API.
+
+This middleware provides input validation and security checks for all incoming requests:
+- Null byte detection in path, query params, and body
+- HTTP method validation
+- Request body validation for POST/PUT/PATCH requests
+- Configurable size limits (with exemptions for model proxy endpoints)
+"""
+
+import fnmatch
+import json
+from collections.abc import Callable
+
+from fastapi import Request, Response
+from fastapi.responses import JSONResponse
+from loguru import logger
+
+# HTTP methods that require a request body
+METHODS_REQUIRING_BODY = {"POST", "PUT", "PATCH"}
+
+# HTTP methods that are allowed by the serve API
+ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
+
+# Default size limit for non-exempt endpoints (1MB)
+DEFAULT_MAX_SIZE = 1024 * 1024
+
+# Endpoints exempt from size limits - all serve API routes are model proxy routes
+SIZE_LIMIT_EXEMPT_PATTERNS = ["/*"]
+
+
+def contains_null_bytes(data: bytes) -> bool:
+ """Check if data contains null bytes.
+
+ Args:
+ data: Bytes to check for null bytes
+
+ Returns:
+ True if null bytes are found, False otherwise
+ """
+ return b"\x00" in data
+
+
+def is_exempt_from_size_limit(path: str, exempt_patterns: list[str] | None = None) -> bool:
+ """Check if path is exempt from size limits.
+
+ Args:
+ path: Request path to check
+ exempt_patterns: List of glob patterns for exempt endpoints
+
+ Returns:
+ True if path matches an exempt pattern, False otherwise
+ """
+ patterns = exempt_patterns or SIZE_LIMIT_EXEMPT_PATTERNS
+ for pattern in patterns:
+ if fnmatch.fnmatch(path, pattern):
+ return True
+ return False
+
+
+def create_error_response(status_code: int, error: str, message: str) -> JSONResponse:
+ """Create a standardized error response.
+
+ Args:
+ status_code: HTTP status code
+ error: Error type (e.g., "Bad Request")
+ message: User-friendly error message
+
+ Returns:
+ JSONResponse with error details
+ """
+ return JSONResponse(
+ status_code=status_code,
+ content={"error": error, "message": message},
+ )
+
+
+async def security_middleware(
+ request: Request,
+ call_next: Callable[[Request], Response],
+ default_max_size: int = DEFAULT_MAX_SIZE,
+ exempt_patterns: list[str] | None = None,
+) -> Response:
+ """Security middleware for input validation.
+
+ This middleware performs the following security checks:
+ 1. HTTP method validation - returns 405 for unsupported methods
+ 2. Null byte detection - returns 400 if null bytes found
+ 3. Request body validation - returns 400 for missing/invalid body on POST/PUT/PATCH
+ 4. Size limit enforcement - returns 413 for oversized requests (non-exempt endpoints)
+
+ Args:
+ request: The incoming FastAPI request
+ call_next: The next middleware or route handler
+ default_max_size: Default max request size in bytes for non-exempt endpoints
+ exempt_patterns: List of glob patterns for endpoints exempt from size limits
+
+ Returns:
+ Response from the next handler or an error response
+ """
+ path = request.url.path
+ method = request.method
+
+ # 1. HTTP Method Validation
+ if method not in ALLOWED_METHODS:
+ logger.warning(f"Unsupported HTTP method: {method} for path: {path}")
+ response = create_error_response(
+ status_code=405,
+ error="Method Not Allowed",
+ message=f"HTTP method {method} is not allowed",
+ )
+ response.headers["Allow"] = ", ".join(sorted(ALLOWED_METHODS))
+ return response
+
+ # 2. Check for null bytes in path
+ 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,
+ error="Bad Request",
+ message="Invalid characters detected in request",
+ )
+
+ # 3. Check for null bytes in query string
+ query_string = str(request.url.query) if request.url.query else ""
+ 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,
+ error="Bad Request",
+ message="Invalid characters detected in request",
+ )
+
+ # 4. Request body validation for methods that require a body
+ if method in METHODS_REQUIRING_BODY:
+ # Read the body
+ body = await request.body()
+
+ # Check content type to determine if we should validate body
+ content_type = request.headers.get("content-type", "").lower()
+
+ # Skip null byte check for multipart/form-data (binary file uploads contain null bytes)
+ # and other binary content types
+ is_binary_content = (
+ "multipart/form-data" in content_type
+ or "application/octet-stream" in content_type
+ or "image/" in content_type
+ or "video/" in content_type
+ or "audio/" in content_type
+ )
+
+ # Check for null bytes in body (only for text-based content)
+ 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,
+ error="Bad Request",
+ message="Invalid characters detected in request",
+ )
+
+ # Skip body validation for multipart/form-data (file uploads)
+ if "multipart/form-data" not in content_type:
+ # Check for missing body
+ if not body:
+ logger.warning(f"Missing request body for {method} request to: {path}")
+ return create_error_response(
+ status_code=400,
+ error="Bad Request",
+ message="Request body is required",
+ )
+
+ # Validate JSON body (only for JSON content types or when no content type specified)
+ if "application/json" in content_type or not content_type:
+ try:
+ json.loads(body)
+ except json.JSONDecodeError:
+ logger.warning(f"Invalid JSON in request body for path: {path}")
+ return create_error_response(
+ status_code=400,
+ error="Bad Request",
+ message="Request body must be valid JSON",
+ )
+
+ # 5. Size limit enforcement (only for non-exempt endpoints)
+ if not is_exempt_from_size_limit(path, exempt_patterns):
+ if len(body) > default_max_size:
+ logger.warning(
+ f"Request body too large ({len(body)} bytes) for path: {path}, "
+ f"max allowed: {default_max_size} bytes"
+ )
+ return create_error_response(
+ status_code=413,
+ error="Payload Too Large",
+ message="Request body exceeds maximum size",
+ )
+
+ # All validations passed, continue to next handler
+ return await call_next(request)
diff --git a/lib/serve/rest-api/src/requirements.txt b/lib/serve/rest-api/src/requirements.txt
index 291a4c3ca..29b54bfc8 100644
--- a/lib/serve/rest-api/src/requirements.txt
+++ b/lib/serve/rest-api/src/requirements.txt
@@ -1,29 +1,32 @@
-# AWS SDK - Version constrained by litellm[proxy]==1.80.9
-# litellm requires boto3==1.36.0, which requires aioboto3==13.4.0 and aiobotocore==2.18.0
-aioboto3==13.4.0
-aiobotocore==2.18.0
-boto3==1.36.0
+# AWS SDK Dependencies
+# boto3 version pinned by litellm[proxy]==1.81.3 for RDS IAM token refresh
+boto3==1.40.76
+
+# OpenTelemetry - Optional for LiteLLM Weave integration, silences import warnings
+opentelemetry-api>=1.20.0
+opentelemetry-sdk>=1.20.0
aiohttp==3.13.2
backoff==2.2.1
cachetools==6.2.2
click==8.3.1
cryptography==46.0.3
-fastapi==0.124.2
+fastapi>=0.120.1
fastapi_utils==0.8.0
-gunicorn==23.0.0
+gunicorn>=23.0.0,<24.0.0
-# LiteLLM - Constrains boto3==1.36.0 and uvicorn<0.32.0
-litellm[proxy]==1.80.9
+# LiteLLM - Upgraded to 1.81.3 for RDS IAM token refresh fix (PR #18795)
+# Fixes: "All connection attempts failed" errors every 15 minutes with IAM auth
+litellm[proxy]==1.81.3
loguru==0.7.3
-pydantic==2.12.5
-PyJWT==2.10.1
+pydantic>=2.5.0,<3.0.0
+PyJWT>=2.10.1,<3.0.0
text-generation==0.7.0
prisma==0.15.0
-pynacl==1.6.1
+pynacl>=1.5.0,<2.0.0
starlette>=0.40.0,<0.51.0
-# ASGI Server - Version constrained by litellm[proxy]==1.80.9
-# litellm requires uvicorn>=0.31.1,<0.39.0
-uvicorn>=0.31.1,<0.39.0
+# ASGI Server - Version constrained by litellm[proxy]==1.81.3
+# litellm requires uvicorn>=0.31.1,<0.32.0
+uvicorn>=0.31.1,<0.32.0
diff --git a/lib/serve/rest-api/src/services/__init__.py b/lib/serve/rest-api/src/services/__init__.py
new file mode 100644
index 000000000..382e3705c
--- /dev/null
+++ b/lib/serve/rest-api/src/services/__init__.py
@@ -0,0 +1,15 @@
+# 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.
+
+"""Service layer for REST API business logic."""
diff --git a/lib/serve/rest-api/src/services/model_registration.py b/lib/serve/rest-api/src/services/model_registration.py
new file mode 100644
index 000000000..3a66650c3
--- /dev/null
+++ b/lib/serve/rest-api/src/services/model_registration.py
@@ -0,0 +1,157 @@
+# 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.
+
+"""Model registration service."""
+from typing import Any, Protocol
+
+from utils.resources import ModelType, RestApiResource
+
+
+class RegistryProtocol(Protocol):
+ """Protocol for model registry."""
+
+ def get_assets(self, provider: str) -> dict[str, Any]:
+ """Get model assets for a provider."""
+ ...
+
+
+class ModelRegistrationService:
+ """Service for registering models from configuration."""
+
+ # Supported inference containers
+ SUPPORTED_CONTAINERS = ["tgi", "tei", "instructor"]
+
+ def __init__(self, registry: RegistryProtocol):
+ """Initialize the service.
+
+ Parameters
+ ----------
+ registry : RegistryProtocol
+ The model registry to use for getting validators
+ """
+ self.registry = registry
+
+ def create_empty_cache(self) -> dict[str, dict[str, Any]]:
+ """Create an empty model cache structure.
+
+ Returns
+ -------
+ dict[str, dict[str, Any]]
+ Empty cache with all required keys
+ """
+ return {
+ ModelType.EMBEDDING: {},
+ ModelType.TEXTGEN: {},
+ RestApiResource.EMBEDDINGS: {},
+ RestApiResource.GENERATE: {},
+ RestApiResource.GENERATE_STREAM: {},
+ "metadata": {},
+ "endpointUrls": {},
+ }
+
+ def is_supported_container(self, inference_container: str) -> bool:
+ """Check if inference container is supported.
+
+ Parameters
+ ----------
+ inference_container : str
+ The inference container name
+
+ Returns
+ -------
+ bool
+ True if supported, False otherwise
+ """
+ return inference_container in self.SUPPORTED_CONTAINERS
+
+ def register_model(self, model: dict[str, Any], cache: dict[str, dict[str, Any]]) -> None:
+ """Register a single model into the cache.
+
+ Parameters
+ ----------
+ model : dict[str, Any]
+ Model configuration with keys: provider, modelName, modelType, endpointUrl, streaming
+ cache : dict[str, dict[str, Any]]
+ The cache to update
+ """
+ provider = model["provider"]
+ model_name = model["modelName"]
+ model_type = model["modelType"]
+
+ # provider format is `modelHosting.modelType.inferenceContainer`
+ # example: "ecs.textgen.tgi"
+ parts = provider.split(".")
+ if len(parts) != 3:
+ return # Invalid provider format
+
+ inference_container = parts[2]
+
+ # Skip unsupported containers
+ if not self.is_supported_container(inference_container):
+ return
+
+ # Get default model kwargs from validator
+ validator = self.registry.get_assets(provider)["validator"]
+ model_kwargs = validator().dict()
+
+ # Build model key
+ model_key = f"{provider}.{model_name}"
+
+ # Store endpoint URL
+ cache["endpointUrls"][model_key] = model["endpointUrl"]
+
+ # Store metadata
+ cache["metadata"][model_key] = {
+ "provider": provider,
+ "modelName": model_name,
+ "modelType": model_type,
+ "modelKwargs": model_kwargs,
+ }
+ if "streaming" in model:
+ cache["metadata"][model_key]["streaming"] = model["streaming"]
+
+ # Register by model type and resource
+ if model_type == ModelType.EMBEDDING:
+ cache[RestApiResource.EMBEDDINGS].setdefault(provider, []).append(model_name)
+ cache[ModelType.EMBEDDING].setdefault(provider, []).append(model_name)
+ elif model_type == ModelType.TEXTGEN:
+ cache[RestApiResource.GENERATE].setdefault(provider, []).append(model_name)
+ cache[ModelType.TEXTGEN].setdefault(provider, []).append(model_name)
+ if model.get("streaming", False):
+ cache[RestApiResource.GENERATE_STREAM].setdefault(provider, []).append(model_name)
+
+ def register_models(self, models: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ """Register multiple models.
+
+ Parameters
+ ----------
+ models : list[dict[str, Any]]
+ List of model configurations
+
+ Returns
+ -------
+ dict[str, dict[str, Any]]
+ The populated cache
+ """
+ cache = self.create_empty_cache()
+
+ for model in models:
+ try:
+ self.register_model(model, cache)
+ except Exception: # nosec B112
+ # Skip models that fail to register - this is intentional
+ # to allow partial registration when some models are misconfigured
+ continue
+
+ return cache
diff --git a/lib/serve/rest-api/src/services/model_service.py b/lib/serve/rest-api/src/services/model_service.py
new file mode 100644
index 000000000..daef3f9ba
--- /dev/null
+++ b/lib/serve/rest-api/src/services/model_service.py
@@ -0,0 +1,122 @@
+# 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.
+
+"""Service for model operations - follows Single Responsibility Principle."""
+
+import time
+from collections import defaultdict
+from typing import Any, DefaultDict
+
+from utils.resources import ModelType
+
+
+class ModelService:
+ """Service class for model-related operations.
+
+ This class encapsulates all model listing and description logic,
+ making it easy to test without external dependencies.
+ """
+
+ def __init__(self, models_cache: dict[str, Any]):
+ """Initialize with models cache.
+
+ Parameters
+ ----------
+ models_cache : dict
+ The registered models cache
+ """
+ self.models_cache = models_cache
+
+ def list_models(self, model_types: list[ModelType]) -> dict[ModelType, dict[str, list[str]]]:
+ """List models by type.
+
+ Parameters
+ ----------
+ model_types : List[ModelType]
+ Model types to list
+
+ Returns
+ -------
+ Dict[ModelType, Dict[str, List[str]]]
+ List of model names by model type and provider
+ """
+ return {model_type: self.models_cache.get(model_type, {}) for model_type in model_types}
+
+ def list_models_openai_format(self) -> dict[str, Any]:
+ """List models in OpenAI-compatible format.
+
+ Returns
+ -------
+ Dict[str, Any]
+ OpenAI-compatible response with text generation models
+ """
+ textgen_models = self.models_cache.get(ModelType.TEXTGEN, {})
+
+ model_payload: list[dict[str, Any]] = []
+ for provider, models in textgen_models.items():
+ model_payload.extend(
+ {"id": f"{model} ({provider})", "object": "model", "created": int(time.time()), "owned_by": "LISA"}
+ for model in models
+ )
+
+ return {"data": model_payload, "object": "list"}
+
+ def get_model_metadata(self, provider: str, model_name: str) -> dict[str, Any] | None:
+ """Get metadata for a specific model.
+
+ Parameters
+ ----------
+ provider : str
+ Model provider name
+ model_name : str
+ Model name
+
+ Returns
+ -------
+ Dict[str, Any] | None
+ Model metadata or None if not found
+ """
+ model_key = f"{provider}.{model_name}"
+ metadata_cache = self.models_cache.get("metadata", {})
+ result = metadata_cache.get(model_key)
+ return result if result is not None else None
+
+ def describe_models(self, model_types: list[ModelType]) -> DefaultDict[str, DefaultDict[str, dict[str, Any]]]:
+ """Get detailed metadata for models by type.
+
+ Parameters
+ ----------
+ model_types : List[ModelType]
+ Model types to describe
+
+ Returns
+ -------
+ DefaultDict[str, DefaultDict[str, Dict[str, Any]]]
+ Model metadata by type, provider, and name
+ """
+ registered_models = self.list_models(model_types)
+ metadata_cache = self.models_cache.get("metadata", {})
+ response: DefaultDict[str, DefaultDict[str, dict[str, Any]]] = defaultdict(lambda: defaultdict(dict))
+
+ for model_type, providers in registered_models.items():
+ response[model_type] = {} # type: ignore
+ providers = providers or {}
+ for provider, model_names in providers.items():
+ response[model_type][provider] = [
+ metadata_cache[f"{provider}.{model_name}"]
+ for model_name in model_names
+ if f"{provider}.{model_name}" in metadata_cache
+ ] # type: ignore
+
+ return response
diff --git a/lib/serve/rest-api/src/services/text_processing.py b/lib/serve/rest-api/src/services/text_processing.py
new file mode 100644
index 000000000..ff670b864
--- /dev/null
+++ b/lib/serve/rest-api/src/services/text_processing.py
@@ -0,0 +1,100 @@
+# 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.
+
+"""Text processing utilities - pure functions for easy testing."""
+
+
+def render_context_from_messages(messages_list: list[dict[str, str]]) -> str:
+ """Render context string from message list.
+
+ Pure function that converts a list of messages into a single context string.
+
+ Parameters
+ ----------
+ messages_list : List[Dict[str, str]]
+ List of messages with 'content' field
+
+ Returns
+ -------
+ str
+ Concatenated message content
+ """
+ return "\n\n".join([message["content"] for message in messages_list])
+
+
+def parse_model_provider_from_string(model_string: str) -> tuple[str, str]:
+ """Parse model name and provider from combined string.
+
+ Pure function that extracts model and provider from format: "model_name (provider_name)"
+
+ Parameters
+ ----------
+ model_string : str
+ Combined model string in format "model_name (provider_name)"
+
+ Returns
+ -------
+ Tuple[str, str]
+ Model name and provider name
+
+ Raises
+ ------
+ ValueError
+ If string format is invalid
+ """
+ if not model_string or "(" not in model_string or ")" not in model_string:
+ raise ValueError(f"Invalid model string format: {model_string}")
+
+ model_parts = model_string.split()
+ if len(model_parts) < 2:
+ raise ValueError(f"Invalid model string format: {model_string}")
+
+ model_name = model_parts[0].strip()
+ provider = model_parts[1].replace("(", "").replace(")", "").strip()
+
+ if not model_name or not provider:
+ raise ValueError(f"Invalid model string format: {model_string}")
+
+ return model_name, provider
+
+
+def map_openai_params_to_lisa(request_data: dict) -> dict:
+ """Map OpenAI API parameters to LISA parameters.
+
+ Pure function that transforms OpenAI request format to LISA format.
+
+ Parameters
+ ----------
+ request_data : dict
+ OpenAI-format request data
+
+ Returns
+ -------
+ dict
+ Mapped parameters for LISA
+ """
+ # Mapping of OpenAI params to TGI/LISA params
+ param_mapping = {
+ "echo": "return_full_text",
+ "frequency_penalty": "repetition_penalty",
+ "max_tokens": "max_new_tokens",
+ "seed": "seed",
+ "stop": "stop_sequences",
+ "temperature": "temperature",
+ "top_p": "top_p",
+ }
+
+ return {
+ param_mapping[k]: request_data[k] for k in param_mapping if k in request_data and request_data[k] is not None
+ }
diff --git a/lib/serve/rest-api/src/utils/cache_manager.py b/lib/serve/rest-api/src/utils/cache_manager.py
index 3c94bbace..9f3b87660 100644
--- a/lib/serve/rest-api/src/utils/cache_manager.py
+++ b/lib/serve/rest-api/src/utils/cache_manager.py
@@ -14,7 +14,7 @@
"""Model Cache Utilities."""
import threading
-from typing import Any, Dict, Optional, Tuple
+from typing import Any
from .resources import ModelType, RestApiResource
@@ -23,7 +23,7 @@
# - RestApiResource keys (EMBEDDINGS, GENERATE, GENERATE_STREAM) contain models by endpoint.
# - 'metadata' contains detailed information about each model.
# - 'endpointUrls' contains the URLs for model instantiation.
-REGISTERED_MODELS_CACHE: Dict[str, Dict[str, Any]] = {
+REGISTERED_MODELS_CACHE: dict[str, dict[str, Any]] = {
ModelType.EMBEDDING: {},
ModelType.TEXTGEN: {},
RestApiResource.EMBEDDINGS: {},
@@ -32,32 +32,32 @@
"metadata": {},
"endpointUrls": {},
}
-MODEL_ASSETS_CACHE: Dict[str, Tuple[Any, Any]] = {}
+MODEL_ASSETS_CACHE: dict[str, tuple[Any, Any]] = {}
# Thread locks for cache operations
_REGISTERED_MODELS_LOCK = threading.RLock()
_MODEL_ASSETS_LOCK = threading.RLock()
-def get_registered_models_cache() -> Dict[str, Dict[str, Any]]:
+def get_registered_models_cache() -> dict[str, dict[str, Any]]:
"""Get the cache containing the registered models."""
with _REGISTERED_MODELS_LOCK:
return REGISTERED_MODELS_CACHE.copy()
-def get_model_assets(model_key: str) -> Optional[Tuple[Any, Any]]:
+def get_model_assets(model_key: str) -> tuple[Any, Any] | None:
"""Get the cache belonging to the model assets."""
with _MODEL_ASSETS_LOCK:
return MODEL_ASSETS_CACHE.get(model_key)
-def cache_model_assets(key: str, model_assets: Tuple[Any, Any]) -> None:
+def cache_model_assets(key: str, model_assets: tuple[Any, Any]) -> None:
"""Cache the specified model assets for the specified key."""
with _MODEL_ASSETS_LOCK:
MODEL_ASSETS_CACHE[key] = model_assets
-def set_registered_models_cache(models: Dict[str, Dict[str, Any]]) -> None:
+def set_registered_models_cache(models: dict[str, dict[str, Any]]) -> None:
"""Set the registered model cache to the specified models value."""
with _REGISTERED_MODELS_LOCK:
global REGISTERED_MODELS_CACHE
diff --git a/lib/serve/rest-api/src/utils/decorators.py b/lib/serve/rest-api/src/utils/decorators.py
index a550aa44c..659335bb3 100644
--- a/lib/serve/rest-api/src/utils/decorators.py
+++ b/lib/serve/rest-api/src/utils/decorators.py
@@ -13,14 +13,15 @@
# limitations under the License.
"""Utility decorators."""
-from typing import Any, Callable, cast, Dict, TypeVar
+from collections.abc import Callable
+from typing import Any, cast, TypeVar
T = TypeVar("T")
def singleton(cls: type[T]) -> Callable[..., T]:
"""Singleton decorator."""
- instances: Dict[type, Any] = {}
+ instances: dict[type, Any] = {}
def get_instance(*args: Any, **kwargs: Any) -> T:
if cls not in instances:
diff --git a/lib/serve/rest-api/src/utils/generate_litellm_config.py b/lib/serve/rest-api/src/utils/generate_litellm_config.py
index 6217cf3d6..1ae021f10 100644
--- a/lib/serve/rest-api/src/utils/generate_litellm_config.py
+++ b/lib/serve/rest-api/src/utils/generate_litellm_config.py
@@ -16,12 +16,44 @@
import json
import os
-from typing import Tuple
import boto3
import click
import yaml
-from rds_auth import generate_auth_token, get_lambda_role_name
+
+
+def _is_embedding_model(model: dict) -> bool:
+ """Check if a model is an embedding model based on naming conventions."""
+ model_name = model.get("modelName", "").lower()
+ model_id = model.get("modelId", "").lower()
+ return "embed" in model_name or "embed" in model_id
+
+
+def _build_model_config(model: dict) -> dict:
+ """Build LiteLLM model configuration for a registered model."""
+ model_name = model["modelName"]
+ is_embedding = _is_embedding_model(model)
+
+ # Use hosted_vllm provider for embedding models to avoid encoding_format issues
+ # LiteLLM 1.80+ has issues with openai/ provider sending invalid encoding_format to vLLM
+ if is_embedding:
+ provider_prefix = "hosted_vllm"
+ else:
+ provider_prefix = "openai"
+
+ litellm_params = {
+ "model": f"{provider_prefix}/{model_name}",
+ "api_base": model["endpointUrl"] + "/v1", # Local containers require the /v1 for OpenAI API routing.
+ }
+
+ # For embedding models, also add drop_params as a safety measure
+ if is_embedding:
+ litellm_params["drop_params"] = True
+
+ return {
+ "model_name": model["modelId"], # Use user-provided name if one given, otherwise it is the model name.
+ "litellm_params": litellm_params,
+ }
@click.command()
@@ -30,7 +62,7 @@ def generate_config(filepath: str) -> None:
"""Read LiteLLM configuration and rewrite it with LISA-deployed model information."""
ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"])
- with open(filepath, "r") as fp:
+ with open(filepath) as fp:
config_contents = yaml.safe_load(fp)
# Get and load registered models from ParameterStore
param_response = ssm_client.get_parameter(Name=os.environ["REGISTERED_MODELS_PS_NAME"])
@@ -55,6 +87,10 @@ def generate_config(filepath: str) -> None:
{
"drop_params": True, # drop unrecognized param instead of failing the request on it
"request_timeout": 600,
+ # Performance optimizations for embeddings
+ "num_retries": 2, # Reduce retries for faster failure detection
+ "retry_after": 1, # Shorter retry delay
+ "embedding_cache": True, # Enable embedding caching (if Redis configured)
}
)
@@ -62,21 +98,34 @@ def generate_config(filepath: str) -> None:
db_param_response = ssm_client.get_parameter(Name=os.environ["LITELLM_DB_INFO_PS_NAME"])
db_params = json.loads(db_param_response["Parameter"]["Value"])
- username, password = get_database_credentials(db_params)
- connection_str = (
- f"postgresql://{username}:{password}@{db_params['dbHost']}:{db_params['dbPort']}" f"/{db_params['dbName']}"
- )
+ # Check if using IAM auth - either via environment variable (preferred) or SSM parameter
+ # IAM_TOKEN_DB_AUTH is set by CDK when iamRdsAuth=true
+ use_iam_auth = os.environ.get("IAM_TOKEN_DB_AUTH", "").lower() == "true" or "passwordSecretId" not in db_params
if "general_settings" not in config_contents:
config_contents["general_settings"] = {}
- config_contents["general_settings"].update(
- {
- "store_model_in_db": True,
- "database_url": connection_str,
- "master_key": config_contents["db_key"],
- }
- )
+ if use_iam_auth:
+ config_contents["general_settings"].update(
+ {
+ "store_model_in_db": True,
+ "master_key": config_contents["db_key"],
+ }
+ )
+ else:
+ # Password auth: build connection string with password from Secrets Manager
+ username, password = get_database_credentials(db_params)
+ connection_str = (
+ f"postgresql://{username}:{password}@{db_params['dbHost']}:{db_params['dbPort']}" f"/{db_params['dbName']}"
+ )
+
+ config_contents["general_settings"].update(
+ {
+ "store_model_in_db": True,
+ "database_url": connection_str,
+ "master_key": config_contents["db_key"],
+ }
+ )
print(f"Generated config_contents file: \n{json.dumps(config_contents, indent=2)}")
@@ -85,17 +134,22 @@ def generate_config(filepath: str) -> None:
yaml.safe_dump(config_contents, fp)
-def get_database_credentials(db_params: dict[str, str]) -> Tuple:
- """Get database password from Secrets Manager or using IAM auth."""
-
- if "passwordSecretId" in db_params:
- secrets_client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"])
+def get_database_credentials(db_params: dict[str, str]) -> tuple:
+ """Get database credentials using password auth from Secrets Manager."""
+ secrets_client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"])
+ try:
secret_response = secrets_client.get_secret_value(SecretId=db_params["passwordSecretId"])
- secret = json.loads(secret_response["SecretString"])
- return (db_params["username"], secret["password"])
- else:
- iam_name = get_lambda_role_name()
- return (iam_name, generate_auth_token(db_params["dbHost"], db_params["dbPort"], iam_name))
+ except secrets_client.exceptions.ResourceNotFoundException:
+ raise RuntimeError(
+ f"Database password secret '{db_params['passwordSecretId']}' not found. "
+ "This typically occurs when switching from IAM authentication (iamRdsAuth=true) "
+ "back to password authentication (iamRdsAuth=false). The master password is "
+ "permanently deleted when IAM auth is enabled. To resolve this, either: "
+ "1) Set iamRdsAuth=true in your config, or "
+ "2) Recreate the database by deleting and redeploying the stack."
+ )
+ secret = json.loads(secret_response["SecretString"])
+ return (db_params["username"], secret["password"])
if __name__ == "__main__":
diff --git a/lib/serve/rest-api/src/utils/guardrails.py b/lib/serve/rest-api/src/utils/guardrails.py
index 1d6eb042a..d44b33475 100644
--- a/lib/serve/rest-api/src/utils/guardrails.py
+++ b/lib/serve/rest-api/src/utils/guardrails.py
@@ -18,14 +18,14 @@
import os
import re
from collections.abc import Iterator
-from typing import Any, Dict, List, Optional
+from typing import Any
import boto3
from fastapi.responses import JSONResponse
from loguru import logger
-async def get_model_guardrails(model_id: str) -> List[Dict[str, Any]]:
+async def get_model_guardrails(model_id: str) -> list[dict[str, Any]]:
"""
Query the guardrails DynamoDB table for guardrails associated with a model.
@@ -52,14 +52,14 @@ async def get_model_guardrails(model_id: str) -> List[Dict[str, Any]]:
guardrails = response.get("Items", [])
logger.debug(f"Found {len(guardrails)} guardrails for model {model_id}")
- return guardrails
+ return guardrails # type: ignore[no-any-return]
except Exception as e:
logger.error(f"Error fetching guardrails for model {model_id}: {e}")
return []
-def get_applicable_guardrails(user_groups: List[str], guardrails: List[Dict[str, Any]], model_id: str) -> List[str]:
+def get_applicable_guardrails(user_groups: list[str], guardrails: list[dict[str, Any]], model_id: str) -> list[str]:
"""
Determine which guardrails apply to a user based on group membership.
@@ -131,7 +131,7 @@ def is_guardrail_violation(error_msg: str) -> bool:
return "Violated guardrail policy" in error_msg
-def extract_guardrail_response(error_msg: str) -> Optional[str]:
+def extract_guardrail_response(error_msg: str) -> str | None:
"""
Extract the bedrock_guardrail_response from an error message.
diff --git a/lib/serve/rest-api/src/utils/header_sanitizer.py b/lib/serve/rest-api/src/utils/header_sanitizer.py
new file mode 100644
index 000000000..f9ae5ad94
--- /dev/null
+++ b/lib/serve/rest-api/src/utils/header_sanitizer.py
@@ -0,0 +1,149 @@
+# 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.
+
+"""Utility for sanitizing HTTP headers before logging to prevent log injection attacks.
+
+This module is adapted for the serve API (ECS context) where we don't have
+API Gateway event context. Instead, we extract real client IP from ECS/ALB headers.
+"""
+
+from fastapi import Request
+from loguru import logger
+
+# Security-critical headers that should be replaced with server-controlled values
+SECURITY_CRITICAL_HEADERS = {
+ "x-forwarded-for",
+ "x-forwarded-host",
+ "x-forwarded-server",
+ "x-amzn-client-id",
+ "x-real-ip",
+ "forwarded",
+}
+
+
+def get_real_client_ip(request: Request) -> str:
+ """
+ Extract the real client IP address from the request.
+
+ In ECS behind ALB, the real client IP is typically in the last entry
+ of x-forwarded-for added by the ALB, or we can use the client host.
+
+ Args:
+ request: FastAPI Request object
+
+ Returns:
+ Real client IP address, or "unknown" if not available
+ """
+ try:
+ # In ECS behind ALB, the client IP is available from the request
+ if request.client and request.client.host:
+ return str(request.client.host)
+
+ logger.warning("No client IP found in request")
+ return "unknown"
+
+ except Exception as e:
+ logger.error(f"Error extracting real client IP: {e}")
+ return "unknown"
+
+
+def sanitize_headers_for_logging(
+ headers: dict[str, str],
+ real_client_ip: str | None = None,
+) -> dict[str, str]:
+ """
+ Sanitize HTTP headers by replacing user-controlled values with server-controlled values.
+
+ This prevents attackers from manipulating security-critical headers in logs,
+ which could be used to hide their true source IP or manipulate audit trails.
+
+ Args:
+ headers: Original HTTP headers from the request
+ real_client_ip: The real client IP (from request.client.host)
+
+ Returns:
+ Dictionary of sanitized headers with security-critical values replaced
+
+ Example:
+ >>> headers = {"x-forwarded-for": "1.2.3.4, 5.6.7.8"}
+ >>> sanitized = sanitize_headers_for_logging(headers, "9.10.11.12")
+ >>> sanitized["x-forwarded-for"]
+ "9.10.11.12"
+ """
+ if not headers:
+ return {}
+
+ # Create a copy to avoid modifying the original
+ sanitized = dict(headers)
+
+ # Use provided IP or default to unknown
+ real_ip = real_client_ip or "unknown"
+
+ # Track modifications for logging
+ modifications: list[str] = []
+
+ # Replace security-critical headers with server-controlled values
+ for header_name in SECURITY_CRITICAL_HEADERS:
+ header_lower = header_name.lower()
+
+ # Find the actual header key (may have different casing)
+ actual_key = None
+ for key in sanitized.keys():
+ if key.lower() == header_lower:
+ actual_key = key
+ break
+
+ if actual_key:
+ original_value = sanitized[actual_key]
+
+ # Replace with server-controlled value
+ if header_lower in ("x-forwarded-for", "x-real-ip"):
+ sanitized[actual_key] = real_ip
+ elif header_lower == "x-forwarded-host":
+ # Redact - we don't have API Gateway context
+ sanitized[actual_key] = "[REDACTED]"
+ elif header_lower == "x-forwarded-server":
+ sanitized[actual_key] = "[REDACTED]"
+ elif header_lower == "x-amzn-client-id":
+ sanitized[actual_key] = "[REDACTED]"
+ elif header_lower == "forwarded":
+ sanitized[actual_key] = f"for={real_ip}"
+
+ # Track modification
+ if original_value != sanitized[actual_key]:
+ modifications.append(actual_key)
+
+ # Log sanitization actions for security monitoring
+ if modifications:
+ logger.debug(f"Sanitized headers: {', '.join(modifications)}")
+
+ return sanitized
+
+
+def get_sanitized_headers_from_request(request: Request) -> dict[str, str]:
+ """
+ Extract and sanitize headers from a FastAPI request for safe logging.
+
+ This is a convenience function that extracts headers from the request
+ and sanitizes them in one step.
+
+ Args:
+ request: FastAPI Request object
+
+ Returns:
+ Dictionary of sanitized headers safe for logging
+ """
+ headers = dict(request.headers)
+ real_ip = get_real_client_ip(request)
+ return sanitize_headers_for_logging(headers, real_ip)
diff --git a/lib/serve/rest-api/src/utils/metrics.py b/lib/serve/rest-api/src/utils/metrics.py
index f6b43b967..417a5e95f 100644
--- a/lib/serve/rest-api/src/utils/metrics.py
+++ b/lib/serve/rest-api/src/utils/metrics.py
@@ -21,10 +21,9 @@
from datetime import datetime
import boto3
+from auth import get_user_context
from fastapi import Request
-from ..auth import get_user_context
-
logger = logging.getLogger(__name__)
sqs_client = boto3.client("sqs", region_name=os.environ["AWS_REGION"])
diff --git a/lib/serve/rest-api/src/utils/rds_auth.py b/lib/serve/rest-api/src/utils/rds_auth.py
index dd777b93a..e3adb07ab 100644
--- a/lib/serve/rest-api/src/utils/rds_auth.py
+++ b/lib/serve/rest-api/src/utils/rds_auth.py
@@ -11,19 +11,15 @@
# 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.
+
+"""RDS authentication utilities."""
+
import os
from typing import cast
-from urllib.parse import quote_plus
import boto3
-def generate_auth_token(host: str, port: str, user: str) -> str:
- rds = boto3.client("rds", region_name=os.environ["AWS_REGION"])
- token = rds.generate_db_auth_token(DBHostname=host, Port=port, DBUsername=user)
- return quote_plus(token)
-
-
def _get_lambda_role_arn() -> str:
"""Get the ARN of the Lambda execution role.
@@ -34,7 +30,7 @@ def _get_lambda_role_arn() -> str:
"""
sts = boto3.client("sts", region_name=os.environ["AWS_REGION"])
identity = sts.get_caller_identity()
- return cast(str, identity["Arn"]) # This will include the role name
+ return cast(str, identity["Arn"])
def get_lambda_role_name() -> str:
@@ -47,4 +43,4 @@ def get_lambda_role_name() -> str:
"""
arn = _get_lambda_role_arn()
parts = arn.split(":assumed-role/")[1].split("/")
- return parts[0] # This is the role name
+ return parts[0]
diff --git a/lib/serve/rest-api/src/utils/request_utils.py b/lib/serve/rest-api/src/utils/request_utils.py
index 9b5fc2fc7..b4aa86094 100644
--- a/lib/serve/rest-api/src/utils/request_utils.py
+++ b/lib/serve/rest-api/src/utils/request_utils.py
@@ -17,13 +17,34 @@
import os
import sys
import traceback
-from typing import Any, AsyncGenerator, Callable, Dict, Tuple
+from collections.abc import AsyncGenerator, Callable
+from typing import Any, Protocol
from loguru import logger
+from utils.cache_manager import cache_model_assets, get_model_assets, get_registered_models_cache
+from utils.resources import RestApiResource
+
+
+class RegistryProtocol(Protocol):
+ """Protocol for model registry - allows dependency injection."""
+
+ def get_assets(self, provider: str) -> dict[str, Any]:
+ """Get model assets for a provider."""
+ ...
+
+
+def _get_default_registry() -> RegistryProtocol:
+ """Lazy import of registry to avoid import-time dependencies.
+
+ This function is only called at runtime, not at import time,
+ allowing tests to mock the registry without importing lisa_serve.
+ """
+ # Import here to avoid circular dependencies and allow test mocking
+ # This is intentionally not at module level
+ from lisa_serve.registry import registry # noqa: PLC0415
+
+ return registry
-from ..lisa_serve.registry import registry
-from .cache_manager import cache_model_assets, get_model_assets, get_registered_models_cache
-from .resources import RestApiResource
logger.remove()
logger_level = os.environ.get("LOG_LEVEL", "INFO")
@@ -48,7 +69,7 @@
)
-async def validate_model(request_data: Dict[str, Any], resource: RestApiResource) -> None:
+async def validate_model(request_data: dict[str, Any], resource: RestApiResource) -> None:
"""Validate that the selected model is registered and supported for the specified resource.
Parameters
@@ -91,13 +112,17 @@ async def validate_model(request_data: Dict[str, Any], resource: RestApiResource
raise ValueError(message)
-async def get_model_and_validator(request_data: Dict[str, Any]) -> Tuple[Any, Any]:
+async def get_model_and_validator(
+ request_data: dict[str, Any], registry: RegistryProtocol | None = None
+) -> tuple[Any, Any]:
"""Get model and model kwargs validator.
Parameters
----------
request_data : Dict[str, Any]
Request data.
+ registry : RegistryProtocol | None
+ Optional registry for dependency injection (testing).
Returns
-------
@@ -112,6 +137,9 @@ async def get_model_and_validator(request_data: Dict[str, Any]) -> Tuple[Any, An
model_assets = get_model_assets(model_key)
if not model_assets:
# If not cached, retrieve model assets from registry
+ if registry is None:
+ registry = _get_default_registry()
+
registry_assets = registry.get_assets(provider)
adapter = registry_assets["adapter"]
validator = registry_assets["validator"]
@@ -134,8 +162,8 @@ async def get_model_and_validator(request_data: Dict[str, Any]) -> Tuple[Any, An
async def validate_and_prepare_llm_request(
- request_data: Dict[str, Any], resource: RestApiResource
-) -> Tuple[Any, Any, str]:
+ request_data: dict[str, Any], resource: RestApiResource, registry: RegistryProtocol | None = None
+) -> tuple[Any, Any, str]:
"""Validate and prepare data for LLM (Language Model) requests.
Parameters
@@ -146,6 +174,9 @@ async def validate_and_prepare_llm_request(
resource : RestApiResource
REST API resource.
+ registry : RegistryProtocol | None
+ Optional registry for dependency injection (testing).
+
Returns
-------
Tuple
@@ -159,7 +190,7 @@ async def validate_and_prepare_llm_request(
await validate_model(request_data, resource)
# Instantiate the model and get the model kwargs validator
- model, validator = await get_model_and_validator(request_data)
+ model, validator = await get_model_and_validator(request_data, registry)
# Verify model kwargs
model_kwargs = validator(**request_data["modelKwargs"])
@@ -174,8 +205,8 @@ async def validate_and_prepare_llm_request(
def handle_stream_exceptions(
- func: Callable[..., AsyncGenerator[str, None]],
-) -> Callable[..., AsyncGenerator[str, None]]:
+ func: Callable[..., AsyncGenerator[str]],
+) -> Callable[..., AsyncGenerator[str]]:
"""Decorate a streaming function to handle exceptions gracefully.
This decorator catches any exceptions raised during the execution of a streaming function
@@ -200,7 +231,7 @@ def handle_stream_exceptions(
The items yielded by the original function, or a JSON-formatted error message in case of an exception.
"""
- async def wrapper(*args: Any, **kwargs: Any) -> AsyncGenerator[str, None]:
+ async def wrapper(*args: Any, **kwargs: Any) -> AsyncGenerator[str]:
try:
async for item in func(*args, **kwargs):
yield item
diff --git a/lib/serve/rest-api/src/utils/resources.py b/lib/serve/rest-api/src/utils/resources.py
index c8929ae7a..17934b00a 100644
--- a/lib/serve/rest-api/src/utils/resources.py
+++ b/lib/serve/rest-api/src/utils/resources.py
@@ -14,7 +14,7 @@
"""REST API resources."""
from enum import Enum
-from typing import Any, Dict, List, Optional, Union
+from typing import Any
from pydantic import BaseModel, Field
@@ -43,6 +43,7 @@ class ModelType(str, Enum):
EMBEDDING = "embedding"
TEXTGEN = "textgen"
+ VIDEOGEN = "videogen"
class _BaseModelRequest(BaseModel):
@@ -50,8 +51,8 @@ class _BaseModelRequest(BaseModel):
provider: str = Field(..., description="The backend provider for the model.")
modelName: str = Field(..., description="The model name.")
- text: Union[str, list[str]] = Field(..., description="The input text(s) to be processed by the model.")
- modelKwargs: Dict[str, Any] = Field(default={}, description="Arguments to the model.")
+ text: str | list[str] = Field(..., description="The input text(s) to be processed by the model.")
+ modelKwargs: dict[str, Any] = Field(default={}, description="Arguments to the model.")
class EmbeddingsRequest(_BaseModelRequest):
@@ -72,13 +73,13 @@ class OpenAIChatCompletionsRequest(BaseModel):
Additional documentation at https://platform.openai.com/docs/api-reference/chat/create
"""
- messages: List[Dict[str, str]] = Field(..., description="A list of messages comprising the conversation so far.")
+ messages: list[dict[str, str]] = Field(..., description="A list of messages comprising the conversation so far.")
model: str = Field(..., description="ID of the model to use.")
- frequency_penalty: Optional[float] = Field(None, description="Penalty to add for text repetition.")
- logit_bias: Optional[Dict[Any, Any]] = Field(
+ frequency_penalty: float | None = Field(None, description="Penalty to add for text repetition.")
+ logit_bias: dict[Any, Any] | None = Field(
None, description="Modify the likelihood of specified tokens appearing in the completion."
)
- logprobs: Optional[bool] = Field(
+ logprobs: bool | None = Field(
False,
description=" ".join(
[
@@ -88,7 +89,7 @@ class OpenAIChatCompletionsRequest(BaseModel):
]
),
)
- top_logprobs: Optional[int] = Field(
+ top_logprobs: int | None = Field(
None,
description=" ".join(
[
@@ -99,8 +100,8 @@ class OpenAIChatCompletionsRequest(BaseModel):
]
),
)
- max_tokens: Optional[int] = Field(50, description="Maximum number of generated tokens.")
- n: Optional[int] = Field(
+ max_tokens: int | None = Field(50, description="Maximum number of generated tokens.")
+ n: int | None = Field(
1,
description=" ".join(
[
@@ -109,7 +110,7 @@ class OpenAIChatCompletionsRequest(BaseModel):
]
),
)
- presence_penalty: Optional[float] = Field(
+ presence_penalty: float | None = Field(
0,
description=" ".join(
[
@@ -118,11 +119,11 @@ class OpenAIChatCompletionsRequest(BaseModel):
]
),
)
- seed: Optional[int] = Field(None, description="Random sampling seed.")
- stop: Optional[List[str]] = Field(
+ seed: int | None = Field(None, description="Random sampling seed.")
+ stop: list[str] | None = Field(
default_factory=list, description="Stop generating tokens if a member of `stop` is generated."
)
- stream: Optional[bool] = Field(
+ stream: bool | None = Field(
False,
description=" ".join(
[
@@ -132,7 +133,7 @@ class OpenAIChatCompletionsRequest(BaseModel):
]
),
)
- top_p: Optional[float] = Field(
+ top_p: float | None = Field(
None,
description=" ".join(
[
@@ -141,7 +142,7 @@ class OpenAIChatCompletionsRequest(BaseModel):
]
),
)
- temperature: Optional[float] = Field(None, description="Value used to divide the logits distribution.")
+ temperature: float | None = Field(None, description="Value used to divide the logits distribution.")
class OpenAICompletionsRequest(BaseModel):
@@ -160,7 +161,7 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- best_of: Optional[int] = Field(
+ best_of: int | None = Field(
1,
description=" ".join(
[
@@ -170,9 +171,9 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- echo: Optional[bool] = Field(False, description="Whether to prepend the prompt to the generated text.")
- frequency_penalty: Optional[float] = Field(None, description="Penalty to add for text repetition.")
- logit_bias: Optional[Dict[Any, Any]] = Field(
+ echo: bool | None = Field(False, description="Whether to prepend the prompt to the generated text.")
+ frequency_penalty: float | None = Field(None, description="Penalty to add for text repetition.")
+ logit_bias: dict[Any, Any] | None = Field(
None,
description=" ".join(
[
@@ -181,7 +182,7 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- logprobs: Optional[int] = Field(
+ logprobs: int | None = Field(
None,
description=" ".join(
[
@@ -194,10 +195,10 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- max_tokens: Optional[int] = Field(
+ max_tokens: int | None = Field(
50, description="The maximum number of tokens that can be generated in the completion."
)
- n: Optional[int] = Field(
+ n: int | None = Field(
1,
description=" ".join(
[
@@ -206,7 +207,7 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- presence_penalty: Optional[float] = Field(
+ presence_penalty: float | None = Field(
0,
description=" ".join(
[
@@ -215,11 +216,11 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- seed: Optional[int] = Field(None, description="Random sampling seed.")
- stop: Optional[Any] = Field(
+ seed: int | None = Field(None, description="Random sampling seed.")
+ stop: Any | None = Field(
default_factory=list, description="Stop generating tokens if a member of `stop` is generated."
)
- stream: Optional[bool] = Field(
+ stream: bool | None = Field(
False,
description=" ".join(
[
@@ -229,7 +230,7 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- suffix: Optional[str] = Field(
+ suffix: str | None = Field(
None,
description=" ".join(
[
@@ -239,8 +240,8 @@ class OpenAICompletionsRequest(BaseModel):
]
),
)
- temperature: Optional[float] = Field(1.0, description="Value used to divide the logits distribution.")
- top_p: Optional[float] = Field(
+ temperature: float | None = Field(1.0, description="Value used to divide the logits distribution.")
+ top_p: float | None = Field(
None,
description=" ".join(
[
diff --git a/lib/serve/serveApplicationConstruct.ts b/lib/serve/serveApplicationConstruct.ts
index 72b6665b7..452b3b04d 100644
--- a/lib/serve/serveApplicationConstruct.ts
+++ b/lib/serve/serveApplicationConstruct.ts
@@ -13,34 +13,30 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Duration, Stack, StackProps } from 'aws-cdk-lib';
+import { Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { ITable, Table } from 'aws-cdk-lib/aws-dynamodb';
import { Credentials, DatabaseInstance, DatabaseInstanceEngine } from 'aws-cdk-lib/aws-rds';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
-import { Code, Function, IFunction, ILayerVersion, LayerVersion } from 'aws-cdk-lib/aws-lambda';
import { FastApiContainer } from '../api-base/fastApiContainer';
import { ECSCluster } from '../api-base/ecsCluster';
import { createCdkId } from '../core/utils';
import { Vpc } from '../networking/vpc';
-import { APP_MANAGEMENT_KEY, BaseProps, Config } from '../schema';
+import { APP_MANAGEMENT_KEY, BaseProps } from '../schema';
import {
Effect,
Policy,
- PolicyDocument,
PolicyStatement,
- Role,
- ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
-import { HostedRotation, ISecret } from 'aws-cdk-lib/aws-secretsmanager';
+import { HostedRotation } from 'aws-cdk-lib/aws-secretsmanager';
import { SecurityGroupEnum } from '../core/iam/SecurityGroups';
import { SecurityGroupFactory } from '../networking/vpc/security-group-factory';
-import { LAMBDA_PATH, REST_API_PATH } from '../util';
-import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
-import { getPythonRuntime } from '../api-base/utils';
+import { REST_API_PATH } from '../util';
+import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
import { ISecurityGroup, Port } from 'aws-cdk-lib/aws-ec2';
import { ECSTasks } from '../api-base/ecsCluster';
import { GuardrailsTable } from '../models/guardrails-table';
+import { Role } from 'aws-cdk-lib/aws-iam';
export type LisaServeApplicationProps = {
vpc: Vpc;
@@ -70,6 +66,9 @@ export class LisaServeApplicationConstruct extends Construct {
super(scope, id);
const { config, vpc, securityGroups } = props;
+ // Determine authentication method - default to IAM auth (iamRdsAuth = false)
+ const useIamAuth = config.iamRdsAuth ?? false;
+
// TokenTable is now created in API Base, reference it from SSM parameter
// API Base stack must be deployed before Serve stack (dependency is set in stages.ts)
const tokenTableNameParameter = StringParameter.fromStringParameterName(
@@ -128,25 +127,38 @@ export class LisaServeApplicationConstruct extends Construct {
}
const username = config.restApiConfig.rdsConfig.username;
+
+ // Create credentials for database setup
const dbCreds = Credentials.fromGeneratedSecret(username);
// DB is a Single AZ instance for cost + inability to make non-Aurora multi-AZ cluster in CDK
// DB is not expected to be under any form of heavy load.
// https://github.com/aws/aws-cdk/issues/25547
+ // NOTE: databaseName is intentionally NOT set here for backwards compatibility.
+ // Previous deployments created this RDS instance without a named database, so the
+ // default 'postgres' database is used. This means restApiConfig.rdsConfig.dbName
+ // is NOT respected for the LiteLLM database - it will always use 'postgres'.
const litellmDb = new DatabaseInstance(scope, 'LiteLLMScalingDB', {
engine: DatabaseInstanceEngine.POSTGRES,
vpc: vpc.vpc,
subnetGroup: vpc.subnetGroup,
credentials: dbCreds,
- iamAuthentication: true,
+ iamAuthentication: useIamAuth, // Enable IAM auth when iamRdsAuth is true
securityGroups: [litellmDbSg],
removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
});
- const litellmDbPasswordSecret = litellmDb.secret!;
+ // Secret is used for password auth or for IAM user bootstrap
+ const litellmDbSecret = litellmDb.secret!;
+
+ // Add rotation policy for the database password secret (only if using password auth)
+ if (!useIamAuth) {
+ // WARNING: If switching from IAM auth (iamRdsAuth=true) back to password auth (iamRdsAuth=false),
+ // the deployment will fail because the master password secret was deleted during IAM auth setup.
+ // This is a one-way migration - once IAM auth is enabled, you cannot switch back to password auth
+ // without recreating the database.
- // Add rotation policy for the database password secret (only if not using IAM auth)
- if (!config.iamRdsAuth) {
// Allow the rotation Lambda to connect to the database
securityGroups.forEach((sg) => {
litellmDbSg.addIngressRule(
@@ -156,7 +168,7 @@ export class LisaServeApplicationConstruct extends Construct {
);
});
- litellmDbPasswordSecret.addRotationSchedule('DatabasePasswordRotationSchedule', {
+ litellmDbSecret.addRotationSchedule('DatabasePasswordRotationSchedule', {
automaticallyAfter: Duration.days(30),
hostedRotation: HostedRotation.postgreSqlSingleUser({
functionName: `${config.deploymentName}-Litellm-Rotation-Function`,
@@ -174,18 +186,10 @@ export class LisaServeApplicationConstruct extends Construct {
dbHost: litellmDb.dbInstanceEndpointAddress,
dbName: config.restApiConfig.rdsConfig.dbName,
dbPort: config.restApiConfig.rdsConfig.dbPort,
- // only include passwordSecretId if authenticating with username/password
- ...(config.iamRdsAuth ? {} : { passwordSecretId: litellmDbPasswordSecret.secretName })
+ // Include passwordSecretId only when using password auth
+ ...(!useIamAuth ? { passwordSecretId: litellmDbSecret.secretName } : {})
}),
});
- console.log('storing llmdbconninfop', JSON.stringify({
- username: username,
- dbHost: litellmDb.dbInstanceEndpointAddress,
- dbName: config.restApiConfig.rdsConfig.dbName,
- dbPort: config.restApiConfig.rdsConfig.dbPort,
- // only include passwordSecretId if authenticating with username/password
- ...(config.iamRdsAuth ? {} : { passwordSecretId: litellmDbPasswordSecret.secretName })
- }));
// update the rdsConfig with the endpoint address
config.restApiConfig.rdsConfig.dbHost = litellmDb.dbInstanceEndpointAddress;
@@ -208,40 +212,100 @@ export class LisaServeApplicationConstruct extends Construct {
if (serveRole) {
// Grant access to REST API task role only
litellmDbConnectionInfoPs.grantRead(serveRole);
- if (config.iamRdsAuth) {
- litellmDb.grantConnect(serveRole, serveRole.roleName);
- // Create the lambda for generating DB users for IAM auth
- const createDbUserLambda = this.getIAMAuthLambda(scope, config, litellmDbPasswordSecret, serveRole.roleName, vpc, [litellmDbSg]);
+ if (!useIamAuth) {
+ // Password auth: grant secret read access only (grantConnect requires IAM auth)
+ litellmDbSecret.grantRead(serveRole);
+ } else {
+ // IAM auth: manually grant rds-db:connect permission
+ // Note: We do NOT use litellmDb.grantConnect() due to CDK bug #11851
+ // The grantConnect method generates incorrect ARN format (uses rds: instead of rds-db:)
+ // Per AWS docs: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html
+ // The correct format is: arn:aws:rds-db:region:account-id:dbuser:DbiResourceId/db-user-name
+ serveRole.addToPrincipalPolicy(new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['rds-db:connect'],
+ resources: [
+ // Use wildcard for DbiResourceId since it's not available in CloudFormation
+ // Format: arn:aws:rds-db:region:account:dbuser:*/username
+ `arn:${config.partition}:rds-db:${config.region}:${config.accountNumber}:dbuser:*/${serveRole.roleName}`
+ ]
+ }));
+
+ // Use the shared IAM auth setup Lambda from API Base stack
+ const iamAuthSetupFnArn = StringParameter.valueForStringParameter(
+ scope,
+ `${config.deploymentPrefix}/iamAuthSetupFnArn`
+ );
- const customResourceRole = new Role(scope, 'LISAServeCustomResourceRole', {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- });
- createDbUserLambda.grantInvoke(customResourceRole);
-
- // run updateInstanceKmsConditionsLambda every deploy
- new AwsCustomResource(scope, 'LISAServeCreateDbUserCustomResource', {
- onCreate: {
- service: 'Lambda',
- action: 'invoke',
- physicalResourceId: PhysicalResourceId.of('LISAServeCreateDbUserCustomResource'),
- parameters: {
- FunctionName: createDbUserLambda.functionName,
- Payload: '{}'
- },
+ // Get the IAM auth setup Lambda role ARN from SSM to grant it permissions
+ const iamAuthSetupRoleArn = StringParameter.valueForStringParameter(
+ scope,
+ `${config.deploymentPrefix}/iamAuthSetupRoleArn`
+ );
+
+ // Import the IAM auth setup role to grant it secret permissions
+ const iamAuthSetupRole = Role.fromRoleArn(
+ scope,
+ 'IamAuthSetupRoleRef',
+ iamAuthSetupRoleArn
+ );
+
+ // Grant the IAM auth setup Lambda role permission to read the bootstrap secret
+ litellmDbSecret.grantRead(iamAuthSetupRole);
+
+ // Run the shared IAM auth setup Lambda on create and update
+ // This runs when switching to IAM auth or updating the configuration
+ // Pass parameters via payload since the Lambda is shared
+ // Use Stack.of(scope).toJsonString() to properly resolve CDK tokens in the payload
+ const lambdaInvokeParams = {
+ service: 'Lambda',
+ action: 'invoke',
+ physicalResourceId: PhysicalResourceId.of('LISAServeCreateDbUserCustomResource'),
+ parameters: {
+ FunctionName: iamAuthSetupFnArn,
+ Payload: Stack.of(scope).toJsonString({
+ secretArn: litellmDbSecret.secretArn,
+ dbHost: config.restApiConfig.rdsConfig.dbHost,
+ dbPort: config.restApiConfig.rdsConfig.dbPort,
+ dbName: config.restApiConfig.rdsConfig.dbName,
+ dbUser: config.restApiConfig.rdsConfig.username,
+ iamName: serveRole.roleName,
+ })
},
- role: customResourceRole
+ };
+
+ const createDbUserResource = new AwsCustomResource(scope, 'LISAServeCreateDbUserCustomResource', {
+ onCreate: lambdaInvokeParams,
+ onUpdate: lambdaInvokeParams, // Also run on updates to ensure IAM user is created
+ policy: AwsCustomResourcePolicy.fromStatements([
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['lambda:InvokeFunction'],
+ resources: [iamAuthSetupFnArn],
+ })
+ ]),
});
- } else {
- litellmDb.grantConnect(serveRole);
- litellmDbPasswordSecret.grantRead(serveRole);
+
+ // Ensure the RDS instance is fully available before running IAM auth setup
+ createDbUserResource.node.addDependency(litellmDb);
+
+ // Ensure the ECS service waits for IAM user setup to complete
+ restApi.node.addDependency(createDbUserResource);
}
+
this.modelsPs.grantRead(serveRole);
}
// Use the guardrails table name from the construct we just created
const guardrailsTableName = this.guardrailsTable.tableName;
+ // Get generated images bucket name for video/image content storage
+ const imagesBucketName = StringParameter.valueForStringParameter(
+ scope,
+ `${config.deploymentPrefix}/generatedImagesBucketName`
+ );
+
// Add parameter as container environment variable for both RestAPI and RagAPI
const container = restApi.apiCluster.containers[ECSTasks.REST];
if (container) {
@@ -249,12 +313,23 @@ export class LisaServeApplicationConstruct extends Construct {
container.addEnvironment('REGISTERED_MODELS_PS_NAME', this.modelsPs.parameterName);
container.addEnvironment('LITELLM_DB_INFO_PS_NAME', litellmDbConnectionInfoPs.parameterName);
container.addEnvironment('GUARDRAILS_TABLE_NAME', guardrailsTableName);
+ container.addEnvironment('GENERATED_IMAGES_S3_BUCKET_NAME', imagesBucketName);
// Add metrics queue URL if provided
if (props.metricsQueueUrl) {
// Get the queue URL from SSM parameter
const queueUrl = StringParameter.valueForStringParameter(scope, props.metricsQueueUrl);
container.addEnvironment('USAGE_METRICS_QUEUE_URL', queueUrl);
}
+
+ // Add IAM auth environment variables for LiteLLM's native token refresh
+ // When these are set, LiteLLM automatically generates and refreshes IAM auth tokens
+ if (useIamAuth && serveRole) {
+ container.addEnvironment('IAM_TOKEN_DB_AUTH', 'true');
+ container.addEnvironment('DATABASE_HOST', litellmDb.dbInstanceEndpointAddress);
+ container.addEnvironment('DATABASE_NAME', config.restApiConfig.rdsConfig.dbName);
+ container.addEnvironment('DATABASE_PORT', config.restApiConfig.rdsConfig.dbPort.toString());
+ container.addEnvironment('DATABASE_USER', serveRole.roleName);
+ }
}
restApi.node.addDependency(this.modelsPs);
restApi.node.addDependency(litellmDbConnectionInfoPs);
@@ -316,6 +391,15 @@ export class LisaServeApplicationConstruct extends Construct {
restRole.attachInlinePolicy(invocation_permissions);
restRole.attachInlinePolicy(guardrails_permissions);
+ // Grant S3 bucket permissions for video/image content storage
+ restRole.addToPrincipalPolicy(
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['s3:PutObject', 's3:GetObject', 's3:DeleteObject'],
+ resources: [`arn:${config.partition}:s3:::${imagesBucketName}/*`]
+ })
+ );
+
// Grant SQS send permissions if metrics queue URL is provided
if (props.metricsQueueUrl) {
// Get the queue name from SSM parameter
@@ -343,56 +427,4 @@ export class LisaServeApplicationConstruct extends Construct {
}
};
- getIAMAuthLambda (scope: Stack, config: Config, secret: ISecret, user: string, vpc: Vpc, securityGroups: ISecurityGroup[]): IFunction {
- // Create the IAM role for updating the database to allow IAM authentication
- const iamAuthLambdaRole = new Role(scope, createCdkId(['LISAServe', 'IAMAuthLambdaRole']), {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- inlinePolicies: {
- 'EC2NetworkInterfaces': new PolicyDocument({
- statements: [
- new PolicyStatement({
- effect: Effect.ALLOW,
- actions: ['ec2:CreateNetworkInterface', 'ec2:DescribeNetworkInterfaces', 'ec2:DeleteNetworkInterface'],
- resources: ['*'],
- }),
- ],
- }),
- }
- });
-
- secret.grantRead(iamAuthLambdaRole);
-
- const commonLayer = this.getLambdaLayer(scope, config);
- const lambdaPath = config.lambdaPath || LAMBDA_PATH;
-
- // Create the Lambda function that will create the database user
- return new Function(scope, 'LISAServeCreateDbUserLambda', {
- runtime: getPythonRuntime(),
- handler: 'utilities.db_setup_iam_auth.handler',
- code: Code.fromAsset(lambdaPath),
- timeout: Duration.minutes(2),
- environment: {
- SECRET_ARN: secret.secretArn, // ARN of the RDS secret
- DB_HOST: config.restApiConfig.rdsConfig.dbHost!,
- DB_PORT: String(config.restApiConfig.rdsConfig.dbPort), // Default PostgreSQL port
- DB_NAME: config.restApiConfig.rdsConfig.dbName, // Database name
- DB_USER: config.restApiConfig.rdsConfig.username, // Admin user for RDS
- IAM_NAME: user, // IAM role for Lambda execution
- },
- role: iamAuthLambdaRole, // Lambda execution role
- layers: [commonLayer],
- vpc: vpc.vpc,
- vpcSubnets: vpc.subnetSelection,
- securityGroups: securityGroups,
- });
- }
-
- getLambdaLayer (scope: Stack, config: Config): ILayerVersion {
- return LayerVersion.fromLayerVersionArn(
- scope,
- 'LISAServeCommonLayerVersion',
- StringParameter.valueForStringParameter(scope, `${config.deploymentPrefix}/layerVersion/common`),
- );
- }
-
}
diff --git a/lib/stages.ts b/lib/stages.ts
index 83e75f5fc..8b217f19c 100644
--- a/lib/stages.ts
+++ b/lib/stages.ts
@@ -313,8 +313,10 @@ export class LisaServeApplicationStage extends Stage {
securityGroups: [networkingStack.vpc.securityGroups.ecsModelAlbSg],
vpc: networkingStack.vpc,
});
- apiDeploymentStack.addDependency(mcpApiStack);
mcpApiStack.addDependency(apiBaseStack);
+ // McpApiStack reads: layerVersion/*, bucket/bucket-access-logs, APP_MANAGEMENT_KEY
+ mcpApiStack.addDependency(coreStack);
+ apiDeploymentStack.addDependency(mcpApiStack);
this.stacks.push(mcpApiStack);
}
let metricsStack: LisaMetricsStack | undefined;
@@ -345,8 +347,14 @@ export class LisaServeApplicationStage extends Stage {
});
this.stacks.push(serveStack);
serveStack.addDependency(networkingStack);
+ // ServeStack reads: roles/* from IAMStack (via EcsCluster)
serveStack.addDependency(iamStack);
+ // ServeStack reads: tokenTableName, APP_MANAGEMENT_KEY, iamAuthSetupFnArn, iamAuthSetupRoleArn from ApiBaseStack
serveStack.addDependency(apiBaseStack);
+ // ServeStack reads: queue-url/usage-metrics from MetricsStack (if deployMetrics)
+ if (metricsStack) {
+ serveStack.addDependency(metricsStack);
+ }
const modelsApiDeploymentStack = new LisaModelsApiStack(this, 'LisaModelsApiDeployment', {
...baseStackProps,
@@ -360,6 +368,11 @@ export class LisaServeApplicationStage extends Stage {
securityGroups: [networkingStack.vpc.securityGroups.ecsModelAlbSg],
vpc: networkingStack.vpc,
});
+ // ModelsApiStack reads: layerVersion/*, bucket/bucket-access-logs from CoreStack
+ modelsApiDeploymentStack.addDependency(coreStack);
+ // ModelsApiStack reads: APP_MANAGEMENT_KEY from ApiBaseStack
+ modelsApiDeploymentStack.addDependency(apiBaseStack);
+ // ModelsApiStack reads: guardrailsTable from ServeStack (passed as prop, creates implicit dep)
modelsApiDeploymentStack.addDependency(serveStack);
apiDeploymentStack.addDependency(modelsApiDeploymentStack);
this.stacks.push(modelsApiDeploymentStack);
@@ -395,9 +408,14 @@ export class LisaServeApplicationStage extends Stage {
securityGroups: [networkingStack.vpc.securityGroups.lambdaSg],
vpc: networkingStack.vpc,
});
+ // RagStack reads: layerVersion/*, bucket/bucket-access-logs from CoreStack
ragStack.addDependency(coreStack);
+ // RagStack reads: roles/ragLambdaRoleId from IAMStack
ragStack.addDependency(iamStack);
+ // RagStack reads: iamAuthSetupFnArn, iamAuthSetupRoleArn from ApiBaseStack
ragStack.addDependency(apiBaseStack);
+ // RagStack reads: modelTableName from ModelsApiStack
+ ragStack.addDependency(modelsApiDeploymentStack);
this.stacks.push(ragStack);
apiDeploymentStack.addDependency(ragStack);
}
@@ -413,8 +431,14 @@ export class LisaServeApplicationStage extends Stage {
securityGroups: [networkingStack.vpc.securityGroups.lambdaSg],
vpc: networkingStack.vpc,
});
- chatStack.addDependency(apiBaseStack);
+ // ChatStack reads: layerVersion/*, bucket/bucket-access-logs from CoreStack
chatStack.addDependency(coreStack);
+ chatStack.addDependency(apiBaseStack);
+ // ChatStack reads: modelTableName from ModelsApiStack
+ chatStack.addDependency(modelsApiDeploymentStack);
+ // ChatStack reads: serve/endpoint from ServeStack
+ chatStack.addDependency(serveStack);
+ // ChatStack reads: queue-name/usage-metrics from MetricsStack (if deployMetrics)
if (metricsStack) {
chatStack.addDependency(metricsStack);
}
@@ -430,7 +454,10 @@ export class LisaServeApplicationStage extends Stage {
restApiId: apiBaseStack.restApiId,
rootResourceId: apiBaseStack.rootResourceId,
});
+ // UIStack reads: bucket/bucket-access-logs from CoreStack
+ uiStack.addDependency(coreStack);
uiStack.addDependency(chatStack);
+ // UIStack reads: lisaServeRestApiUri from ServeStack
uiStack.addDependency(serveStack);
uiStack.addDependency(apiBaseStack);
apiDeploymentStack.addDependency(uiStack);
@@ -444,6 +471,8 @@ export class LisaServeApplicationStage extends Stage {
const docsStack = new LisaDocsStack(this, 'LisaDocs', {
...baseStackProps
});
+ // DocsStack reads: bucket/bucket-access-logs from CoreStack
+ docsStack.addDependency(coreStack);
this.stacks.push(docsStack);
}
diff --git a/lib/user-interface/react/.gitignore b/lib/user-interface/react/.gitignore
index de9ee66e8..99395f4d1 100644
--- a/lib/user-interface/react/.gitignore
+++ b/lib/user-interface/react/.gitignore
@@ -23,3 +23,6 @@ coverage
*.njsproj
*.sln
*.sw?
+
+public/branding/custom/
+src/theme-custom.ts
diff --git a/lib/user-interface/react/index.html b/lib/user-interface/react/index.html
index 965ab49e5..0dbad35ba 100644
--- a/lib/user-interface/react/index.html
+++ b/lib/user-interface/react/index.html
@@ -2,11 +2,26 @@
-
+
- AWS LISA AI Chat Assistant
+ AWS LISA AI Chat Assistant
+
diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json
index ffa3f25d6..8b373a19c 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.1.1",
+ "version": "6.2.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -27,7 +27,7 @@
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.1",
"@langchain/core": "^1.1.4",
- "@langchain/openai": "^1.1.3",
+ "@langchain/openai": "1.2.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@reduxjs/toolkit": "^2.11.1",
"@swc/core": "^1.15.3",
@@ -48,6 +48,7 @@
"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",
@@ -74,7 +75,7 @@
"@types/ace": "^0.0.52",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.10.2",
- "@types/react": "^19.2.7",
+ "@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@types/redux-mock-store": "^1.5.0",
"@types/redux-persist": "^4.3.1",
@@ -94,6 +95,8 @@
"jsdom": "^27.3.0",
"linkify-it": "^5.0.0",
"markdown-it": "^14.1.0",
+ "patch-package": "^8.0.1",
+ "postinstall-postinstall": "^2.1.0",
"prettier": "^3.7.4",
"redux-mock-store": "^1.5.5",
"uuid": "^13.0.0",
diff --git a/lib/user-interface/react/public/branding/base/favicon.ico b/lib/user-interface/react/public/branding/base/favicon.ico
new file mode 100644
index 000000000..d336d05e6
Binary files /dev/null and b/lib/user-interface/react/public/branding/base/favicon.ico differ
diff --git a/lib/user-interface/react/public/branding/base/login.png b/lib/user-interface/react/public/branding/base/login.png
new file mode 100644
index 000000000..ff3a3e0d3
Binary files /dev/null and b/lib/user-interface/react/public/branding/base/login.png differ
diff --git a/lib/user-interface/react/public/branding/base/logo.svg b/lib/user-interface/react/public/branding/base/logo.svg
new file mode 100644
index 000000000..477145a36
--- /dev/null
+++ b/lib/user-interface/react/public/branding/base/logo.svg
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ LISA
+
+
+
+ LISA
+
+
diff --git a/lib/user-interface/react/public/favicon.ico b/lib/user-interface/react/public/favicon.ico
deleted file mode 100644
index 643489f29..000000000
Binary files a/lib/user-interface/react/public/favicon.ico and /dev/null differ
diff --git a/lib/user-interface/react/public/logo.svg b/lib/user-interface/react/public/logo.svg
deleted file mode 100644
index 8cf4d8fdd..000000000
--- a/lib/user-interface/react/public/logo.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
diff --git a/lib/user-interface/react/src/App.tsx b/lib/user-interface/react/src/App.tsx
index 72c95eadb..b5788c92a 100644
--- a/lib/user-interface/react/src/App.tsx
+++ b/lib/user-interface/react/src/App.tsx
@@ -19,7 +19,7 @@ import { ReactElement, useEffect, useState } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { AppLayout } from '@cloudscape-design/components';
import Spinner from '@cloudscape-design/components/spinner';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from './auth/useAuth';
import Home from './pages/Home';
import Chatbot from './pages/Chatbot';
@@ -105,7 +105,6 @@ const ApiUserRoute = ({ children }: RouteProps) => {
};
function App () {
- const [showNavigation, setShowNavigation] = useState(false);
const [nav, setNav] = useState(null);
const confirmationModal: ConfirmationModalProps = useAppSelector((state) => state.modal.confirmationModal);
const auth = useAuth();
@@ -131,13 +130,7 @@ function App () {
applyMode(colorScheme);
}, [colorScheme]);
- useEffect(() => {
- if (nav) {
- setShowNavigation(true);
- } else {
- setShowNavigation(false);
- }
- }, [nav]);
+ const showNavigation = !!nav;
return (
@@ -194,14 +187,14 @@ function App () {
}
/>}
-
}
- />
+ />}
({
diff --git a/lib/user-interface/react/src/components/Topbar.tsx b/lib/user-interface/react/src/components/Topbar.tsx
index faecd3916..2e4dd26f4 100644
--- a/lib/user-interface/react/src/components/Topbar.tsx
+++ b/lib/user-interface/react/src/components/Topbar.tsx
@@ -15,17 +15,18 @@
*/
import { ReactElement, useContext } from 'react';
-import { useAuth } from 'react-oidc-context';
+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 { getBaseURI } from './utils';
import { purgeStore, 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';
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';
applyDensity(Density.Comfortable);
@@ -78,15 +79,15 @@ function Topbar ({ configs }: TopbarProps): ReactElement {
external: false,
href: '/mcp-connections',
} as ButtonDropdownProps.Item] : [])
- ];
+ ].sort((a,b) => a.text.localeCompare(b.text));
return (
void;
onRefresh: () => void;
disableCreate?: boolean;
+ isFetching?: boolean;
};
export function ApiTokenActions ({
@@ -36,6 +38,7 @@ export function ApiTokenActions ({
setCreateWizardVisible,
onRefresh,
disableCreate = false,
+ isFetching = false,
}: ApiTokenActionsProps): ReactElement {
const dispatch = useAppDispatch();
const notificationService = useNotificationService(dispatch);
@@ -74,12 +77,11 @@ export function ApiTokenActions ({
return (
-
- Refresh
-
+ ariaLabel='Refresh tokens'
+ />
setCreateWizardVisible(true)}
variant='primary'
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 748c52e50..1dd86316a 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
@@ -262,6 +262,7 @@ export function ApiTokenManagementComponent ({ currentUserOnly = false }: ApiTok
setCreateWizardVisible={setCreateWizardVisible}
onRefresh={refetch}
disableCreate={userHasToken}
+ isFetching={isFetching}
/>
}
>
diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx
index 946e00aa2..576480c73 100644
--- a/lib/user-interface/react/src/components/chatbot/Chat.tsx
+++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx
@@ -15,17 +15,20 @@
*/
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../auth/useAuth';
import Form from '@cloudscape-design/components/form';
import Box from '@cloudscape-design/components/box';
import SpaceBetween from '@cloudscape-design/components/space-between';
import Spinner from '@cloudscape-design/components/spinner';
import {
Autosuggest,
- ButtonGroup, Checkbox,
+ ButtonGroup,
+ Checkbox,
Grid,
PromptInput,
Icon,
+ Flashbar,
+ FileTokenGroup,
} from '@cloudscape-design/components';
import StatusIndicator from '@cloudscape-design/components/status-indicator';
@@ -102,7 +105,7 @@ export default function Chat ({ sessionId }) {
const allModels = useMemo(() =>
(allModelsRaw || []).filter((model) =>
- (model.modelType === ModelType.textgen || model.modelType === ModelType.imagegen) &&
+ (model.modelType === ModelType.textgen || model.modelType === ModelType.imagegen || model.modelType === ModelType.videogen) &&
model.status === ModelStatus.InService
),
[allModelsRaw]
@@ -119,6 +122,7 @@ export default function Chat ({ sessionId }) {
// State management
const [userPrompt, setUserPrompt] = useState('');
const [fileContext, setFileContext] = useState('');
+ const [fileContextName, setFileContextName] = useState('');
const [dirtySession, setDirtySession] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [useRag, setUseRag] = useState(false);
@@ -127,18 +131,37 @@ export default function Chat ({ sessionId }) {
const [modelFilterValue, setModelFilterValue] = useState('');
const [hasUserInteractedWithModel, setHasUserInteractedWithModel] = useState(false);
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);
// Callback to handle Mermaid diagram rendering completion
const handleMermaidRenderComplete = useCallback(() => {
setMermaidRenderComplete((prev) => prev + 1);
}, []);
+ // Callback to handle video load completion (for auto-scroll)
+ const handleVideoLoadComplete = useCallback(() => {
+ setVideoLoadComplete((prev) => prev + 1);
+ }, []);
+
+ // Callback to handle image load completion (for auto-scroll)
+ const handleImageLoadComplete = useCallback(() => {
+ // Use setTimeout with RAF to ensure the DOM has fully updated and reflowed
+ // before triggering scroll. Images can cause significant layout shifts.
+ setTimeout(() => {
+ requestAnimationFrame(() => {
+ setImageLoadComplete((prev) => prev + 1);
+ });
+ }, 50);
+ }, []);
+
// Ref to track if we're processing tool calls to prevent infinite loops
const isProcessingToolCalls = useRef(false);
const lastProcessedMessageIndex = useRef(-1);
- const startToolChainRef = useRef<(session: LisaChatSession) => Promise>();
+ const startToolChainRef = useRef<((session: LisaChatSession) => Promise) | undefined>(undefined);
// Memoize enabled servers to prevent infinite re-renders
const enabledServers = useMemo(() => {
@@ -156,7 +179,7 @@ export default function Chat ({ sessionId }) {
// Use the custom hook to manage multiple MCP connections
const { tools: mcpTools, callTool, McpConnections, toolToServerMap } = useMultipleMcp(enabledServers, userPreferences?.preferences?.mcp);
- const [updatePreferences] = useUpdateUserPreferencesMutation();
+ const [updatePreferences, {isSuccess: isUpdatingPreferencesSuccess, isError: isUpdatingPreferencesError, isLoading: isUpdatingPreferences}] = useUpdateUserPreferencesMutation();
useEffect(() => {
if (userPreferences) {
@@ -166,6 +189,22 @@ export default function Chat ({ sessionId }) {
}
}, [userPreferences, userName]);
+ // Handle preferences update success
+ useEffect(() => {
+ if (isUpdatingPreferencesSuccess) {
+ notificationService.generateNotification('Successfully updated tool preferences', 'success');
+ setUpdatingAutoApprovalForTool(null);
+ }
+ }, [isUpdatingPreferencesSuccess, notificationService]);
+
+ // Handle preferences update error
+ useEffect(() => {
+ if (isUpdatingPreferencesError) {
+ notificationService.generateNotification('Error updating tool preferences', 'error');
+ setUpdatingAutoApprovalForTool(null);
+ }
+ }, [isUpdatingPreferencesError, notificationService]);
+
useEffect(() => {
const calculateMaxRows = () => {
const LINE_HEIGHT = 24; // pixels per row
@@ -207,6 +246,12 @@ export default function Chat ({ sessionId }) {
setChatConfiguration
);
+ // Check if the selected model has been deleted (exists in session but not in available models)
+ const isModelDeleted = useMemo(() => {
+ if (!selectedModel) return false;
+ return !allModels?.some((model) => model.modelId === selectedModel.modelId);
+ }, [selectedModel, allModels]);
+
// Set default model if none is selected, default model is configured, and user hasn't interacted
useEffect(() => {
if (!selectedModel && !hasUserInteractedWithModel && config?.configuration?.global?.defaultModel && allModels) {
@@ -254,6 +299,7 @@ export default function Chat ({ sessionId }) {
// Derived states
const isImageGenerationMode = selectedModel?.modelType === ModelType.imagegen;
+ const isVideoGenerationMode = selectedModel?.modelType === ModelType.videogen;
// Format MCP tools for OpenAI when they change
useEffect(() => {
@@ -286,16 +332,18 @@ export default function Chat ({ sessionId }) {
});
}, [getRelevantDocuments, chatConfiguration.sessionConfiguration, ragConfig.repositoryId, ragConfig.collection, ragConfig.embeddingModel]);
- const { isRunning, setIsRunning, isStreaming, generateResponse, stopGeneration } = useChatGeneration({
+ const { isRunning, setIsRunning, isStreaming, generateResponse, stopGeneration, retryResponse, errorState } = useChatGeneration({
chatConfiguration,
selectedModel,
isImageGenerationMode,
+ isVideoGenerationMode,
session,
setSession,
metadata,
memory,
openAiTools: config?.configuration?.enabledComponents?.mcpConnections ? openAiTools : undefined,
auth,
+ fileContext,
notificationService
});
@@ -322,13 +370,17 @@ export default function Chat ({ sessionId }) {
const toggleToolAutoApproval = (toolName: string, enabled: boolean) => {
+ setUpdatingAutoApprovalForTool(toolName);
const existingMcpPrefs = preferences.preferences.mcp ?? { enabledServers: [], overrideAllApprovals: false };
const mcpPrefs: McpPreferences = {
...existingMcpPrefs,
enabledServers: [...existingMcpPrefs.enabledServers]
};
const originalServer = mcpPrefs.enabledServers.find((server) => server.name === toolToServerMap.get(toolName));
- if (!originalServer) return; // Early return if server not found
+ if (!originalServer) {
+ setUpdatingAutoApprovalForTool(null);
+ return; // Early return if server not found
+ }
// Create a deep copy of the server object with its nested arrays
const serverToUpdate = {
...originalServer,
@@ -402,8 +454,12 @@ export default function Chat ({ sessionId }) {
});
if ('data' in resp) {
const image: LisaAttachImageResponse = resp.data;
- content.image_url.url = image.body.image_url.url;
- content.image_url.s3_key = image.body.image_url.s3_key;
+ if (content.image_url && image.body.image_url) {
+ content.image_url.url = image.body.image_url.url;
+ if ('s3_key' in image.body.image_url) {
+ content.image_url.s3_key = image.body.image_url.s3_key;
+ }
+ }
}
}
})
@@ -481,13 +537,42 @@ export default function Chat ({ sessionId }) {
}
}, [sessionHealth]);
+ // When session finishes loading, enable auto-scroll and scroll to bottom
+ useEffect(() => {
+ if (!loadingSession && session.history.length > 0 && sessionId) {
+ // Re-enable auto-scroll when a session is loaded
+ setShouldAutoScroll(true);
+
+ // For sessions with images, we need multiple scroll attempts because:
+ // - Base64 images load instantly (synchronously)
+ // - Cached images load very quickly
+ // - The browser needs time to reflow the layout with image dimensions
+ const scrollToBottom = () => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
+ }
+ };
+
+ // Multiple scroll attempts with increasing delays to ensure we reach the bottom
+ // as images fully load and the container height updates
+ const delays = [0, 50, 150, 300, 500];
+ const timeoutIds = delays.map((delay) => setTimeout(scrollToBottom, delay));
+
+ // Cleanup timeouts if component unmounts or effect re-runs
+ return () => {
+ timeoutIds.forEach((id) => clearTimeout(id));
+ };
+ }
+ }, [loadingSession, sessionId, session.history.length]);
+
useEffect(() => {
- if (shouldAutoScroll && bottomRef.current) {
- // Use 'auto' instead of 'smooth' to prevent jagged scrolling during rapid streaming
- // which was breaking AT_BOTTOM_THRESHOLD disabling auto-scroll without user input
- bottomRef.current.scrollIntoView({ behavior: 'auto' });
+ if (shouldAutoScroll && scrollContainerRef.current) {
+ // Scroll the container directly to the bottom
+ // This is more reliable than scrollIntoView for ensuring we reach the actual bottom
+ const container = scrollContainerRef.current;
+ container.scrollTop = container.scrollHeight;
}
- }, [isStreaming, session, mermaidRenderComplete, shouldAutoScroll]);
+ }, [isStreaming, session, mermaidRenderComplete, videoLoadComplete, imageLoadComplete, shouldAutoScroll]);
// Scroll event listener to detect scroll position
useEffect(() => {
@@ -555,13 +640,13 @@ export default function Chat ({ sessionId }) {
history: prev.history.concat(new LisaChatMessage({
type: 'human',
content: userPrompt,
- metadata: isImageGenerationMode ? { imageGeneration: true } : {},
+ metadata: isImageGenerationMode ? { imageGeneration: true } : isVideoGenerationMode ? { videoGeneration: true } : {},
}))
}));
const messages = [];
- if (session.history.length === 0 && !isImageGenerationMode) {
+ if (session.history.length === 0 && !isImageGenerationMode && !isVideoGenerationMode) {
messages.push(new LisaChatMessage({
type: 'system',
content: chatConfiguration.promptConfiguration.promptTemplate,
@@ -571,13 +656,13 @@ export default function Chat ({ sessionId }) {
// Fetch RAG documents once if needed
let ragDocs = null;
- if (useRag && !isImageGenerationMode) {
+ if (useRag && !isImageGenerationMode && !isVideoGenerationMode) {
ragDocs = await fetchRelevantDocuments(userPrompt);
}
// Use extracted message builder utilities
const messageContent = await buildMessageContent({
- isImageGenerationMode,
+ isImageGenerationMode: isImageGenerationMode || isVideoGenerationMode,
fileContext,
useRag,
userPrompt,
@@ -585,7 +670,7 @@ export default function Chat ({ sessionId }) {
});
const messageMetadata = await buildMessageMetadata({
- isImageGenerationMode,
+ isImageGenerationMode: isImageGenerationMode || isVideoGenerationMode,
useRag,
chatConfiguration,
ragDocs,
@@ -615,7 +700,7 @@ export default function Chat ({ sessionId }) {
setDirtySession(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [userPrompt, useRag, fileContext, chatConfiguration, generateResponse, isImageGenerationMode, fetchRelevantDocuments, notificationService]);
+ }, [userPrompt, useRag, fileContext, chatConfiguration, generateResponse, isImageGenerationMode, isVideoGenerationMode, fetchRelevantDocuments, notificationService]);
// Ref to track if we're processing a keyboard event
const isKeyboardEventRef = useRef(false);
@@ -697,20 +782,21 @@ export default function Chat ({ sessionId }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
/>), conditionalDeps([modals.sessionConfiguration], [modals.sessionConfiguration], [modals.sessionConfiguration, chatConfiguration, setChatConfiguration, selectedModel, isRunning, openModal, closeModal, config, session, updateSession, ragConfig]))}
- {useMemo(() => ( (window.env.RAG_ENABLED ? show ? openModal('ragUpload') : closeModal('ragUpload')}
- />), [ragConfig, modals.ragUpload, openModal, closeModal])}
+ /> : null), [ragConfig, modals.ragUpload, openModal, closeModal])}
{useMemo(() => ( show ? openModal('contextUpload') : closeModal('contextUpload')}
fileContext={fileContext}
setFileContext={setFileContext}
+ setFileContextName={setFileContextName}
selectedModel={selectedModel}
// eslint-disable-next-line react-hooks/exhaustive-deps
- />), conditionalDeps([modals.contextUpload], [modals.contextUpload], [modals.contextUpload, openModal, closeModal, fileContext, setFileContext, selectedModel]))}
+ />), conditionalDeps([modals.contextUpload], [modals.contextUpload], [modals.contextUpload, openModal, closeModal, fileContext, setFileContext, setFileContextName, selectedModel]))}
{useMemo(() => (Do you want to allow this tool execution?
-
- toggleToolAutoApproval(toolApprovalModal.tool.name, detail.checked)
- }
- checked={userPreferences?.preferences?.mcp?.enabledServers.find((server) => server.name === toolToServerMap.get(toolApprovalModal.tool.name))?.autoApprovedTools.includes(toolApprovalModal.tool.name)}
- >
- Auto-approve this tool in the future
-
+ {updatingAutoApprovalForTool === toolApprovalModal.tool.name ? (
+
+ Updating preferences...
+
+ ) : (
+
+ toggleToolAutoApproval(toolApprovalModal.tool.name, detail.checked)
+ }
+ checked={preferences?.preferences?.mcp?.enabledServers.find((server) => server.name === toolToServerMap.get(toolApprovalModal.tool.name))?.autoApprovedTools.includes(toolApprovalModal.tool.name)}
+ disabled={isUpdatingPreferences}
+ >
+ Auto-approve this tool in the future
+
+ )}
}
/>
)}
+
+ {/* Sticky warning banner for deleted model */}
+ {isModelDeleted && (
+
+
+
+ This session uses the model {selectedModel?.modelId} which is no longer available.
+ You can view the conversation history but cannot send new messages.
+ Please start a new session with a different model.
+ >
+ ),
+ id: 'model-deleted-warning',
+ },
+ ]}
+ />
+
+
+ )}
+
+
{loadingSession && (
@@ -782,11 +901,15 @@ export default function Chat ({ sessionId }) {
chatConfiguration={chatConfiguration}
setUserPrompt={setUserPrompt}
onMermaidRenderComplete={handleMermaidRenderComplete}
+ onVideoLoadComplete={handleVideoLoadComplete}
+ onImageLoadComplete={handleImageLoadComplete}
+ retryResponse={retryResponse}
+ errorState={errorState && idx === session.history.length - 1 }
/>));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.history, chatConfiguration, loadingSession])}
- {!loadingSession && (isRunning || callingToolName) && !isStreaming && }
{!loadingSession && session.history.length === 0 && sessionId === undefined && (
Image Generation Mode : undefined}
+ label={isImageGenerationMode || isVideoGenerationMode ? {isImageGenerationMode ? 'Image Generation Mode' : 'Video Generation Mode'} : undefined}
>
0}
@@ -838,7 +963,7 @@ export default function Chat ({ sessionId }) {
controlId='model-selection-autosuggest'
/>
- {window.env.RAG_ENABLED && !isImageGenerationMode && (
+ {window.env.RAG_ENABLED && !isImageGenerationMode && !isVideoGenerationMode && (
setUserPrompt(detail.value)}
onAction={handleAction}
onKeyDown={handleKeyPress}
@@ -869,12 +1004,34 @@ export default function Chat ({ sessionId }) {
}
+ 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'
+ }}
+ />
+ )
+ }
/>
diff --git a/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx b/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx
index 260994efb..11122cef5 100644
--- a/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/FileUploadModals.tsx
@@ -26,7 +26,7 @@ import {
TextContent,
} from '@cloudscape-design/components';
import { FileTypes, StatusTypes } from '@/components/types';
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import { RagConfig } from './RagOptions';
import { useAppDispatch } from '@/config/store';
import { useNotificationService } from '@/shared/util/hooks';
@@ -37,10 +37,11 @@ import {
} from '@/shared/reducers/rag.reducer';
import { uploadToS3Request } from '@/components/utils';
import { ChunkingStrategy, ChunkingStrategyType, RagRepositoryType } from '#root/lib/schema';
-import { IModel } from '@/shared/model/model-management.model';
+import { IModel, ModelType } from '@/shared/model/model-management.model';
import { JobStatusTable } from '@/components/chatbot/components/JobStatusTable';
import { ChunkingConfigForm } from '@/shared/form/ChunkingConfigForm';
import { MetadataForm } from '@/shared/form/MetadataForm';
+import { getDisplayName } from '@/shared/util/branding';
export const renameFile = (originalFile: File) => {
// Add timestamp to filename for RAG uploads to not conflict with existing S3 files
@@ -87,6 +88,7 @@ export type ContextUploadProps = {
setShowContextUploadModal: React.Dispatch>;
fileContext: string;
setFileContext: React.Dispatch>;
+ setFileContextName: React.Dispatch>;
selectedModel: IModel;
};
@@ -95,12 +97,20 @@ export const ContextUploadModal = ({
setShowContextUploadModal,
fileContext,
setFileContext,
+ setFileContextName,
selectedModel
}: ContextUploadProps) => {
const [selectedFiles, setSelectedFiles] = useState([]);
const dispatch = useAppDispatch();
const notificationService = useNotificationService(dispatch);
- const modelSupportsImages = selectedModel?.features?.filter((feature) => feature.name === 'imageInput')?.length && true;
+ const modelSupportsImages = !!(selectedModel?.features?.filter((feature) => feature.name === 'imageInput')?.length) || selectedModel?.modelType === ModelType.videogen || selectedModel?.modelType === ModelType.imagegen;
+
+ // Clear selectedFiles when fileContext is cleared externally (e.g., via badge dismissal)
+ useEffect(() => {
+ if (!fileContext) {
+ queueMicrotask(() => setSelectedFiles([]));
+ }
+ }, [fileContext]);
function handleError (error: string) {
notificationService.generateNotification(error, 'error');
@@ -126,6 +136,7 @@ export const ContextUploadModal = ({
}
setFileContext(`File context: ${fileContents}`);
+ setFileContextName(file.name);
setSelectedFiles([file]);
return true;
}
@@ -159,6 +170,7 @@ export const ContextUploadModal = ({
onClick={() => {
setShowContextUploadModal(false);
setFileContext('');
+ setFileContextName('');
setSelectedFiles([]);
}}
disabled={!fileContext}
@@ -174,7 +186,7 @@ export const ContextUploadModal = ({
File Context
- Upload files for LISA to use as context in this session. This additional context will be referenced to
+ Upload files for {getDisplayName()} to use as context in this session. This additional context will be referenced to
answer your questions.
@@ -334,7 +346,7 @@ export const RagUploadModal = ({
Upload to RAG
- Upload files to the RAG repository leveraged by LISA. This will provide LISA with trusted information for
+ Upload files to the RAG repository leveraged by {getDisplayName()}. This will provide {getDisplayName()} with trusted information for
answering prompts.
@@ -357,12 +369,12 @@ export const RagUploadModal = ({
setFields={(values) => {
if (values.chunkingStrategy !== undefined) {
setChunkingStrategy(values.chunkingStrategy);
- } else if (values['chunkingStrategy.size'] !== undefined) {
+ } else if (values['chunkingStrategy.size'] !== undefined && chunkingStrategy?.type === ChunkingStrategyType.FIXED) {
setChunkingStrategy({
...chunkingStrategy,
size: values['chunkingStrategy.size'],
});
- } else if (values['chunkingStrategy.overlap'] !== undefined) {
+ } else if (values['chunkingStrategy.overlap'] !== undefined && chunkingStrategy?.type === ChunkingStrategyType.FIXED) {
setChunkingStrategy({
...chunkingStrategy,
overlap: values['chunkingStrategy.overlap'],
diff --git a/lib/user-interface/react/src/components/chatbot/components/JobStatusTable.tsx b/lib/user-interface/react/src/components/chatbot/components/JobStatusTable.tsx
index 7546a1aa1..a602242c2 100644
--- a/lib/user-interface/react/src/components/chatbot/components/JobStatusTable.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/JobStatusTable.tsx
@@ -17,7 +17,6 @@
import React, { useState } from 'react';
import {
Box,
- Button,
SpaceBetween,
StatusIndicator,
Table,
@@ -43,6 +42,7 @@ import {
JobStatusItem,
getMatchesCountText,
} from './JobStatusTableConfig';
+import { RefreshButton } from '@/components/common/RefreshButton';
export type JobStatusTableProps = {
ragConfig: RagConfig;
@@ -240,13 +240,11 @@ export function JobStatusTable ({
>
-
-
-
+ ariaLabel='Refresh jobs'
+ />
}
>
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 9883e54f3..07b620e2f 100644
--- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx
@@ -35,7 +35,7 @@ import 'katex/dist/katex.min.css';
import styles from './Message.module.css';
import { MessageContent } from '@langchain/core/messages';
-import { base64ToBlob, fetchImage, getDisplayableMessage, messageContainsImage } from '@/components/utils';
+import { base64ToBlob, fetchImage, getDisplayableMessage } from '@/components/utils';
import React, { useEffect, useState, useMemo } from 'react';
import { IChatConfiguration } from '@/shared/model/chat.configurations.model';
import { downloadFile } from '@/shared/util/downloader';
@@ -61,17 +61,32 @@ type MessageProps = {
chatConfiguration: IChatConfiguration;
showUsage?: boolean;
onMermaidRenderComplete?: () => void;
+ onVideoLoadComplete?: () => void;
+ onImageLoadComplete?: () => void;
+ retryResponse?: () => Promise
+ errorState?: boolean;
};
-export const Message = React.memo(({ message, isRunning, showMetadata, isStreaming, markdownDisplay, setUserPrompt, setChatConfiguration, handleSendGenerateRequest, chatConfiguration, callingToolName, showUsage = false, onMermaidRenderComplete }: MessageProps) => {
+export const Message = React.memo(({ message, isRunning, showMetadata, isStreaming, markdownDisplay, setUserPrompt, setChatConfiguration, handleSendGenerateRequest, chatConfiguration, callingToolName, showUsage = false, onMermaidRenderComplete, onVideoLoadComplete, onImageLoadComplete, retryResponse, errorState }: MessageProps) => {
const currentUser = useAppSelector(selectCurrentUsername);
const ragCitations = !isStreaming && message?.metadata?.ragDocuments ? message?.metadata.ragDocuments : undefined;
const [resend, setResend] = useState(false);
const [showImageViewer, setShowImageViewer] = useState(false);
const [selectedImage, setSelectedImage] = useState(undefined);
const [selectedMetadata, setSelectedMetadata] = useState(undefined);
+ const [reasoningExpanded, setReasoningExpanded] = useState(true);
const { colorScheme } = useContext(ColorSchemeContext);
const isDarkMode = colorScheme === Mode.Dark;
+ const hasMessageContent = message?.content && typeof message.content === 'string' && message.content.trim() && message.content.trim() !== '\u00A0';
+
+ // Auto-expand reasoning when it first appears, then auto-collapse when message content starts arriving
+ useEffect(() => {
+ if (hasMessageContent) {
+ setReasoningExpanded(false);
+ } else if (!hasMessageContent && message?.reasoningContent) {
+ setReasoningExpanded(true);
+ }
+ }, [hasMessageContent, message?.reasoningContent]);
useEffect(() => {
if (resend) {
@@ -246,22 +261,23 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
);
} else if (item.type === 'image_url' && item.image_url?.url) {
return message.type === MessageTypes.HUMAN ?
- :
+ onImageLoadComplete?.()} /> :
{
setSelectedImage(item);
setSelectedMetadata(metadata);
setShowImageViewer(true);
}}>
-
+ onImageLoadComplete?.()} />
{
if (e.detail.id === 'download-image') {
@@ -269,6 +285,8 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
await fetchImage(item.image_url.url)
: base64ToBlob(item.image_url.url.split(',')[1], 'image/png');
downloadFile(URL.createObjectURL(file), `${metadata?.imageGenerationParams?.prompt}.png`);
+ } else if (e.detail.id === 'share-image') {
+ navigator.clipboard.writeText(item.image_url.url);
} else if (e.detail.id === 'copy-image') {
const copy = new ClipboardItem({
'image/png': item.image_url.url.startsWith('https://') ?
@@ -293,6 +311,51 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
}}
/>
;
+ } else if (item.type === 'video_url' && item.video_url?.url) {
+ const videoId = item.video_url.video_id;
+ return (
+
+ onVideoLoadComplete?.()}
+ >
+
+ Your browser does not support the video tag.
+
+ {
+ if (e.detail.id === 'download-video') {
+ const videoUrl = item.video_url.url;
+ const videoBlob = await fetch(videoUrl).then((r) => r.blob());
+ const filename = `${metadata?.videoGenerationParams?.prompt || 'video'}.mp4`;
+ downloadFile(URL.createObjectURL(videoBlob), filename);
+ } else if (e.detail.id === 'share-video') {
+ navigator.clipboard.writeText(item.video_url.url);
+ } else if (e.detail.id === 'remix-video' && videoId) {
+ // Call the remix endpoint to create a new variation
+ setUserPrompt(`Remix video: ${metadata?.videoGenerationParams?.prompt ?? ''}`);
+ // Store the video_id for the remix call
+ setChatConfiguration(
+ merge({}, chatConfiguration, {
+ sessionConfiguration: {
+ remixVideoId: videoId
+ }
+ })
+ );
+ setResend(true);
+ }
+ }}
+ />
+
+ );
}
return null;
});
@@ -314,9 +377,9 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
return (
(message.type === MessageTypes.HUMAN || message.type === MessageTypes.AI || message.type === MessageTypes.TOOL) &&
-
+
- {(isRunning && !callingToolName) && (
+ {(isRunning && !callingToolName && !message?.metadata?.videoGeneration) && (
: undefined}
>
-
+
Generating response
@@ -356,14 +419,15 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
)}
- {message?.type === 'ai' && !isRunning && !callingToolName && message?.content && (
+ {message?.type === 'ai' && !isRunning && !callingToolName && (message?.content || message?.reasoningContent) && (
: undefined}
>
- {renderContent(message.content, message.metadata)}
+ {message?.reasoningContent && chatConfiguration.sessionConfiguration.showReasoningContent && (
+
+ {
+ setReasoningExpanded(detail.expanded);
+ }}
+ >
+
+
+
+
+ {message.reasoningContent}
+
+
+
+ {
+ if (detail.id === 'copy-reasoning') {
+ navigator.clipboard.writeText(message.reasoningContent || '');
+ }
+ }}
+ ariaLabel='Copy reasoning content'
+ dropdownExpandToViewport
+ items={[
+ {
+ type: 'icon-button',
+ id: 'copy-reasoning',
+ iconName: 'copy',
+ text: 'Copy Reasoning',
+ popoverFeedback: (
+
+ Reasoning copied
+
+ )
+ }
+ ]}
+ variant='icon'
+ />
+
+
+
+
+ )}
+ {message?.content && (typeof message.content === 'string' ? (message.content.trim() && message.content.trim() !== '\u00A0') : true) && renderContent(message.content, message.metadata)}
{showMetadata && !isStreaming &&
}
-
- {!isStreaming && !messageContainsImage(message.content) &&
+ {!isStreaming && !isRunning && !message?.metadata?.imageGeneration && !message?.metadata?.videoGeneration &&
['copy'].includes(detail.id) &&
@@ -407,8 +515,8 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
}
]}
variant='icon'
- />
-
}
+ />}
+
)}
{message?.type === 'human' && (
@@ -417,6 +525,13 @@ export const Message = React.memo(({ message, isRunning, showMetadata, isStreami
{renderContent(message.content)}
-
-
- ['copy'].includes(detail.id) &&
- navigator.clipboard.writeText(getDisplayableMessage(message.content))
- }
- ariaLabel='Chat actions'
- dropdownExpandToViewport
- items={[
- {
- type: 'icon-button',
- id: 'copy',
- iconName: 'copy',
- text: 'Copy Input',
- popoverFeedback: (
-
- Input copied
-
- )
+ {
+ if (detail.id === 'copy') {
+ navigator.clipboard.writeText(getDisplayableMessage(message.content));
+ } else if (detail.id === 'retry') {
+ await retryResponse();
+ }
}
- ]}
- variant='icon'
- />
+ }
+ ariaLabel='Chat actions'
+ dropdownExpandToViewport
+ items={[
+ {
+ type: 'icon-button',
+ id: 'copy',
+ iconName: 'copy',
+ text: 'Copy Input',
+ popoverFeedback: (
+
+ Input copied
+
+ )
+ },
+ ...(errorState ? [
+ {
+ type: 'icon-button' as const,
+ id: 'retry' as const,
+ iconName: 'refresh' as const,
+ text: 'Retry Message' as const,
+ popoverFeedback: (
+
+ Retrying Message
+
+ )
+ }] : [])
+ ]}
+ variant='icon'
+ />
+
)}
diff --git a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx
index c1db76ef5..63d53839a 100644
--- a/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/RagOptions.tsx
@@ -104,8 +104,7 @@ export default function RagControls ({ isRunning, setUseRag, setRagConfig, ragCo
// Update tracking when repository changes
if (repositoryHasChanged) {
lastRepositoryIdRef.current = currentRepositoryId;
-
- setUserHasSelectedCollection(false);
+ queueMicrotask(() => setUserHasSelectedCollection(false));
}
if (currentRepositoryId && filteredRepositories && allModels && (!userHasSelectedCollection || repositoryHasChanged)) {
diff --git a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx
index 4586b31c9..66497e30b 100644
--- a/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/SessionConfiguration.tsx
@@ -31,6 +31,7 @@ import { IChatConfiguration } from '@/shared/model/chat.configurations.model';
import { IModel, ModelType } from '@/shared/model/model-management.model';
import { IConfiguration } from '@/shared/model/configuration.model';
import { LisaChatSession } from '@/components/types';
+import { ModelFeatures } from '@/components/types';
export type SessionConfigurationProps = {
title?: string;
@@ -93,7 +94,16 @@ export const SessionConfiguration = ({
};
});
+ const reasoningEffortOptions = [
+ { value: 'none', label: 'None' },
+ { value: 'minimal', label: 'Minimal' },
+ { value: 'low', label: 'Low' },
+ { value: 'medium', label: 'Medium' },
+ { value: 'high', label: 'High' },
+ { value: 'xhigh', label: 'X-High' },
+ ];
const isImageModel = selectedModel?.modelType === ModelType.imagegen;
+ const isVideoModel = selectedModel?.modelType === ModelType.videogen;
return (
-
+
updateSessionConfiguration('streaming', detail.checked)}
checked={chatConfiguration.sessionConfiguration.streaming}
@@ -126,7 +136,7 @@ export const SessionConfiguration = ({
>
Show Message Metadata
}
- {systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && !modelOnly &&
+ {systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && !isVideoModel && !modelOnly &&
}
- {systemConfig && systemConfig.configuration.enabledComponents.editNumOfRagDocument && !isImageModel && !modelOnly &&
+ {systemConfig && systemConfig.configuration.enabledComponents.editNumOfRagDocument && !isImageModel && !isVideoModel && !modelOnly &&
}
+ {selectedModel?.features?.find((feature) => feature.name === ModelFeatures.REASONING) &&
+
+ updateSessionConfiguration('modelArgs', {...chatConfiguration.sessionConfiguration.modelArgs, reasoning_effort: detail.selectedOption.value })}
+ options={reasoningEffortOptions}
+ />
+ }
+ {selectedModel?.features?.find((feature) => feature.name === ModelFeatures.REASONING) &&
+ updateSessionConfiguration('showReasoningContent', detail.checked)}
+ checked={chatConfiguration.sessionConfiguration.showReasoningContent}
+ disabled={isRunning}
+ >
+ Show Reasoning Content
+ }
- {systemConfig && systemConfig.configuration.enabledComponents.editKwargs && !isImageModel &&
+ {systemConfig && systemConfig.configuration.enabledComponents.editKwargs && !isImageModel && !isVideoModel &&
)}
+ {isVideoModel && (
+
+ Video Generation Options
+
+ }
+ >
+
+ {
+ updateSessionConfiguration('videoGenerationArgs', {
+ ...chatConfiguration.sessionConfiguration.videoGenerationArgs,
+ seconds: detail.selectedOption.value,
+ });
+ }}
+ options={[
+ { label: '4 seconds', value: '4' },
+ { label: '8 seconds', value: '8' },
+ { label: '12 seconds', value: '12' },
+ ]}
+ />
+
+
+ {
+ updateSessionConfiguration('videoGenerationArgs', {
+ ...chatConfiguration.sessionConfiguration.videoGenerationArgs,
+ size: detail.selectedOption.value,
+ });
+ }}
+ options={[
+ { label: '720x1280 (Portrait)', value: '720x1280' },
+ { label: '1280x720 (Landscape)', value: '1280x720' },
+ ]}
+ />
+
+
+ )}
);
diff --git a/lib/user-interface/react/src/components/chatbot/components/Sessions.module.css b/lib/user-interface/react/src/components/chatbot/components/Sessions.module.css
new file mode 100644
index 000000000..e7c80bd44
--- /dev/null
+++ b/lib/user-interface/react/src/components/chatbot/components/Sessions.module.css
@@ -0,0 +1,34 @@
+/**
+ 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.
+*/
+
+.sessionItem {
+ border-radius: 4px;
+ transition: background-color 0.2s ease;
+ cursor: pointer;
+}
+
+.sessionItem:hover {
+ background-color: #007bff14;
+}
+
+.sessionItemActive {
+ background-color: #007bff26;
+ border-radius: 4px;
+}
+
+.sessionItemActive:hover {
+ background-color: #007bff33;
+}
diff --git a/lib/user-interface/react/src/components/chatbot/components/Sessions.test.tsx b/lib/user-interface/react/src/components/chatbot/components/Sessions.test.tsx
index 63e5afd5a..d342b49c1 100644
--- a/lib/user-interface/react/src/components/chatbot/components/Sessions.test.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/Sessions.test.tsx
@@ -171,12 +171,8 @@ describe('Sessions', () => {
renderWithProviders(
);
- // Open search popover
- const searchButton = screen.getByLabelText('Search sessions');
- await user.click(searchButton);
-
// Type search query that won't match
- const searchInput = screen.getByPlaceholderText('Search sessions by name...');
+ const searchInput = screen.getByPlaceholderText('Search sessions by name');
await user.type(searchInput, 'NonExistentSession');
await waitFor(() => {
@@ -210,12 +206,8 @@ describe('Sessions', () => {
renderWithProviders(
);
- // Open search popover
- const searchButton = screen.getByLabelText('Search sessions');
- await user.click(searchButton);
-
// Type search query
- const searchInput = screen.getByPlaceholderText('Search sessions by name...');
+ const searchInput = screen.getByPlaceholderText('Search sessions by name');
await user.type(searchInput, 'Python');
await waitFor(() => {
@@ -234,7 +226,7 @@ describe('Sessions', () => {
renderWithProviders(
);
- const newSessionButton = screen.getByLabelText('New Session');
+ const newSessionButton = screen.getByRole('button', { name: /new/i });
await user.click(newSessionButton);
expect(mockNewSession).toHaveBeenCalledOnce();
diff --git a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx
index 831a27d3c..d687695b8 100644
--- a/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx
+++ b/lib/user-interface/react/src/components/chatbot/components/Sessions.tsx
@@ -18,12 +18,11 @@ import SpaceBetween from '@cloudscape-design/components/space-between';
import Link from '@cloudscape-design/components/link';
import Header from '@cloudscape-design/components/header';
import ExpandableSection from '@cloudscape-design/components/expandable-section';
-import { ButtonDropdown, Input, Popover, Modal, FormField, Grid } from '@cloudscape-design/components';
+import { ButtonDropdown, Input, Modal, FormField, Grid } from '@cloudscape-design/components';
import Button from '@cloudscape-design/components/button';
import { useLazyGetConfigurationQuery } from '@/shared/reducers/configuration.reducer';
import {
- sessionApi,
useDeleteAllSessionsForUserMutation,
useDeleteSessionByIdMutation,
useLazyGetSessionByIdQuery,
@@ -33,15 +32,16 @@ import {
import { useAppDispatch } from '@/config/store';
import { useNotificationService } from '@/shared/util/hooks';
import { useEffect, useState, useMemo } from 'react';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../../auth/useAuth';
import { IConfiguration } from '@/shared/model/configuration.model';
import { useNavigate } from 'react-router-dom';
-import { fetchImage, getSessionDisplay, messageContainsImage } from '@/components/utils';
+import { getDisplayableMessage, getSessionDisplay, messageContainsImage, messageContainsVideo } from '@/components/utils';
import { LisaChatSession } from '@/components/types';
import Box from '@cloudscape-design/components/box';
import JSZip from 'jszip';
import { downloadFile } from '@/shared/util/downloader';
import { setConfirmationModal } from '@/shared/reducers/modal.reducer';
+import styles from './Sessions.module.css';
@@ -210,58 +210,42 @@ export function Sessions ({ newSession }) {
return (
-
+
-
-
- setSearchQuery(detail.value)}
- placeholder='Search sessions by name...'
- clearAriaLabel='Clear search'
- type='search'
- controlId='session-search-input'
- />
- {searchQuery && (
-
- Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''}
-
- )}
-
+ setSearchQuery(detail.value)}
+ placeholder='Search sessions by name'
+ clearAriaLabel='Clear search'
+ type='search'
+ style={
+ {
+ root: {
+ borderColor: {
+ focus: filteredSessions.length >= 1 ? '' : '#ff7a7a',
+ }
+ }
}
- >
-
-
+ }
+ />
+ {searchQuery && (
+ = 1 ? 'text-status-info' : 'text-status-error'}>
+ Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''}
+
+ )}
+
- dispatch(sessionApi.util.invalidateTags(['sessions']))}
- ariaLabel='Refresh Sessions'
- />
+ >
+ New
+
{config?.configuration.enabledComponents.deleteSessionHistory &&
dispatch(
setConfirmationModal({
@@ -271,9 +255,10 @@ export function Sessions ({ newSession }) {
description: 'This will delete all of your user sessions.'
})
)}
- ariaLabel='Delete All Sessions'
- />}
-
+ >
+ Delete All
+ }
+
{isSessionsLoading && (
@@ -319,7 +304,11 @@ export function Sessions ({ newSession }) {
>
{sessions.map((item) => (
-
+
navigate(`/ai-assistant/${item.sessionId}`)}>
@@ -335,9 +324,9 @@ export function Sessions ({ newSession }) {
{
const sess: LisaChatSession = resp.data;
- const images = sess.history.filter((msg) => msg.type === 'ai' && messageContainsImage(msg.content))
+ // Extract media with type information to distinguish images from videos
+ const media = sess.history.filter((msg) => msg.type === 'ai' && (messageContainsImage(msg.content) || messageContainsVideo(msg.content)))
.flatMap((msg) => {
if (Array.isArray(msg.content)) {
return msg.content
- .filter((contentItem: any) => contentItem.type === 'image_url' && contentItem.image_url?.url)
- .map((contentItem: any) => contentItem.image_url.url as string);
+ .filter((contentItem: any) => (contentItem.type === 'image_url' && contentItem.image_url?.url) || (contentItem.type === 'video_url' && contentItem.video_url?.url))
+ .map((contentItem: any) => ({
+ url: contentItem.type === 'image_url' ? contentItem.image_url.url as string : contentItem.video_url.url as string,
+ mediaType: contentItem.type === 'image_url' ? 'image' : 'video' as 'image' | 'video'
+ }));
}
return [];
});
- if (images.length === 0) {
- notificationService.generateNotification('No images found to export', 'info');
+ if (media.length === 0) {
+ notificationService.generateNotification('No media found to export', 'info');
} else {
const zip = new JSZip();
- const imagePromises = images.map(async (imageUrl, index) => {
+ let imageCount = 0;
+ let videoCount = 0;
+ const mediaPromises = media.map(async (mediaItem) => {
try {
- const blob = await fetchImage(imageUrl);
- zip.file(`image_${index + 1}.png`, blob, { binary: true });
+ const response = await fetch(mediaItem.url);
+ const blob = await response.blob();
+ if (mediaItem.mediaType === 'image') {
+ imageCount++;
+ zip.file(`image_${imageCount}.png`, blob, { binary: true });
+ } else {
+ videoCount++;
+ zip.file(`video_${videoCount}.mp4`, blob, { binary: true });
+ }
} catch (error) {
- console.error(`Error processing image ${index + 1}:`, error);
+ console.error(`Error processing ${mediaItem.mediaType}:`, error);
}
});
- // Wait for all images to be processed
- await Promise.all(imagePromises);
+ // Wait for all media to be processed
+ await Promise.all(mediaPromises);
const content = await zip.generateAsync({ type: 'blob' });
- downloadFile(URL.createObjectURL(content), `${sess.sessionId}-images.zip`);
+ downloadFile(URL.createObjectURL(content), `${sess.sessionId}-media.zip`);
}
});
} else if (e.detail.id === 'rename-session') {
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 0c6fb7176..dee335873 100644
--- a/lib/user-interface/react/src/components/chatbot/config/buttonConfig.tsx
+++ b/lib/user-interface/react/src/components/chatbot/config/buttonConfig.tsx
@@ -22,14 +22,18 @@ import { PromptTemplateType } from '@/shared/reducers/prompt-templates.reducer';
export const getButtonItems = (
config: IConfiguration,
useRag: boolean,
- isImageGenerationMode: boolean
+ isImageGenerationMode: boolean,
+ isVideoGenerationMode: boolean,
+ isConnected: boolean,
+ isModelDeleted: boolean = false
): ButtonGroupProps.Item[] => {
const baseItems: ButtonGroupProps.Item[] = [
{
type: 'icon-button',
id: 'settings',
iconName: 'settings',
- text: 'Session configuration'
+ text: 'Session configuration',
+ disabled: !isConnected || isModelDeleted
}
];
@@ -38,24 +42,25 @@ export const getButtonItems = (
// RAG Upload
if (config?.configuration.enabledComponents.uploadRagDocs &&
window.env.RAG_ENABLED &&
- !isImageGenerationMode) {
+ !isImageGenerationMode && !isVideoGenerationMode) {
conditionalItems.push({
type: 'icon-button',
id: 'upload-to-rag',
iconName: 'upload',
text: 'Upload to RAG',
- disabled: !useRag
+ disabled: !useRag || !isConnected || isModelDeleted
});
}
// Context Upload
if (config?.configuration.enabledComponents.uploadContextDocs &&
- !isImageGenerationMode) {
+ !isImageGenerationMode && !isVideoGenerationMode) {
conditionalItems.push({
type: 'icon-button',
id: 'add-file-to-context',
iconName: 'insert-row',
- text: 'Add file to context'
+ text: 'Add file to context',
+ disabled: !isConnected || isModelDeleted
});
}
@@ -65,26 +70,29 @@ export const getButtonItems = (
type: 'icon-button',
id: 'insert-prompt-template',
iconName: 'contact',
- text: 'Insert Prompt Template'
+ text: 'Insert Prompt Template',
+ disabled: !isConnected || isModelDeleted
});
}
// Document Summarization
- if (config?.configuration.enabledComponents.documentSummarization) {
+ if (config?.configuration.enabledComponents.documentSummarization && !isVideoGenerationMode && !isImageGenerationMode) {
conditionalItems.push({
type: 'icon-button',
id: 'summarize-document',
iconName: 'transcript',
- text: 'Summarize Document'
+ text: 'Summarize Document',
+ disabled: !isConnected || isModelDeleted
});
}
// Additional Configuration Dropdown
- if (config?.configuration.enabledComponents.editPromptTemplate && !isImageGenerationMode) {
+ if (config?.configuration.enabledComponents.editPromptTemplate && !isImageGenerationMode && !isVideoGenerationMode) {
conditionalItems.push({
type: 'menu-dropdown',
id: 'more-actions',
text: 'Additional Configuration',
+ disabled: !isConnected || isModelDeleted,
items: [
{
id: 'edit-prompt-template',
@@ -95,6 +103,16 @@ export const getButtonItems = (
});
}
+ if (isVideoGenerationMode || isImageGenerationMode) {
+ conditionalItems.push({
+ type: 'icon-button',
+ id: 'attach-reference-photo',
+ iconName: 'video-on',
+ text: 'Add Reference Photo',
+ disabled: !isConnected || isModelDeleted
+ });
+ }
+
return [...baseItems, ...conditionalItems];
};
@@ -124,6 +142,7 @@ export const useButtonActions = ({
setFilterPromptTemplateType(PromptTemplateType.Directive);
openModal('promptTemplate');
},
+ 'attach-reference-photo': () => openModal('contextUpload'),
};
const action = actions[detail.id];
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx
index 089b2a142..79fff6b72 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/chat.hooks.tsx
@@ -29,22 +29,103 @@ import { ChatMemory } from '@/shared/util/chat-memory';
import { useAppDispatch } from '@/config/store';
import { sessionApi } from '@/shared/reducers/session.reducer';
+/**
+ * Parses ... blocks from content and extracts them.
+ * Only extracts complete blocks (with both opening and closing tags).
+ * Returns the cleaned content (with thinking blocks removed) and the extracted thinking content.
+ */
+const parseThinkingBlocks = (content: string): { cleanedContent: string; thinkingContent: string } => {
+ if (!content || typeof content !== 'string') {
+ return { cleanedContent: content, thinkingContent: '' };
+ }
+
+ // Match complete ... blocks (case-insensitive)
+ const thinkingRegex = /([\s\S]*?)<\/thinking>/gi;
+ const thinkingBlocks: string[] = [];
+ let match;
+
+ // Extract all complete thinking blocks
+ while ((match = thinkingRegex.exec(content)) !== null) {
+ if (match[1]) {
+ thinkingBlocks.push(match[1].trim());
+ }
+ }
+
+ // Remove all complete thinking blocks from content
+ const cleanedContent = content.replace(thinkingRegex, '').trim();
+
+ // Combine all thinking blocks with newlines
+ const thinkingContent = thinkingBlocks.join('\n\n');
+
+ return { cleanedContent, thinkingContent };
+};
+
+/**
+ * Calculates response time in seconds from a start timestamp.
+ */
+const calculateResponseTime = (startTime: number): number => {
+ return parseFloat(((performance.now() - startTime) / 1000).toFixed(2));
+};
+
+/**
+ * Processes content to extract reasoning/thinking blocks when model supports reasoning.
+ * Returns cleaned content and reasoning content (preferring API-provided reasoning over parsed).
+ */
+const processReasoningContent = (
+ rawContent: string,
+ apiReasoningContent: string | undefined,
+ modelSupportsReasoning: boolean
+): { cleanedContent: string; reasoningContent: string | undefined } => {
+ if (!modelSupportsReasoning) {
+ return { cleanedContent: rawContent, reasoningContent: apiReasoningContent };
+ }
+
+ const parsed = parseThinkingBlocks(rawContent);
+ const reasoningContent = apiReasoningContent || parsed.thinkingContent || undefined;
+ return { cleanedContent: parsed.cleanedContent, reasoningContent };
+};
+
+/**
+ * Parses accumulated tool call data into final tool call objects.
+ */
+const finalizeToolCalls = (toolCallsAccumulator: { [index: number]: any }): any[] => {
+ return Object.values(toolCallsAccumulator).map((toolCall: any) => {
+ let parsedArgs = {};
+ try {
+ if (toolCall.args && typeof toolCall.args === 'string') {
+ parsedArgs = JSON.parse(toolCall.args.trim());
+ }
+ } catch {
+ parsedArgs = {};
+ }
+ return {
+ id: toolCall.id,
+ name: toolCall.name,
+ args: parsedArgs,
+ type: toolCall.type
+ };
+ }).filter((toolCall) => toolCall.name);
+};
+
// Custom hook for chat generation
export const useChatGeneration = ({
chatConfiguration,
selectedModel,
isImageGenerationMode,
+ isVideoGenerationMode,
session,
setSession,
metadata,
memory,
openAiTools,
auth,
- notificationService
+ notificationService,
+ fileContext
}: {
chatConfiguration: IChatConfiguration;
selectedModel: IModel;
isImageGenerationMode: boolean;
+ isVideoGenerationMode: boolean;
session: LisaChatSession;
setSession: React.Dispatch>;
metadata: LisaChatMessageMetadata;
@@ -52,14 +133,23 @@ export const useChatGeneration = ({
openAiTools: any;
auth: any;
notificationService: any;
+ fileContext?: string;
}) => {
const dispatch = useAppDispatch();
const [isRunning, setIsRunning] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
+ const [lastRequest, setLastRequest] = useState(null);
+ const [errorState, setErrorState] = useState(false);
const stopRequested = useRef(false);
const modelSupportsTools = selectedModel?.features?.filter((feature) => feature.name === ModelFeatures.TOOL_CALLS)?.length && true;
+ const modelSupportsReasoning = selectedModel?.features?.find((feature) => feature.name === ModelFeatures.REASONING) ? true : false;
const createOpenAiClient = useCallback((streaming: boolean) => {
+ const { reasoning_effort, ...modelKwargsWithoutReasoning } = chatConfiguration.sessionConfiguration?.modelArgs || {};
+ const modelKwargs = reasoning_effort === 'none'
+ ? modelKwargsWithoutReasoning
+ : chatConfiguration.sessionConfiguration?.modelArgs || {};
+
const modelConfig = {
modelName: selectedModel?.modelId,
// Use auth token as API key - LangChain will pass it in the Authorization header
@@ -70,16 +160,21 @@ export const useChatGeneration = ({
},
streaming,
maxTokens: chatConfiguration.sessionConfiguration?.max_tokens,
- modelKwargs: {
- ...chatConfiguration.sessionConfiguration?.modelArgs,
- }
+ modelKwargs
};
return new ChatOpenAI(modelConfig);
}, [selectedModel, auth, chatConfiguration]);
+ const retryResponse = async () => {
+ if (!lastRequest) return;
+ await generateResponse(lastRequest);
+ };
+
const generateResponse = async (params: GenerateLLMRequestParams) => {
setIsRunning(true);
+ setErrorState(false);
+ setLastRequest(params);
stopRequested.current = false;
const startTime = performance.now(); // Start client timer
@@ -87,65 +182,433 @@ export const useChatGeneration = ({
const isNewSession = session.history.length === 0;
try {
- // Handle image generation mode specifically
- if (isImageGenerationMode) {
+ // Handle video generation mode specifically
+ if (isVideoGenerationMode) {
try {
- // Create image generation request
- const imageGenParams = {
+ // Check if this is a remix operation
+ const remixVideoId = chatConfiguration.sessionConfiguration?.remixVideoId;
+ const isRemix = !!remixVideoId;
+
+ // Create video generation request
+ const videoGenParams: any = {
prompt: params.input,
model: selectedModel.modelId,
- n: chatConfiguration.sessionConfiguration.imageGenerationArgs.numberOfImages,
- size: chatConfiguration.sessionConfiguration.imageGenerationArgs.size,
- quality: chatConfiguration.sessionConfiguration.imageGenerationArgs.quality,
- response_format: 'url',
+ seconds: chatConfiguration.sessionConfiguration.videoGenerationArgs.seconds,
+ size: chatConfiguration.sessionConfiguration.videoGenerationArgs.size
};
- // Make API call to generate images
- const response = await fetch(`${RESTAPI_URI}/${RESTAPI_VERSION}/serve/images/generations`, {
- method: 'POST',
- headers: {
+ // Handle file context
+ let hasImageReference = false;
+ let imageBlob: Blob | null = null;
+
+ if (fileContext) {
+ // Check if it's an image context (base64 data URL)
+ if (fileContext.startsWith('File context: data:image')) {
+ const imageData = fileContext.replace('File context: ', '');
+ // Extract mime type and base64 data
+ const matches = imageData.match(/^data:(image\/[^;]+);base64,(.+)$/);
+ if (matches) {
+ const mimeType = matches[1];
+ const base64Data = matches[2];
+
+ // Convert base64 to Blob for multipart/form-data upload
+ // OpenAI requires input_reference as a file, not base64 string
+ const binaryString = atob(base64Data);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ imageBlob = new Blob([bytes], { type: mimeType });
+ hasImageReference = true;
+ }
+ } else {
+ // Text file context - prepend to prompt
+ videoGenParams.prompt = `${fileContext}\n\n${videoGenParams.prompt}`;
+ }
+ }
+
+ // Make API call to create video generation or remix request
+ const videoEndpoint = isRemix
+ ? `${RESTAPI_URI}/${RESTAPI_VERSION}/serve/videos/${remixVideoId}/remix`
+ : `${RESTAPI_URI}/${RESTAPI_VERSION}/serve/videos`;
+
+ // Use FormData if we have an image reference (only for non-remix requests)
+ // Remix doesn't support input_reference, so always use JSON for remix
+ let requestBody: FormData | string;
+ let requestHeaders: Record;
+
+ if (hasImageReference && imageBlob && !isRemix) {
+ // Use multipart/form-data for image reference
+ const formData = new FormData();
+ formData.append('prompt', videoGenParams.prompt);
+ formData.append('model', videoGenParams.model);
+ formData.append('seconds', videoGenParams.seconds);
+ formData.append('size', videoGenParams.size);
+
+ // Determine file extension from mime type
+ const mimeType = imageBlob.type;
+ let extension = '.jpg';
+ if (mimeType.includes('png')) extension = '.png';
+ else if (mimeType.includes('webp')) extension = '.webp';
+
+ formData.append('input_reference', imageBlob, `reference${extension}`);
+
+ requestBody = formData;
+ requestHeaders = {
+ 'Authorization': `Bearer ${auth.user?.id_token}`,
+ // Don't set Content-Type - browser will set it with boundary
+ };
+ } else {
+ // Use JSON for remix or when no image reference
+ requestBody = JSON.stringify(videoGenParams);
+ requestHeaders = {
'Authorization': `Bearer ${auth.user?.id_token}`,
'Content-Type': 'application/json',
- },
- body: JSON.stringify(imageGenParams),
+ };
+ }
+
+ const createResponse = await fetch(videoEndpoint, {
+ method: 'POST',
+ headers: requestHeaders,
+ body: requestBody,
});
- const data = await response.json();
+ const createData = await createResponse.json();
- if (!response.ok) {
- throw new Error(`Image generation failed: ${JSON.stringify(data.error.message)}`);
+ if (!createResponse.ok) {
+ throw new Error(`Video generation failed: ${JSON.stringify(createData.error?.message || createData)}`);
}
- const imageContent = data.data.map((img) => ({
- image_url: {
- url: `data:image/png;base64,${img.b64_json}`
- },
- type: 'image_url'
+ const videoId = createData.id || createData.video_id;
+ if (!videoId) {
+ throw new Error('Video generation response missing video ID');
+ }
+
+ // Create initial message with loading state
+ setSession((prev) => ({
+ ...prev,
+ history: [...prev.history, new LisaChatMessage({
+ type: 'ai',
+ content: 'Generating video...',
+ metadata: {
+ ...metadata,
+ videoGeneration: true,
+ videoGenerationParams: videoGenParams,
+ videoId: videoId,
+ videoStatus: 'processing',
+ hasFileContext: !!fileContext
+ },
+ })],
}));
- // Calculate response time
- const responseTime = (performance.now() - startTime) / 1000;
+ // Poll for video status
+ const pollInterval = 2000; // Poll every 2 seconds
+ const maxPollAttempts = 300; // Max 10 minutes (300 * 2s)
+ let pollAttempts = 0;
+ let videoReady = false;
+ let videoContent = null;
+
+ while (!videoReady && pollAttempts < maxPollAttempts && !stopRequested.current) {
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ pollAttempts++;
+
+ try {
+ const statusResponse = await fetch(`${RESTAPI_URI}/${RESTAPI_VERSION}/serve/videos/${videoId}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${auth.user?.id_token}`,
+ 'x-litellm-model-id': selectedModel.modelId,
+ },
+ });
+
+ const statusData = await statusResponse.json();
+
+ if (!statusResponse.ok) {
+ throw new Error(`Status check failed: ${JSON.stringify(statusData.error?.message || statusData)}`);
+ }
+
+ const status = statusData.status || statusData.state;
+
+ // Update status in message
+ setSession((prev) => {
+ const lastMessage = prev.history[prev.history.length - 1];
+ if (lastMessage?.metadata?.videoId === videoId) {
+ return {
+ ...prev,
+ history: [...prev.history.slice(0, -1),
+ new LisaChatMessage({
+ ...lastMessage,
+ content: status === 'completed' || status === 'ready'
+ ? 'Video ready!'
+ : `Generating video... (${status})`,
+ metadata: {
+ ...lastMessage.metadata,
+ videoStatus: status
+ }
+ })
+ ],
+ };
+ }
+ return prev;
+ });
+
+ if (status === 'completed' || status === 'ready' || status === 'succeeded') {
+ videoReady = true;
+
+ // Fetch video content (stored in S3 with presigned URL)
+ const contentResponse = await fetch(`${RESTAPI_URI}/${RESTAPI_VERSION}/serve/videos/${videoId}/content`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${auth.user?.id_token}`,
+ 'x-litellm-model-id': selectedModel.modelId,
+ },
+ });
+
+ if (!contentResponse.ok) {
+ throw new Error(`Failed to fetch video content: ${contentResponse.statusText}`);
+ }
+
+ // Response should be JSON with presigned URL and S3 key
+ const contentData = await contentResponse.json();
+ videoContent = {
+ url: contentData.url || contentData.content || contentData.data,
+ s3_key: contentData.s3_key
+ };
+
+ if (!videoContent.url) {
+ throw new Error('Video URL not found in response');
+ }
+ } else if (status === 'failed' || status === 'error') {
+ throw new Error(`Video generation failed: ${statusData.error?.message || 'Unknown error'}`);
+ }
+ } catch (pollError) {
+ // If polling fails, log but continue polling unless it's a clear failure
+ if (pollAttempts >= 5) {
+ throw pollError;
+ }
+ }
+ }
+
+ if (stopRequested.current) {
+ notificationService.generateNotification('Video generation stopped by user', 'info');
+ setSession((prev) => ({
+ ...prev,
+ history: prev.history.slice(0, -1),
+ }));
+ setIsRunning(false);
+ return;
+ }
+
+ if (!videoReady) {
+ throw new Error('Video generation timed out');
+ }
+
+ if (!videoContent) {
+ throw new Error('Video content not available');
+ }
+
+ const responseTime = calculateResponseTime(startTime);
- // Save the response to the chat history
+ // Update message with video content (presigned URL, S3 key) and video_id
+ setSession((prev) => {
+ const lastMessage = prev.history[prev.history.length - 1];
+ if (lastMessage?.metadata?.videoId === videoId) {
+ return {
+ ...prev,
+ history: [...prev.history.slice(0, -1),
+ new LisaChatMessage({
+ ...lastMessage,
+ content: [{
+ type: 'video_url',
+ video_url: {
+ url: videoContent.url,
+ s3_key: videoContent.s3_key,
+ video_id: videoId
+ }
+ }],
+ metadata: {
+ ...lastMessage.metadata,
+ videoStatus: 'completed'
+ },
+ usage: {
+ responseTime
+ }
+ })
+ ],
+ };
+ }
+ return prev;
+ });
+
+ await memory.saveContext({ input: params.input }, { output: videoContent });
+ } catch (error) {
+ notificationService.generateNotification('Video generation failed', 'error', undefined, error.message ? {error.message}
: undefined);
+ setSession((prev) => ({
+ ...prev,
+ history: prev.history.slice(0, -1),
+ }));
+ setIsRunning(false);
+ }
+ } else if (isImageGenerationMode) {
+ try {
+ // Create initial message with loading state
setSession((prev) => ({
...prev,
history: [...prev.history, new LisaChatMessage({
type: 'ai',
- content: imageContent,
+ content: 'Generating image...',
metadata: {
...metadata,
imageGeneration: true,
- imageGenerationParams: imageGenParams
+ imageGenerationStatus: 'processing',
+ hasFileContext: !!fileContext
},
- usage: {
- responseTime: parseFloat(responseTime.toFixed(2))
- }
})],
}));
+ // Check if there's a reference photo
+ let hasImageReference = false;
+ let imageBlob: Blob | null = null;
+
+ if (fileContext) {
+ // Check if it's an image context (base64 data URL)
+ if (fileContext.startsWith('File context: data:image')) {
+ const imageData = fileContext.replace('File context: ', '');
+ // Extract mime type and base64 data
+ const matches = imageData.match(/^data:(image\/[^;]+);base64,(.+)$/);
+ if (matches) {
+ const mimeType = matches[1];
+ const base64Data = matches[2];
+
+ // Convert base64 to Blob for multipart/form-data upload
+ // LiteLLM requires the image as a file object for image edits
+ const binaryString = atob(base64Data);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ imageBlob = new Blob([bytes], { type: mimeType });
+ hasImageReference = true;
+ }
+ }
+ }
+
+ // Choose endpoint based on whether we have a reference image
+ const imageEndpoint = hasImageReference
+ ? `${RESTAPI_URI}/${RESTAPI_VERSION}/serve/images/edits`
+ : `${RESTAPI_URI}/${RESTAPI_VERSION}/serve/images/generations`;
+
+ // Build request based on endpoint type
+ let requestBody: FormData | string;
+ let requestHeaders: Record;
+
+ if (hasImageReference && imageBlob) {
+ // Use multipart/form-data for image edits with reference photo
+ // LiteLLM requires this format for image edit operations
+ const formData = new FormData();
+ formData.append('prompt', params.input);
+ formData.append('model', selectedModel.modelId);
+ formData.append('n', chatConfiguration.sessionConfiguration.imageGenerationArgs.numberOfImages.toString());
+
+ // Determine file extension from mime type
+ const mimeType = imageBlob.type;
+ let extension = '.png';
+ if (mimeType.includes('jpeg') || mimeType.includes('jpg')) extension = '.jpg';
+ else if (mimeType.includes('webp')) extension = '.webp';
+
+ formData.append('image', imageBlob, `reference${extension}`);
+
+ requestBody = formData;
+ requestHeaders = {
+ 'Authorization': `Bearer ${auth.user?.id_token}`,
+ // Don't set Content-Type - browser will set it with boundary
+ };
+ } else {
+ // Use JSON for standard image generation
+ const imageGenParams = {
+ prompt: params.input,
+ model: selectedModel.modelId,
+ n: chatConfiguration.sessionConfiguration.imageGenerationArgs.numberOfImages,
+ size: chatConfiguration.sessionConfiguration.imageGenerationArgs.size,
+ quality: chatConfiguration.sessionConfiguration.imageGenerationArgs.quality,
+ response_format: 'url',
+ };
+
+ requestBody = JSON.stringify(imageGenParams);
+ requestHeaders = {
+ 'Authorization': `Bearer ${auth.user?.id_token}`,
+ 'Content-Type': 'application/json',
+ };
+ }
+
+ // Make API call to generate or edit images
+ const response = await fetch(imageEndpoint, {
+ method: 'POST',
+ headers: requestHeaders,
+ body: requestBody,
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ throw new Error(`Image ${hasImageReference ? 'edit' : 'generation'} failed: ${JSON.stringify(data.error?.message || data)}`);
+ }
+
+ const imageContent = data.data.map((img) => ({
+ image_url: {
+ url: `data:image/png;base64,${img.b64_json}`
+ },
+ type: 'image_url'
+ }));
+
+ const responseTime = calculateResponseTime(startTime);
+
+ // Build metadata with image generation parameters
+ const imageGenParams = {
+ prompt: params.input,
+ model: selectedModel.modelId,
+ n: chatConfiguration.sessionConfiguration.imageGenerationArgs.numberOfImages,
+ size: chatConfiguration.sessionConfiguration.imageGenerationArgs.size,
+ quality: chatConfiguration.sessionConfiguration.imageGenerationArgs.quality,
+ response_format: 'url',
+ };
+
+ // Update the loading message with the generated images
+ setSession((prev) => {
+ const lastMessage = prev.history[prev.history.length - 1];
+ if (lastMessage?.metadata?.imageGeneration && lastMessage?.metadata?.imageGenerationStatus === 'processing') {
+ return {
+ ...prev,
+ history: [...prev.history.slice(0, -1),
+ new LisaChatMessage({
+ ...lastMessage,
+ content: imageContent,
+ metadata: {
+ ...metadata,
+ imageGeneration: true,
+ imageGenerationParams: imageGenParams,
+ imageGenerationStatus: 'completed',
+ hasFileContext: !!fileContext,
+ isImageEdit: hasImageReference
+ },
+ usage: {
+ responseTime
+ }
+ })
+ ],
+ };
+ }
+ return prev;
+ });
+
await memory.saveContext({ input: params.input }, { output: imageContent });
} catch (error) {
notificationService.generateNotification('Image generation failed', 'error', undefined, error.message ? {error.message}
: undefined);
+ // Remove the loading message on error
+ setSession((prev) => ({
+ ...prev,
+ history: prev.history.slice(0, -1),
+ }));
setIsRunning(false);
}
} else {
@@ -181,6 +644,16 @@ export const useChatGeneration = ({
arguments: JSON.stringify(toolCall.args)
}
}));
+ if (modelSupportsReasoning) {
+ if (msg.reasoningContent) {
+ const thinkingBlock: any = {
+ type: 'thinking',
+ thinking: msg.reasoningContent
+ };
+ thinkingBlock.signature = msg.reasoningSignature;
+ baseMessage.content.unshift(thinkingBlock);
+ }
+ }
}
return baseMessage;
@@ -208,6 +681,9 @@ export const useChatGeneration = ({
const stream = await llmClient.stream(messages, { tools: modelSupportsTools ? openAiTools : undefined });
const resp: string[] = [];
const toolCallsAccumulator: { [index: number]: any } = {};
+ let reasoningContentAccumulator = '';
+ let reasoningSignatureAccumulator = '';
+ let rawContentAccumulator = ''; // Accumulate raw content to parse thinking blocks
let guardrailTriggered = false;
@@ -220,6 +696,11 @@ export const useChatGeneration = ({
const content = chunk.content as string;
+ // Accumulate raw content for thinking block parsing
+ if (content) {
+ rawContentAccumulator += content;
+ }
+
// Check if this chunk indicates a guardrail was triggered
const isGuardrailTriggered = (chunk as any).id === 'guardrail-response';
@@ -227,6 +708,27 @@ export const useChatGeneration = ({
guardrailTriggered = true;
}
+ // Accumulate reasoning content from additional_kwargs
+ if ((chunk as any).additional_kwargs?.reasoning_content) {
+ reasoningContentAccumulator += (chunk as any).additional_kwargs.reasoning_content;
+ }
+
+ if ((chunk as any).additional_kwargs?.thinking_signature) {
+ reasoningSignatureAccumulator += (chunk as any).additional_kwargs.thinking_signature;
+ }
+
+ // Parse thinking blocks from accumulated content (only if model supports reasoning)
+ // Only parse complete blocks (wait for closing tag)
+ const { cleanedContent, reasoningContent: parsedReasoning } = processReasoningContent(
+ rawContentAccumulator,
+ reasoningContentAccumulator || undefined,
+ modelSupportsReasoning
+ );
+ // Update reasoning content if parsed content is longer (complete block found)
+ if (parsedReasoning && (!reasoningContentAccumulator || parsedReasoning.length > reasoningContentAccumulator.length)) {
+ reasoningContentAccumulator = parsedReasoning;
+ }
+
// Get tool calls from LangChain streaming chunks
let tool_calls: any[] = [];
@@ -317,13 +819,18 @@ export const useChatGeneration = ({
setSession((prev) => {
const lastMessage = prev.history[prev.history.length - 1];
+ // Use cleaned content (with thinking blocks removed) for display
+ const newContent = cleanedContent;
+ const finalContent = (reasoningContentAccumulator && !newContent.trim()) ? '\u00A0' : newContent;
return {
...prev,
history: [...prev.history.slice(0, -1),
new LisaChatMessage({
...lastMessage,
- content: lastMessage.content + content,
- toolCalls: currentToolCalls
+ content: finalContent,
+ toolCalls: currentToolCalls,
+ reasoningContent: reasoningContentAccumulator || undefined,
+ reasoningSignature: reasoningSignatureAccumulator,
})
],
};
@@ -332,23 +839,7 @@ export const useChatGeneration = ({
}
// Finalize tool calls with complete JSON parsing
- const finalToolCalls = Object.values(toolCallsAccumulator).map((toolCall: any) => {
- let parsedArgs = {};
- try {
- if (toolCall.args && typeof toolCall.args === 'string') {
- parsedArgs = JSON.parse(toolCall.args.trim());
- }
- } catch {
- parsedArgs = {};
- }
-
- return {
- id: toolCall.id,
- name: toolCall.name,
- args: parsedArgs,
- type: toolCall.type
- };
- }).filter((toolCall) => toolCall.name);
+ const finalToolCalls = finalizeToolCalls(toolCallsAccumulator);
// Update with final parsed tool calls
if (finalToolCalls.length > 0) {
@@ -369,19 +860,29 @@ export const useChatGeneration = ({
});
}
- // Calculate response time and update the final message with usage info
- const responseTime = (performance.now() - startTime) / 1000;
+ // Final parse of thinking blocks from complete response
+ const finalRawContent = resp.join('');
+ const { cleanedContent: finalCleanedContent, reasoningContent: finalReasoningContent } = processReasoningContent(
+ finalRawContent,
+ reasoningContentAccumulator || undefined,
+ modelSupportsReasoning
+ );
+
+ const responseTime = calculateResponseTime(startTime);
setSession((prev) => {
const lastMessage = prev.history[prev.history.length - 1];
if (lastMessage?.type === MessageTypes.AI) {
let updatedHistory = [...prev.history.slice(0, -1),
new LisaChatMessage({
...lastMessage,
+ content: finalCleanedContent,
usage: {
...lastMessage.usage,
- responseTime: parseFloat(responseTime.toFixed(2))
+ responseTime
},
- guardrailTriggered: guardrailTriggered
+ guardrailTriggered: guardrailTriggered,
+ reasoningContent: finalReasoningContent,
+ reasoningSignature: reasoningSignatureAccumulator,
})
];
@@ -398,7 +899,7 @@ export const useChatGeneration = ({
return prev;
});
- await memory.saveContext({ input: params.input }, { output: resp.join('') });
+ await memory.saveContext({ input: params.input }, { output: finalCleanedContent });
setIsStreaming(false);
} catch (exception) {
setSession((prev) => ({
@@ -409,28 +910,38 @@ export const useChatGeneration = ({
}
} else {
const response = await llmClient.invoke(messages, { tools: modelSupportsTools ? openAiTools : undefined });
- const content = response.content as string;
+ const rawContent = response.content as string;
const usage = (response.response_metadata as any)?.tokenUsage;
// Check if guardrail was triggered
const isGuardrailTriggered = (response as any)?.id === 'guardrail-response';
- // Calculate response time
- const responseTime = (performance.now() - startTime) / 1000;
+ // Get reasoning content from API (preferred) and parse thinking blocks
+ const apiReasoningContent = (response as any).additional_kwargs?.reasoning_content;
+ const reasoningSignature = (response as any).additional_kwargs?.thinking_signature;
+ const { cleanedContent, reasoningContent } = processReasoningContent(
+ rawContent,
+ apiReasoningContent,
+ modelSupportsReasoning
+ );
+
+ const responseTime = calculateResponseTime(startTime);
- await memory.saveContext({ input: params.input }, { output: content });
+ await memory.saveContext({ input: params.input }, { output: cleanedContent });
- // Create the AI message
+ // Create the AI message with cleaned content (thinking blocks removed)
const aiMessage = new LisaChatMessage({
type: 'ai',
- content,
+ content: cleanedContent,
metadata,
toolCalls: [...(response.tool_calls ?? [])],
usage: {
...usage,
- responseTime: parseFloat(responseTime.toFixed(2))
+ responseTime
},
- guardrailTriggered: isGuardrailTriggered
+ guardrailTriggered: isGuardrailTriggered,
+ reasoningContent,
+ reasoningSignature
});
setSession((prev) => {
@@ -451,6 +962,7 @@ export const useChatGeneration = ({
} catch (error) {
notificationService.generateNotification('An error occurred while processing your request.', 'error', undefined, error.error?.message ? {JSON.stringify(error.error.message)}
: undefined);
setIsRunning(false);
+ setErrorState(true);
throw error;
} finally {
setIsRunning(false);
@@ -471,5 +983,5 @@ export const useChatGeneration = ({
stopRequested.current = true;
}, []);
- return { isRunning, setIsRunning, isStreaming, setIsStreaming, generateResponse, createOpenAiClient, stopGeneration };
+ return { isRunning, setIsRunning, isStreaming, setIsStreaming, generateResponse, createOpenAiClient, stopGeneration, retryResponse, errorState };
};
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/mcp.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/mcp.hooks.tsx
index 89031012a..4a8ca8220 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/mcp.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/mcp.hooks.tsx
@@ -104,7 +104,7 @@ export const useMultipleMcp = (servers: McpServer[], mcpPreferences: McpPreferen
useEffect(() => {
// Combine all tools from all servers
const combinedTools = Array.from(serverToolsMap.values()).flat();
- setAllTools(combinedTools);
+ queueMicrotask(() => setAllTools(combinedTools));
}, [serverToolsMap]);
const callTool = useCallback(async (toolName: string, args: any) => {
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx
index 82704fdc4..68f520a8f 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.test.tsx
@@ -16,7 +16,7 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../../auth/useAuth';
import { useMemory } from './useMemory.hooks';
import { LisaChatSession } from '@/components/types';
@@ -24,7 +24,7 @@ import { IChatConfiguration } from '@/shared/model/chat.configurations.model';
import { IModel } from '@/shared/model/model-management.model';
import { ChatMemory } from '@/shared/util/chat-memory';
-vi.mock('react-oidc-context');
+vi.mock('../../../auth/useAuth');
const createMockSession = (overrides?: Partial): LisaChatSession => ({
sessionId: 'test-session-id',
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx
index 2e1aa03ec..1e1142887 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useMemory.hooks.tsx
@@ -15,7 +15,7 @@
*/
import { useEffect, useMemo, useState } from 'react';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../../auth/useAuth';
import { ChatMemory } from '@/shared/util/chat-memory';
import { LisaChatMessageHistory } from '@/components/adapters/lisa-chat-history';
import { LisaChatMessageMetadata, LisaChatSession } from '@/components/types';
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useModels.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useModels.hooks.tsx
index 392f6a615..da488a022 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useModels.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useModels.hooks.tsx
@@ -17,6 +17,7 @@
import { useMemo } from 'react';
import { IModel, ModelType } from '@/shared/model/model-management.model';
import { IChatConfiguration } from '@/shared/model/chat.configurations.model';
+import { ModelFeatures } from '@/components/types';
export const useModels = (
allModels: IModel[] | undefined,
@@ -37,21 +38,31 @@ export const useModels = (
} else {
const model = allModels?.find((model) => model.modelId === value);
if (model) {
- // Auto-adjust streaming configuration based on model capabilities
- if (!model.streaming && chatConfiguration.sessionConfiguration.streaming) {
- setChatConfiguration({
- ...chatConfiguration,
- sessionConfiguration: {
- ...chatConfiguration.sessionConfiguration,
- streaming: false
- }
- });
- } else if (model.streaming && !chatConfiguration.sessionConfiguration.streaming) {
+ // Determine what configuration changes are needed
+ const modelSupportsStreaming = model.streaming ?? false;
+ const modelSupportsReasoning = !!model.features?.find((feature) => feature.name === ModelFeatures.REASONING);
+ const currentStreaming = chatConfiguration.sessionConfiguration.streaming;
+ const hasReasoningEffort = !!chatConfiguration.sessionConfiguration.modelArgs.reasoning_effort;
+
+ // Calculate the new configuration values
+ const shouldUpdateStreaming = (modelSupportsStreaming !== currentStreaming);
+ const shouldUpdateReasoning = (modelSupportsReasoning !== hasReasoningEffort);
+
+ // Only update configuration if changes are needed
+ if (shouldUpdateStreaming || shouldUpdateReasoning) {
setChatConfiguration({
...chatConfiguration,
sessionConfiguration: {
...chatConfiguration.sessionConfiguration,
- streaming: true
+ streaming: shouldUpdateStreaming ? modelSupportsStreaming : currentStreaming,
+ modelArgs: {
+ ...chatConfiguration.sessionConfiguration.modelArgs,
+ ...(shouldUpdateReasoning
+ ? modelSupportsReasoning
+ ? { reasoning_effort: 'medium', top_p: 0.95 }
+ : { reasoning_effort: null, top_p: 0.01 }
+ : {})
+ }
}
});
}
@@ -64,9 +75,13 @@ export const useModels = (
const isImageGenerationMode = (selectedModel: IModel | undefined) =>
selectedModel?.modelType === ModelType.imagegen;
+ const isVideoGenerationMode = (selectedModel: IModel | undefined) =>
+ selectedModel?.modelType === ModelType.videogen;
+
return {
modelsOptions,
handleModelChange,
isImageGenerationMode,
+ isVideoGenerationMode,
};
};
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx
index 1e008740e..89551b314 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.test.tsx
@@ -18,13 +18,13 @@ import { renderHook, act } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../../auth/useAuth';
import { useSession } from './useSession.hooks';
import breadcrumbsReducer from '@/shared/reducers/breadcrumbs.reducer';
-// Mock react-oidc-context
-vi.mock('react-oidc-context');
+// Mock the auth abstraction
+vi.mock('../../../auth/useAuth');
// Mock uuid
vi.mock('uuid', () => ({
diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx
index 219f014c1..692dc2c18 100644
--- a/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx
+++ b/lib/user-interface/react/src/components/chatbot/hooks/useSession.hooks.tsx
@@ -15,7 +15,7 @@
*/
import { useCallback, useEffect, useState, useRef } from 'react';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../../auth/useAuth';
import { v4 as uuidv4 } from 'uuid';
import { LisaChatSession } from '@/components/types';
import { baseConfig, IChatConfiguration } from '@/shared/model/chat.configurations.model';
@@ -99,16 +99,14 @@ export const useSession = (sessionId: string, getSessionById: any) => {
setSession((prev) => ({ ...prev, history: [] }));
loadSession(sessionId);
}
- } else {
- // No sessionId in URL - create a new session only once
- // Use ref to prevent creating multiple sessions if effect runs multiple times
- if (!hasCreatedNewSessionRef.current) {
- hasCreatedNewSessionRef.current = true;
- createNewSession();
- }
+ } else if (!internalSessionId || internalSessionId !== session.sessionId || session.history.length > 0) {
+ // Create new session when:
+ // - No sessionId provided AND no internal session yet, OR
+ // - Transitioning from an existing session (internalSessionId doesn't match current session or has history)
+ createNewSession();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sessionId, dispatch, loadSession, createNewSession]);
+ }, [sessionId, dispatch, loadSession]);
return {
session,
diff --git a/lib/user-interface/react/src/components/common/ButtonBadge.tsx b/lib/user-interface/react/src/components/common/ButtonBadge.tsx
index b3670e169..56abd4c63 100644
--- a/lib/user-interface/react/src/components/common/ButtonBadge.tsx
+++ b/lib/user-interface/react/src/components/common/ButtonBadge.tsx
@@ -17,6 +17,7 @@
import { Button, SpaceBetween, TextContent } from '@cloudscape-design/components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
+import kebabCase from 'lodash/kebabCase';
type ButtonBadgeProps = {
text: string;
@@ -31,8 +32,10 @@ export const ButtonBadge = ({ text, icon, onClick, show, dataTestId }: ButtonBad
return null;
}
+ const testId = dataTestId ?? `${kebabCase(text)}-button`;
+
return (
-
+
{text}
diff --git a/lib/user-interface/react/src/components/common/RefreshButton.tsx b/lib/user-interface/react/src/components/common/RefreshButton.tsx
new file mode 100644
index 000000000..5f9913b04
--- /dev/null
+++ b/lib/user-interface/react/src/components/common/RefreshButton.tsx
@@ -0,0 +1,38 @@
+/**
+ 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 { ReactElement } from 'react';
+import { Button, Icon, Spinner } from '@cloudscape-design/components';
+
+export type RefreshButtonProps = {
+ isLoading: boolean;
+ onClick: () => void;
+ ariaLabel?: string;
+};
+
+export function RefreshButton ({ isLoading, onClick, ariaLabel = 'Refresh' }: RefreshButtonProps): ReactElement {
+ return (
+
+ {isLoading ? : }
+
+ );
+}
+
+export default RefreshButton;
diff --git a/lib/user-interface/react/src/components/configuration/ConfigurationComponent.tsx b/lib/user-interface/react/src/components/configuration/ConfigurationComponent.tsx
index 2b7a026c2..3826e7255 100644
--- a/lib/user-interface/react/src/components/configuration/ConfigurationComponent.tsx
+++ b/lib/user-interface/react/src/components/configuration/ConfigurationComponent.tsx
@@ -29,6 +29,7 @@ import { getJsonDifference } from '../../shared/util/validationUtils';
import { setConfirmationModal } from '../../shared/reducers/modal.reducer';
import { useNotificationService } from '../../shared/util/hooks';
import { mcpServerApi } from '@/shared/reducers/mcp-server.reducer';
+import { getDisplayName } from '../../shared/util/branding';
export type ConfigState = {
validateAll: boolean;
@@ -152,7 +153,7 @@ export function ConfigurationComponent (): ReactElement {
variant='h1'
description={'Activate and deactivate platform features'}
>
- LISA Feature Configuration
+ {getDisplayName()} Feature Configuration
- {
if (admin) {
actions.setSelectedItems([]);
@@ -214,9 +217,7 @@ export function CollectionLibraryComponent ({ admin = false }: CollectionLibrary
dispatch(ragApi.util.invalidateTags(['collections']));
}}
ariaLabel='Refresh collections'
- >
-
-
+ />
{admin && (
<>
- {
actions.setSelectedItems([]);
dispatch(ragApi.util.invalidateTags(['docs']));
}}
- ariaLabel={'Refresh documents'}
- >
-
-
+ ariaLabel='Refresh documents'
+ />
void;
onEdit: (server: HostedMcpServer) => void;
refetch: () => void;
+ isFetching: boolean;
};
const DELETABLE_STATUSES = new Set([
@@ -40,7 +42,7 @@ const DELETABLE_STATUSES = new Set([
HostedMcpServerStatus.Failed,
]);
-export function McpManagementActions ({ selectedItems, setSelectedItems, refetch, onCreate, onEdit }: McpManagementActionsProps): ReactElement {
+export function McpManagementActions ({ selectedItems, setSelectedItems, refetch, onCreate, onEdit, isFetching }: McpManagementActionsProps): ReactElement {
const dispatch = useAppDispatch();
const notificationService = useNotificationService(dispatch);
@@ -124,15 +126,14 @@ export function McpManagementActions ({ selectedItems, setSelectedItems, refetch
return (
<>
- {
setSelectedItems([]);
refetch();
}}
- >
-
-
+ ariaLabel='Refresh MCP servers'
+ />
item.disabled)}
diff --git a/lib/user-interface/react/src/components/mcp-management/McpManagementComponent.tsx b/lib/user-interface/react/src/components/mcp-management/McpManagementComponent.tsx
index 59d80f57c..715cf6fe0 100644
--- a/lib/user-interface/react/src/components/mcp-management/McpManagementComponent.tsx
+++ b/lib/user-interface/react/src/components/mcp-management/McpManagementComponent.tsx
@@ -42,6 +42,7 @@ import {
} from './McpManagementTableConfig';
import { setBreadcrumbs } from '@/shared/reducers/breadcrumbs.reducer';
import { useAppDispatch } from '@/config/store';
+import { getDisplayName } from '@/shared/util/branding';
type Preferences = ReturnType;
@@ -178,11 +179,12 @@ export function McpManagementComponent (): ReactElement {
refetch={refetch}
onCreate={handleCreate}
onEdit={handleEdit}
+ isFetching={isFetching}
/>
}
- description='Host MCP servers within LISA MCP infrastructure.'
+ description={`Host MCP servers within ${getDisplayName()} MCP infrastructure.`}
>
- LISA MCP servers
+ {getDisplayName()} MCP servers
}
filter={
diff --git a/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx b/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx
index 1199f150e..c7aaeeae9 100644
--- a/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx
+++ b/lib/user-interface/react/src/components/mcp-workbench/McpWorkbenchManagementComponent.tsx
@@ -15,6 +15,7 @@
*/
import { Button, Container, Grid, SpaceBetween, List, Header, Box, Input, FormField, TextFilter, Pagination, Link, TextContent, Spinner } from '@cloudscape-design/components';
+import { RefreshButton } from '@/components/common/RefreshButton';
import AceEditor from 'react-ace';
import {Editor} from 'ace-builds';
@@ -363,9 +364,8 @@ export function McpWorkbenchManagementComponent (): ReactElement {
variant='h3'
actions={
- {
// Invalidate cache - this will automatically trigger refetch of active queries
dispatch(mcpToolsApi.util.invalidateTags(['mcpTools']));
diff --git a/lib/user-interface/react/src/components/mcp/McpServerActions.tsx b/lib/user-interface/react/src/components/mcp/McpServerActions.tsx
index 81bd89ed5..3f11f8377 100644
--- a/lib/user-interface/react/src/components/mcp/McpServerActions.tsx
+++ b/lib/user-interface/react/src/components/mcp/McpServerActions.tsx
@@ -15,7 +15,7 @@
*/
import React, { ReactElement, useEffect } from 'react';
-import { Button, ButtonDropdown, Icon, SpaceBetween } from '@cloudscape-design/components';
+import { Button, ButtonDropdown, SpaceBetween } from '@cloudscape-design/components';
import { useAppDispatch, useAppSelector } from '../../config/store';
import { useNotificationService } from '../../shared/util/hooks';
import { INotificationService } from '../../shared/notification/notification.service';
@@ -24,8 +24,9 @@ import { Action, ThunkDispatch } from '@reduxjs/toolkit';
import { setConfirmationModal } from '../../shared/reducers/modal.reducer';
import { NavigateFunction, useNavigate } from 'react-router-dom';
import { selectCurrentUserIsAdmin, selectCurrentUsername } from '../../shared/reducers/user.reducer';
-import { McpServer, mcpServerApi, useDeleteMcpServerMutation } from '@/shared/reducers/mcp-server.reducer';
+import { McpServer, mcpServerApi, useDeleteMcpServerMutation, useListMcpServersQuery } from '@/shared/reducers/mcp-server.reducer';
import { McpPreferences } from '@/shared/reducers/user-preferences.reducer';
+import { RefreshButton } from '@/components/common/RefreshButton';
export type McpServerActionsProps = {
selectedItems: readonly McpServer[];
@@ -41,18 +42,18 @@ export function McpServerActions (props: McpServerActionsProps): ReactElement {
const isUserAdmin = useAppSelector(selectCurrentUserIsAdmin);
const username = useAppSelector(selectCurrentUsername);
const preferences = props.preferences;
+ const { isFetching } = useListMcpServersQuery();
return (
- {
props.setSelectedItems([]);
dispatch(mcpServerApi.util.invalidateTags(['mcpServers']));
}}
- ariaLabel={'Refresh MCP Connections'}
- >
-
-
+ ariaLabel='Refresh MCP Connections'
+ />
{McpServerActionButton(dispatch, notificationService, props, {isUserAdmin, username, preferences})}
{
navigate('./new');
diff --git a/lib/user-interface/react/src/components/mcp/McpServerDetails.tsx b/lib/user-interface/react/src/components/mcp/McpServerDetails.tsx
index 780507679..eb4dd7729 100644
--- a/lib/user-interface/react/src/components/mcp/McpServerDetails.tsx
+++ b/lib/user-interface/react/src/components/mcp/McpServerDetails.tsx
@@ -20,11 +20,12 @@ import {
Header,
Pagination,
SpaceBetween,
+ Spinner,
Table,
TextContent, Toggle
} from '@cloudscape-design/components';
import 'react';
-import React, { useEffect, useState } from 'react';
+import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useCollection } from '@cloudscape-design/collection-hooks';
import { useLazyGetMcpServerQuery } from '@/shared/reducers/mcp-server.reducer';
@@ -34,86 +35,56 @@ import Box from '@cloudscape-design/components/box';
import { setBreadcrumbs } from '@/shared/reducers/breadcrumbs.reducer';
import { useAppDispatch, useAppSelector } from '@/config/store';
import {
- DefaultUserPreferences, McpPreferences,
- useGetUserPreferencesQuery, UserPreferences,
- useUpdateUserPreferencesMutation
+ DefaultUserPreferences,
+ useGetUserPreferencesQuery, UserPreferences
} from '@/shared/reducers/user-preferences.reducer';
-import { useNotificationService } from '@/shared/util/hooks';
import { selectCurrentUsername } from '@/shared/reducers/user.reducer';
+import { useMcpPreferencesUpdate } from './hooks/useMcpPreferencesUpdate';
export function McpServerDetails () {
const { mcpServerId } = useParams();
const dispatch = useAppDispatch();
const [getMcpServerQuery, {isUninitialized, data, isFetching, isSuccess}] = useLazyGetMcpServerQuery();
const {data: userPreferences} = useGetUserPreferencesQuery();
- const [preferences, setPreferences] = useState(undefined);
const userName = useAppSelector(selectCurrentUsername);
- const [updatePreferences, {isSuccess: isUpdatingSuccess, isError: isUpdatingError, error: updateError}] = useUpdateUserPreferencesMutation();
- const notificationService = useNotificationService(dispatch);
- // create success notification
- useEffect(() => {
- if (isUpdatingSuccess) {
- notificationService.generateNotification('Successfully updated tool preferences', 'success');
- }
- }, [isUpdatingSuccess, notificationService]);
-
- // create failure notification
- useEffect(() => {
- if (isUpdatingError) {
- const errorMessage = 'data' in updateError ? (updateError.data?.message ?? updateError.data) : updateError.message;
- notificationService.generateNotification(`Error updating tool preferences: ${errorMessage}`, 'error');
- }
- }, [isUpdatingError, updateError, notificationService]);
+ const { updatingItemId: updatingToolName, isUpdating, updateMcpPreferences } = useMcpPreferencesUpdate({
+ successMessage: 'Successfully updated tool preferences',
+ errorMessage: 'Error updating tool preferences'
+ });
- useEffect(() => {
- if (userPreferences) {
- setPreferences(userPreferences);
- } else {
- setPreferences({...DefaultUserPreferences, user: userName});
- }
- }, [userPreferences, userName]);
+ // Derive preferences from userPreferences or defaults
+ const preferences = userPreferences || {...DefaultUserPreferences, user: userName};
+ const [localPreferences, setLocalPreferences] = useState(preferences);
const toggleTool = (toolName: string, enabled: boolean) => {
- const existingMcpPrefs = preferences.preferences.mcp ?? {enabledServers: [], overrideAllApprovals: false};
- const mcpPrefs: McpPreferences = {
- ...existingMcpPrefs,
- enabledServers: [...existingMcpPrefs.enabledServers]
- };
-
- const originalServer = mcpPrefs.enabledServers.find((server) => server.id === mcpServerId);
- if (!originalServer) return; // Early return if server not found
-
- // Create a deep copy of the server object with its nested arrays
- const serverToUpdate = {
- ...originalServer,
- disabledTools: [...originalServer.disabledTools],
- };
-
- if (enabled) {
- serverToUpdate.disabledTools = serverToUpdate.disabledTools.filter((item) => item !== toolName);
- } else {
- serverToUpdate.disabledTools = [...serverToUpdate.disabledTools, toolName];
- }
-
- mcpPrefs.enabledServers = [
- ...mcpPrefs.enabledServers.filter((server) => server.id !== mcpServerId),
- serverToUpdate
- ];
- updatePrefs(mcpPrefs);
- };
-
- const updatePrefs = (mcpPrefs: McpPreferences) => {
- const updated = {...preferences,
- preferences: {...preferences.preferences,
- mcp: {
- ...preferences.preferences.mcp,
- ...mcpPrefs
+ updateMcpPreferences(
+ toolName,
+ localPreferences,
+ (existingMcpPrefs) => {
+ const originalServer = existingMcpPrefs.enabledServers.find((server) => server.id === mcpServerId);
+ if (!originalServer) {
+ return existingMcpPrefs; // Return unchanged if server not found
}
- }
- };
- setPreferences(updated);
- updatePreferences(updated);
+
+ // Create a deep copy of the server with updated disabled tools
+ const serverToUpdate = {
+ ...originalServer,
+ disabledTools: enabled
+ ? originalServer.disabledTools.filter((item) => item !== toolName)
+ : [...originalServer.disabledTools, toolName]
+ };
+
+ return {
+ ...existingMcpPrefs,
+ enabledServers: [
+ ...existingMcpPrefs.enabledServers.filter((server) => server.id !== mcpServerId),
+ serverToUpdate
+ ]
+ };
+ },
+ setLocalPreferences
+ );
};
if (isSuccess) {
@@ -185,7 +156,17 @@ export function McpServerDetails () {
pagination={ }
items={items}
columnDefinitions={[
- { header: 'Use tool', cell: (item) => server.id === mcpServerId)?.disabledTools.includes(item.name)} onChange={({detail}) => toggleTool(item.name, detail.checked)}/>},
+ { header: 'Use tool', cell: (item) => (
+ updatingToolName === item.name ? (
+
+ ) : (
+ server.id === mcpServerId)?.disabledTools.includes(item.name)}
+ onChange={({detail}) => toggleTool(item.name, detail.checked)}
+ disabled={isUpdating}
+ />
+ )
+ )},
{ header: 'Name', cell: (item) => item.name},
{ header: 'Description', cell: (item) => item.description},
]}
diff --git a/lib/user-interface/react/src/components/mcp/McpServerForm.tsx b/lib/user-interface/react/src/components/mcp/McpServerForm.tsx
index 73381c429..507a3374b 100644
--- a/lib/user-interface/react/src/components/mcp/McpServerForm.tsx
+++ b/lib/user-interface/react/src/components/mcp/McpServerForm.tsx
@@ -177,15 +177,17 @@ export function McpServerForm (props: McpServerFormProps) {
// Reset test connection state when URL changes
useEffect(() => {
if (testConnectionUrl !== state.form.url) {
- setTestConnectionUrl('');
- setIsTestingConnection(false);
+ queueMicrotask(() => {
+ setTestConnectionUrl('');
+ setIsTestingConnection(false);
+ });
}
}, [state.form.url, testConnectionUrl]);
// Reset testing state when connection completes
useEffect(() => {
if (testConnectionUrl && (connectionState === 'ready' || connectionState === 'failed')) {
- setIsTestingConnection(false);
+ queueMicrotask(() => setIsTestingConnection(false));
}
}, [connectionState, testConnectionUrl]);
diff --git a/lib/user-interface/react/src/components/mcp/McpServerManagementComponent.tsx b/lib/user-interface/react/src/components/mcp/McpServerManagementComponent.tsx
index 24739d556..63201a662 100644
--- a/lib/user-interface/react/src/components/mcp/McpServerManagementComponent.tsx
+++ b/lib/user-interface/react/src/components/mcp/McpServerManagementComponent.tsx
@@ -20,6 +20,7 @@ import {
Link,
Pagination,
SpaceBetween,
+ Spinner,
Table,
TextContent,
Toggle
@@ -36,13 +37,13 @@ import {
import { useAppDispatch, useAppSelector } from '@/config/store';
import { selectCurrentUserIsAdmin } from '@/shared/reducers/user.reducer';
import {
- DefaultUserPreferences, McpPreferences,
+ DefaultUserPreferences,
useGetUserPreferencesQuery,
- UserPreferences, useUpdateUserPreferencesMutation
+ UserPreferences
} from '@/shared/reducers/user-preferences.reducer';
-import { useNotificationService } from '@/shared/util/hooks';
import { setBreadcrumbs } from '@/shared/reducers/breadcrumbs.reducer';
import { formatDate } from '@/shared/util/formats';
+import { useMcpPreferencesUpdate } from './hooks/useMcpPreferencesUpdate';
export function McpServerManagementComponent () {
const navigate = useNavigate();
@@ -51,8 +52,11 @@ export function McpServerManagementComponent () {
const {data: userPreferences} = useGetUserPreferencesQuery();
const { data: {Items: allItems} = {Items: []}, isFetching } = useListMcpServersQuery(undefined, {});
const [preferences, setPreferences] = useState(undefined);
- const [updatePreferences, {isSuccess: isUpdatingSuccess, isError: isUpdatingError, error: updateError}] = useUpdateUserPreferencesMutation();
- const notificationService = useNotificationService(dispatch);
+
+ const { updatingItemId: updatingServerId, isUpdating, updateMcpPreferences } = useMcpPreferencesUpdate({
+ successMessage: 'Successfully updated server preferences',
+ errorMessage: 'Error updating server preferences'
+ });
useEffect(() => {
if (userPreferences) {
@@ -62,55 +66,47 @@ export function McpServerManagementComponent () {
}
}, [userPreferences]);
- // create success notification
- useEffect(() => {
- if (isUpdatingSuccess) {
- notificationService.generateNotification('Successfully updated server preferences', 'success');
- }
- }, [isUpdatingSuccess, notificationService]);
+ const toggleServer = (serverId: string, serverName: string, enabled: boolean) => {
+ updateMcpPreferences(
+ serverId,
+ preferences,
+ (existingMcpPrefs) => {
+ const enabledServers = [...existingMcpPrefs.enabledServers];
- // create failure notification
- useEffect(() => {
- if (isUpdatingError) {
- const errorMessage = 'data' in updateError ? (updateError.data?.message ?? updateError.data) : updateError.message;
- notificationService.generateNotification(`Error updating server preferences: ${errorMessage}`, 'error');
- }
- }, [isUpdatingError, updateError, notificationService]);
+ if (enabled) {
+ enabledServers.push({
+ id: serverId,
+ name: serverName,
+ enabled: true,
+ disabledTools: [],
+ autoApprovedTools: []
+ });
+ } else {
+ return {
+ ...existingMcpPrefs,
+ enabledServers: enabledServers.filter((server) => server.id !== serverId)
+ };
+ }
- const toggleServer = (serverId: string, serverName: string, enabled: boolean) => {
- const existingMcpPrefs = preferences.preferences.mcp ?? {enabledServers: [], overrideAllApprovals: false};
- const mcpPrefs: McpPreferences = {
- ...existingMcpPrefs,
- enabledServers: [...existingMcpPrefs.enabledServers]
- };
- if (enabled){
- mcpPrefs.enabledServers.push({id: serverId, name: serverName, enabled: true, disabledTools: [], autoApprovedTools: []});
- } else {
- mcpPrefs.enabledServers = mcpPrefs.enabledServers.filter((server) => server.id !== serverId);
- }
- updatePrefs(mcpPrefs);
+ return {
+ ...existingMcpPrefs,
+ enabledServers
+ };
+ },
+ setPreferences
+ );
};
const toggleAutopilotMode = () => {
- const existingMcpPrefs = preferences.preferences.mcp ?? {enabledServers: [], overrideAllApprovals: false};
- const mcpPrefs: McpPreferences = {
- ...existingMcpPrefs,
- overrideAllApprovals: !existingMcpPrefs.overrideAllApprovals
- };
- updatePrefs(mcpPrefs);
- };
-
- const updatePrefs = (mcpPrefs: McpPreferences) => {
- const updated = {...preferences,
- preferences: {...preferences.preferences,
- mcp: {
- ...preferences.preferences.mcp,
- ...mcpPrefs
- }
- }
- };
- setPreferences(updated);
- updatePreferences(updated);
+ updateMcpPreferences(
+ 'autopilot',
+ preferences,
+ (existingMcpPrefs) => ({
+ ...existingMcpPrefs,
+ overrideAllApprovals: !existingMcpPrefs.overrideAllApprovals
+ }),
+ setPreferences
+ );
};
useEffect(() => {
@@ -173,7 +169,17 @@ export function McpServerManagementComponent () {
pagination={ }
items={items}
columnDefinitions={[
- { header: 'Use server', cell: (item) => item.canUse ? server.id === item.id)?.enabled ?? false} onChange={({detail}) => toggleServer(item.id, item.name, detail.checked)}/> : <>>},
+ { header: 'Use server', cell: (item) => item.canUse ? (
+ updatingServerId === item.id ? (
+
+ ) : (
+ server.id === item.id)?.enabled ?? false}
+ onChange={({detail}) => toggleServer(item.id, item.name, detail.checked)}
+ disabled={isUpdating}
+ />
+ )
+ ) : <>>},
{ header: 'Name', cell: (item) => navigate(`./${item.id}`)}>{item.name}},
{ header: 'Description', cell: (item) => item.description, id: 'description', sortingField: 'description'},
{ header: 'URL', cell: (item) => item.url, id: 'url', sortingField: 'url'},
diff --git a/lib/user-interface/react/src/components/mcp/hooks/useMcpPreferencesUpdate.ts b/lib/user-interface/react/src/components/mcp/hooks/useMcpPreferencesUpdate.ts
new file mode 100644
index 000000000..32cf4def8
--- /dev/null
+++ b/lib/user-interface/react/src/components/mcp/hooks/useMcpPreferencesUpdate.ts
@@ -0,0 +1,116 @@
+/**
+ 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';
+import {
+ McpPreferences,
+ UserPreferences,
+ useUpdateUserPreferencesMutation
+} from '@/shared/reducers/user-preferences.reducer';
+import { useNotificationService } from '@/shared/util/hooks';
+import { useAppDispatch } from '@/config/store';
+
+type UseMcpPreferencesUpdateOptions = {
+ successMessage?: string;
+ errorMessage?: string;
+};
+
+/**
+ * Custom hook to handle MCP preferences updates with loading states and notifications.
+ * Provides optimistic UI updates and handles success/error notifications.
+ */
+export function useMcpPreferencesUpdate (options: UseMcpPreferencesUpdateOptions = {}) {
+ const {
+ successMessage = 'Successfully updated preferences',
+ errorMessage = 'Error updating preferences'
+ } = options;
+
+ const dispatch = useAppDispatch();
+ const notificationService = useNotificationService(dispatch);
+ const [updatingItemId, setUpdatingItemId] = useState(null);
+ const [updatePreferences, {
+ isSuccess: isUpdatingSuccess,
+ isError: isUpdatingError,
+ error: updateError,
+ isLoading: isUpdating
+ }] = useUpdateUserPreferencesMutation();
+
+ // Handle success notification
+ useEffect(() => {
+ if (isUpdatingSuccess) {
+ notificationService.generateNotification(successMessage, 'success');
+ queueMicrotask(() => setUpdatingItemId(null));
+ }
+ }, [isUpdatingSuccess, notificationService, successMessage]);
+
+ // Handle error notification
+ useEffect(() => {
+ if (isUpdatingError) {
+ const errorDetail = 'data' in updateError
+ ? (updateError.data?.message ?? updateError.data)
+ : updateError.message;
+ notificationService.generateNotification(
+ `${errorMessage}: ${errorDetail}`,
+ 'error'
+ );
+ queueMicrotask(() => setUpdatingItemId(null));
+ }
+ }, [isUpdatingError, updateError, notificationService, errorMessage]);
+
+ /**
+ * Updates MCP preferences with optimistic UI state management.
+ *
+ * @param itemId - The ID of the item being updated (for spinner tracking)
+ * @param preferences - Current user preferences
+ * @param mcpPrefsBuilder - Function that builds the new MCP preferences
+ * @param setPreferences - Function to update local preferences state
+ */
+ const updateMcpPreferences = (
+ itemId: string,
+ preferences: UserPreferences,
+ mcpPrefsBuilder: (currentMcpPrefs: McpPreferences) => McpPreferences,
+ setPreferences: (prefs: UserPreferences) => void
+ ) => {
+ setUpdatingItemId(itemId);
+
+ const existingMcpPrefs = preferences.preferences.mcp ?? {
+ enabledServers: [],
+ overrideAllApprovals: false
+ };
+
+ const newMcpPrefs = mcpPrefsBuilder(existingMcpPrefs);
+
+ const updatedPreferences = {
+ ...preferences,
+ preferences: {
+ ...preferences.preferences,
+ mcp: {
+ ...preferences.preferences.mcp,
+ ...newMcpPrefs
+ }
+ }
+ };
+
+ setPreferences(updatedPreferences);
+ updatePreferences(updatedPreferences);
+ };
+
+ return {
+ updatingItemId,
+ isUpdating,
+ updateMcpPreferences
+ };
+}
diff --git a/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx b/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx
index aa16ba933..1d3d194e1 100644
--- a/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx
+++ b/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx
@@ -15,22 +15,22 @@
*/
import React, { ReactElement } from 'react';
-import { Button, Icon } from '@cloudscape-design/components';
-import { useAppDispatch } from '../../config/store';
-import { modelManagementApi } from '../../shared/reducers/model-management.reducer';
+import { useAppDispatch } from '@/config/store';
+import { modelManagementApi, useGetAllModelsQuery } from '@/shared/reducers/model-management.reducer';
+import { RefreshButton } from '@/components/common/RefreshButton';
function ModelLibraryActions (): ReactElement {
const dispatch = useAppDispatch();
+ const { isFetching } = useGetAllModelsQuery();
return (
- {
dispatch(modelManagementApi.util.invalidateTags(['models']));
}}
- ariaLabel={'Refresh models cards'}
- >
-
-
+ ariaLabel='Refresh models cards'
+ />
);
}
diff --git a/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx b/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx
index 9a6fde52a..a9f25c6ef 100644
--- a/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx
+++ b/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx
@@ -46,27 +46,31 @@ export function ModelLibraryComponent () : ReactElement {
useEffect(() => {
const finalStatePredicate = (model) => [ModelStatus.InService, ModelStatus.Failed, ModelStatus.Stopped].includes(model.status);
if (allModels?.every(finalStatePredicate)) {
- setShouldPoll(false);
+ queueMicrotask(() => setShouldPoll(false));
}
- }, [allModels, setShouldPoll]);
+ }, [allModels]);
useEffect(() => {
let newPageCount = 0;
if (searchText){
const filteredModels = allModels.filter((model) => JSON.stringify(model).toLowerCase().includes(searchText.toLowerCase()));
- setMatchedModels(filteredModels.slice(preferences.pageSize * (currentPageIndex - 1), preferences.pageSize * currentPageIndex));
+ queueMicrotask(() => {
+ setMatchedModels(filteredModels.slice(preferences.pageSize * (currentPageIndex - 1), preferences.pageSize * currentPageIndex));
+ setCount(filteredModels.length.toString());
+ });
newPageCount = Math.ceil(filteredModels.length / preferences.pageSize);
- setCount(filteredModels.length.toString());
} else {
- setMatchedModels(allModels ? allModels.slice(preferences.pageSize * (currentPageIndex - 1), preferences.pageSize * currentPageIndex) : []);
+ queueMicrotask(() => {
+ setMatchedModels(allModels ? allModels.slice(preferences.pageSize * (currentPageIndex - 1), preferences.pageSize * currentPageIndex) : []);
+ setCount(allModels ? allModels.length.toString() : '0');
+ });
newPageCount = Math.ceil(allModels ? (allModels.length / preferences.pageSize) : 1);
- setCount(allModels ? allModels.length.toString() : '0');
}
if (newPageCount < numberOfPages){
- setCurrentPageIndex(1);
+ queueMicrotask(() => setCurrentPageIndex(1));
}
- setNumberOfPages(newPageCount);
+ queueMicrotask(() => setNumberOfPages(newPageCount));
}, [allModels, searchText, preferences, currentPageIndex, numberOfPages]);
return (
diff --git a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx
index 478b6c884..e391c1f3a 100644
--- a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx
+++ b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx
@@ -15,7 +15,8 @@
*/
import { ReactElement, useEffect } from 'react';
-import { Button, ButtonDropdown, Icon, SpaceBetween } from '@cloudscape-design/components';
+import { Button, ButtonDropdown, SpaceBetween } from '@cloudscape-design/components';
+import { RefreshButton } from '@/components/common/RefreshButton';
import { useAppDispatch, useAppSelector } from '@/config/store';
import { IModel, ModelStatus } from '@/shared/model/model-management.model';
import { useNotificationService } from '@/shared/util/hooks';
@@ -36,6 +37,7 @@ export type ModelActionProps = {
currentDefaultModel?: string;
currentConfig?: any;
refetch?: () => void;
+ isFetching?: boolean;
};
function ModelActions (props: ModelActionProps): ReactElement {
@@ -44,15 +46,14 @@ function ModelActions (props: ModelActionProps): ReactElement {
return (
- {
props.setSelectedItems([]);
props.refetch?.();
}}
- ariaLabel={'Refresh models cards'}
- >
-
-
+ ariaLabel='Refresh models cards'
+ />
{ModelActionButton(dispatch, notificationService, props)}
([]);
const [searchText, setSearchText] = useState('');
- const [numberOfPages, setNumberOfPages] = useState(1);
const [currentPageIndex, setCurrentPageIndex] = useState(1);
const [selectedItems, setSelectedItems] = useState([]);
const [preferences, setPreferences] = useLocalStorage('ModelManagerPreferences', DEFAULT_PREFERENCES);
const [newModelModalVisible, setNewModelModelVisible] = useState(false);
const [isEdit, setEdit] = useState(false);
- const [count, setCount] = useState('');
const {
data: config,
@@ -65,28 +62,34 @@ export function ModelManagementComponent (): ReactElement {
useEffect(() => {
const finalStatePredicate = (model: IModel) => [ModelStatus.InService, ModelStatus.Failed, ModelStatus.Stopped].includes(model.status);
if (allModels?.every(finalStatePredicate)) {
- setShouldPoll(false);
+ queueMicrotask(() => setShouldPoll(false));
}
- }, [allModels, setShouldPoll]);
+ }, [allModels]);
- useEffect(() => {
- let newPageCount = 0;
- if (searchText) {
- const filteredModels = allModels.filter((model) => JSON.stringify(model).toLowerCase().includes(searchText.toLowerCase()));
- setMatchedModels(filteredModels.slice(preferences.pageSize * (currentPageIndex - 1), preferences.pageSize * currentPageIndex));
- newPageCount = Math.ceil(filteredModels.length / preferences.pageSize);
- setCount(filteredModels.length.toString());
- } else {
- setMatchedModels(allModels ? allModels.slice(preferences.pageSize * (currentPageIndex - 1), preferences.pageSize * currentPageIndex) : []);
- newPageCount = Math.ceil(allModels ? (allModels.length / preferences.pageSize) : 1);
- setCount(allModels ? allModels.length.toString() : '0');
- }
+ // Compute filtered models based on search text
+ const filteredModels = useMemo(() => {
+ if (!allModels) return [];
+ if (!searchText) return allModels;
+ return allModels.filter((model) => JSON.stringify(model).toLowerCase().includes(searchText.toLowerCase()));
+ }, [allModels, searchText]);
+
+ // Compute total count and number of pages
+ const count = useMemo(() => filteredModels.length.toString(), [filteredModels]);
+ const numberOfPages = useMemo(() => Math.ceil(filteredModels.length / preferences.pageSize) || 1, [filteredModels, preferences.pageSize]);
- if (newPageCount < numberOfPages) {
- setCurrentPageIndex(1);
+ // Compute current page models
+ const matchedModels = useMemo(() => {
+ const startIndex = preferences.pageSize * (currentPageIndex - 1);
+ const endIndex = preferences.pageSize * currentPageIndex;
+ return filteredModels.slice(startIndex, endIndex);
+ }, [filteredModels, preferences.pageSize, currentPageIndex]);
+
+ // Reset to first page when filters change and current page becomes invalid
+ useEffect(() => {
+ if (currentPageIndex > numberOfPages && numberOfPages > 0) {
+ queueMicrotask(() => setCurrentPageIndex(1));
}
- setNumberOfPages(newPageCount);
- }, [allModels, searchText, preferences, currentPageIndex, numberOfPages]);
+ }, [currentPageIndex, numberOfPages]);
return (
<>
@@ -120,6 +123,7 @@ export function ModelManagementComponent (): ReactElement {
currentDefaultModel={config?.[0]?.configuration?.global?.defaultModel}
currentConfig={config}
refetch={refetch}
+ isFetching={fetchingModels}
/>
}
>
diff --git a/lib/user-interface/react/src/components/model-management/ModelManagementUtils.tsx b/lib/user-interface/react/src/components/model-management/ModelManagementUtils.tsx
index b6b07a26a..c4d194af6 100644
--- a/lib/user-interface/react/src/components/model-management/ModelManagementUtils.tsx
+++ b/lib/user-interface/react/src/components/model-management/ModelManagementUtils.tsx
@@ -18,6 +18,8 @@ import { StatusIndicatorProps } from '@cloudscape-design/components/status-indic
import { CollectionPreferencesProps, StatusIndicator, Box } from '@cloudscape-design/components';
import { DEFAULT_PAGE_SIZE_OPTIONS } from '../../shared/preferences/common-preferences';
import Badge from '@cloudscape-design/components/badge';
+import { getDisplayName } from '@/shared/util/branding';
+import _ from 'lodash';
type EnumDictionary = {
[K in T]: U;
@@ -138,8 +140,8 @@ export const createCardDefinitions = (defaultModelId?: string) => ({
},
{
id: 'hosting',
- header: 'Hosted in LISA',
- content: (model: IModel) => String(model.containerConfig !== null && model.autoScalingConfig !== null && model.loadBalancerConfig !== null),
+ header: 'Model Provider',
+ content: (model: IModel) => model.containerConfig !== null && model.autoScalingConfig !== null && model.loadBalancerConfig !== null ? `${getDisplayName()} hosted` : _.startCase(model.modelName.split('/')[0]),
},
{
id: 'instanceType',
diff --git a/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx b/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx
index f4158045b..b16546155 100644
--- a/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx
+++ b/lib/user-interface/react/src/components/model-management/create-model/BaseModelConfig.tsx
@@ -14,7 +14,7 @@
limitations under the License.
*/
-import { ReactElement } from 'react';
+import { ReactElement, useEffect } from 'react';
import { FormProps} from '../../../shared/form/form-props';
import FormField from '@cloudscape-design/components/form-field';
import Input from '@cloudscape-design/components/input';
@@ -25,6 +25,7 @@ import { Grid, SpaceBetween } from '@cloudscape-design/components';
import { useGetInstancesQuery } from '../../../shared/reducers/model-management.reducer';
import { ModelFeatures } from '@/components/types';
import { UserGroupsInput } from '@/shared/form/UserGroupsInput';
+import { getDisplayName } from '../../../shared/util/branding';
export type BaseModelConfigCustomProps = {
isEdit: boolean
@@ -34,12 +35,21 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
const {data: instances, isLoading: isLoadingInstances} = useGetInstancesQuery();
const isEmbeddingModel = props.item.modelType === ModelType.embedding;
const isImageModel = props.item.modelType === ModelType.imagegen;
+ const isVideoModel = props.item.modelType === ModelType.videogen;
+
+ // Enable streaming by default for textgen models when creating a new model
+ useEffect(() => {
+ if (!props.isEdit && props.item.modelType === ModelType.textgen && props.item.streaming === false) {
+ props.setFields({ 'streaming': true });
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.isEdit, props.item.modelType]);
return (
& BaseModelConf
onBlur={() => props.touchFields(['lisaHostedModel'])}
options={[
{ label: 'Third party', value: 'false' },
- { label: 'LISA hosted', value: 'true' }
+ { label: `${getDisplayName()} hosted`, value: 'true' }
]}
disabled={props.isEdit}
/>
@@ -106,7 +116,7 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
& BaseModelConf
'modelType': detail.selectedOption.value,
};
- // turn off streaming for embedded models
- if (fields.modelType === ModelType.embedding || fields.modelType === ModelType.imagegen) {
+ // enable streaming by default for textgen models
+ if (fields.modelType === ModelType.textgen) {
+ fields['streaming'] = true;
+ } else if (fields.modelType === ModelType.embedding || fields.modelType === ModelType.imagegen || fields.modelType === ModelType.videogen) {
fields['streaming'] = false;
}
// turn off summarization and image input for embedded and imagegen models
- if ((fields.modelType === ModelType.embedding || fields.modelType === ModelType.imagegen)) {
+ if ((fields.modelType === ModelType.embedding || fields.modelType === ModelType.imagegen || fields.modelType === ModelType.videogen)) {
fields['features'] = props.item.features.filter((feature) => feature.name !== ModelFeatures.SUMMARIZATION && feature.name !== ModelFeatures.IMAGE_INPUT && feature.name !== ModelFeatures.TOOL_CALLS);
}
@@ -132,6 +144,7 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
options={[
{ label: 'TEXTGEN', value: ModelType.textgen },
{ label: 'IMAGEGEN', value: ModelType.imagegen },
+ { label: 'VIDEOGEN', value: ModelType.videogen },
{ label: 'EMBEDDING', value: ModelType.embedding },
]}
disabled={props.isEdit}
@@ -193,7 +206,7 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
props.setFields({'streaming': detail.checked})
}
onBlur={() => props.touchFields(['streaming'])}
- disabled={isEmbeddingModel || isImageModel}
+ disabled={isEmbeddingModel || isImageModel || isVideoModel}
checked={props.item.streaming}
/>
@@ -210,11 +223,29 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
props.setFields({'features': props.item.features.filter((feature) => feature.name !== ModelFeatures.TOOL_CALLS)});
}
}}
- disabled={isEmbeddingModel || isImageModel}
+ disabled={isEmbeddingModel || isImageModel || isVideoModel}
onBlur={() => props.touchFields(['features'])}
checked={props.item.features.find((feature) => feature.name === ModelFeatures.TOOL_CALLS) !== undefined}
/>
+
+ {
+ if (detail.checked && props.item.features.find((feature) => feature.name === ModelFeatures.REASONING) === undefined) {
+ props.setFields({'features': props.item.features.concat({name: ModelFeatures.REASONING, overview: ''})});
+ } else if (!detail.checked && props.item.features.find((feature) => feature.name === ModelFeatures.REASONING) !== undefined) {
+ props.setFields({'features': props.item.features.filter((feature) => feature.name !== ModelFeatures.REASONING)});
+ }
+ }}
+ disabled={isEmbeddingModel || isImageModel || isVideoModel}
+ onBlur={() => props.touchFields(['features'])}
+ checked={props.item.features.find((feature) => feature.name === ModelFeatures.REASONING) !== undefined}
+ />
+
& BaseModelConf
props.setFields({'features': props.item.features.filter((feature) => feature.name !== ModelFeatures.IMAGE_INPUT)});
}
}}
- disabled={isEmbeddingModel || isImageModel}
+ disabled={isEmbeddingModel || isImageModel || isVideoModel}
onBlur={() => props.touchFields(['features'])}
checked={props.item.features.find((feature) => feature.name === ModelFeatures.IMAGE_INPUT) !== undefined}
/>
@@ -247,7 +278,7 @@ export function BaseModelConfig (props: FormProps & BaseModelConf
props.setFields({'features': props.item.features.filter((feature) => feature.name !== ModelFeatures.SUMMARIZATION)});
}
}}
- disabled={isEmbeddingModel || isImageModel}
+ disabled={isEmbeddingModel || isImageModel || isVideoModel}
onBlur={() => props.touchFields(['features'])}
checked={props.item.features.find((feature) => feature.name === ModelFeatures.SUMMARIZATION) !== undefined}
/>
diff --git a/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx b/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx
index 80395f1f4..b6b3aad9a 100644
--- a/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx
+++ b/lib/user-interface/react/src/components/model-management/create-model/CreateModelModal.tsx
@@ -16,7 +16,7 @@
import _ from 'lodash';
import { Modal, Wizard } from '@cloudscape-design/components';
-import { IModel, IModelRequest, ModelRequestSchema } from '../../../shared/model/model-management.model';
+import { IModel, IModelRequest, ModelRequestSchema, ModelRequestBaseSchema } from '../../../shared/model/model-management.model';
import { ReactElement, useEffect, useMemo, useState } from 'react';
import { scrollToInvalid, useValidationReducer } from '../../../shared/validation';
import { BaseModelConfig } from './BaseModelConfig';
@@ -32,6 +32,7 @@ import { getJsonDifference, normalizeError } from '../../../shared/util/validati
import { setConfirmationModal } from '../../../shared/reducers/modal.reducer';
import { ReviewChanges } from '../../../shared/modal/ReviewChanges';
import { EcsRestartWarning } from '../EcsRestartWarning';
+import { getDisplayName } from '../../../shared/util/branding';
export type CreateModelModalProps = {
visible: boolean;
@@ -67,7 +68,7 @@ export function CreateModelModal (props: CreateModelModalProps) : ReactElement {
deleteScheduleMutation,
{ isSuccess: isScheduleDeleteSuccess, isError: isScheduleDeleteError, error: scheduleDeleteError, isLoading: isScheduleDeleting, reset: resetScheduleDelete },
] = useDeleteScheduleMutation();
- const initialForm = ModelRequestSchema.partial().parse({});
+ const initialForm = ModelRequestBaseSchema.partial().parse({});
const dispatch = useAppDispatch();
const notificationService = useNotificationService(dispatch);
@@ -506,7 +507,7 @@ export function CreateModelModal (props: CreateModelModalProps) : ReactElement {
cancelButton: 'Cancel',
previousButton: 'Previous',
nextButton: 'Next',
- optional: 'LISA hosted models only'
+ optional: `${getDisplayName()} hosted models only`
}}
onNavigate={(event) => {
switch (event.detail.reason) {
diff --git a/lib/user-interface/react/src/components/model-management/create-model/ScheduleConfig.tsx b/lib/user-interface/react/src/components/model-management/create-model/ScheduleConfig.tsx
index 8b32af04e..296f71978 100644
--- a/lib/user-interface/react/src/components/model-management/create-model/ScheduleConfig.tsx
+++ b/lib/user-interface/react/src/components/model-management/create-model/ScheduleConfig.tsx
@@ -206,7 +206,7 @@ export function ScheduleConfig (props: ScheduleConfigProps): ReactElement {
}
}
- setValidationErrors(errors);
+ queueMicrotask(() => setValidationErrors(errors));
}, [isScheduleEnabled, scheduleType, props.item.dailySchedule, props.item.recurringSchedule, props.item.timezone]);
// Helper functions for weekly schedule management
diff --git a/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx b/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx
index d72930491..cf5b1a134 100644
--- a/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx
+++ b/lib/user-interface/react/src/components/model-management/hooks/useModelComparison.hook.tsx
@@ -15,7 +15,7 @@
*/
import { useState, useCallback, useMemo, useRef } from 'react';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../../../auth/useAuth';
import { ChatOpenAI } from '@langchain/openai';
import { SelectProps } from '@cloudscape-design/components';
import { IModel, ModelStatus } from '../../../shared/model/model-management.model';
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx
index c1f07a656..e21f7fe8b 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateForm.tsx
@@ -131,8 +131,7 @@ export function PromptTemplateForm (props: PromptTemplateFormProps) {
// Check if template is public and update state accordingly
useEffect(() => {
if (data?.groups?.findIndex((group) => group === 'lisa:public') > -1) {
-
- setSharePublic(true);
+ queueMicrotask(() => setSharePublic(true));
}
}, [data?.groups]);
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx
index 27c3bb870..4c90bb99f 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplateModal.tsx
@@ -82,8 +82,10 @@ export const PromptTemplateModal = ({
}
}, [showModal, dispatch]);
+ const modalTestId = 'prompt-template-modal';
return (
{
setShowModal(false);
setUserPrompt('');
@@ -104,6 +106,7 @@ export const PromptTemplateModal = ({
Cancel
{
if (isPersona) {
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx
index 4aa190052..7546d8ded 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesActions.tsx
@@ -15,7 +15,8 @@
*/
import React, { ReactElement, useEffect } from 'react';
-import { Button, ButtonDropdown, Icon, SpaceBetween } from '@cloudscape-design/components';
+import { Button, ButtonDropdown, SpaceBetween } from '@cloudscape-design/components';
+import { RefreshButton } from '@/components/common/RefreshButton';
import { useAppDispatch, useAppSelector } from '../../config/store';
import { useNotificationService } from '../../shared/util/hooks';
import { INotificationService } from '../../shared/notification/notification.service';
@@ -30,6 +31,7 @@ export type PromptTemplatesActionsProps = {
selectedItems: readonly PromptTemplate[];
setSelectedItems: (items: PromptTemplate[]) => void;
showPublic: boolean;
+ isFetching: boolean;
};
export function PromptTemplatesActions (props: PromptTemplatesActionsProps): ReactElement {
@@ -41,15 +43,14 @@ export function PromptTemplatesActions (props: PromptTemplatesActionsProps): Rea
return (
- {
props.setSelectedItems([]);
dispatch(promptTemplateApi.util.invalidateTags(['promptTemplates']));
}}
- ariaLabel={'Refresh models cards'}
- >
-
-
+ ariaLabel='Refresh prompt templates'
+ />
{PromptTemplatesActionButton(dispatch, notificationService, props, {isUserAdmin, username})}
{
navigate('./new');
diff --git a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesLibraryComponent.tsx b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesLibraryComponent.tsx
index 2b4c441e2..c5363c247 100644
--- a/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesLibraryComponent.tsx
+++ b/lib/user-interface/react/src/components/prompt-templates-library/PromptTemplatesLibraryComponent.tsx
@@ -61,6 +61,7 @@ export function PromptTemplatesLibraryComponent () {
selectedItems={collectionProps.selectedItems || []}
setSelectedItems={actions.setSelectedItems}
showPublic={args.showPublic}
+ isFetching={isFetching}
/>}>
Prompt Templates
diff --git a/lib/user-interface/react/src/components/repository-management/RepositoryActions.tsx b/lib/user-interface/react/src/components/repository-management/RepositoryActions.tsx
index 5fe2a7e47..e714dcc9e 100644
--- a/lib/user-interface/react/src/components/repository-management/RepositoryActions.tsx
+++ b/lib/user-interface/react/src/components/repository-management/RepositoryActions.tsx
@@ -21,7 +21,6 @@ import {
ButtonDropdown,
ButtonDropdownProps,
Checkbox,
- Icon,
SpaceBetween,
} from '@cloudscape-design/components';
import { useAppDispatch } from '@/config/store';
@@ -33,8 +32,10 @@ import {
ragApi,
useUpdateRagRepositoryMutation,
useDeleteRagRepositoryMutation,
+ useListRagRepositoriesQuery,
} from '@/shared/reducers/rag.reducer';
import { RagRepositoryConfig } from '#root/lib/schema';
+import { RefreshButton } from '@/components/common/RefreshButton';
export type RepositoryActionProps = {
selectedItems: ReadonlyArray;
@@ -47,18 +48,22 @@ function RepositoryActions (props: RepositoryActionProps): ReactElement {
const dispatch = useAppDispatch();
const notificationService = useNotificationService(dispatch);
const { setEdit, setNewRepositoryModalVisible, setSelectedItems } = props;
+ const { isFetching } = useListRagRepositoriesQuery(undefined, {
+ refetchOnMountOrArgChange: 30
+ });
+
+ const handleRefresh = () => {
+ setSelectedItems([]);
+ dispatch(ragApi.util.invalidateTags(['repositories']));
+ };
+
return (
- {
- setSelectedItems([]);
- // Invalidate cache - this will automatically trigger refetch of active queries
- dispatch(ragApi.util.invalidateTags(['repositories']));
- }}
- ariaLabel={'Refresh repository table'}
- >
-
-
+
{RepositoryActionButton(dispatch, notificationService, props)}
{
setEdit(false);
diff --git a/lib/user-interface/react/src/components/repository-management/createRepository/CreateRepositoryModal.tsx b/lib/user-interface/react/src/components/repository-management/createRepository/CreateRepositoryModal.tsx
index 26e996448..c1c5956e4 100644
--- a/lib/user-interface/react/src/components/repository-management/createRepository/CreateRepositoryModal.tsx
+++ b/lib/user-interface/react/src/components/repository-management/createRepository/CreateRepositoryModal.tsx
@@ -28,7 +28,7 @@ import { getJsonDifference, normalizeError } from '../../../shared/util/validati
import { ModifyMethod } from '../../../shared/validation/modify-method';
import { PipelineConfigForm } from './PipelineConfigForm';
import _ from 'lodash';
-import { RagRepositoryConfig, RagRepositoryConfigSchema, RagRepositoryType, ChunkingStrategyType } from '#root/lib/schema';
+import { RagRepositoryConfig, RagRepositoryConfigSchema, RagRepositoryType, ChunkingStrategyType, BaseRagRepositoryConfigSchema } from '#root/lib/schema';
export type CreateRepositoryModalProps = {
visible: boolean;
@@ -69,7 +69,7 @@ export function CreateRepositoryModal (props: CreateRepositoryModalProps): React
},
] = useUpdateRagRepositoryMutation();
- const initialForm: RagRepositoryConfig = RagRepositoryConfigSchema.partial().parse({
+ const initialForm: RagRepositoryConfig = BaseRagRepositoryConfigSchema.partial().parse({
metadata: { tags: [] }
}) as RagRepositoryConfig;
const dispatch = useAppDispatch();
diff --git a/lib/user-interface/react/src/components/repository-management/createRepository/RdsConfigForm.tsx b/lib/user-interface/react/src/components/repository-management/createRepository/RdsConfigForm.tsx
index 6116c1eae..81db62ad7 100644
--- a/lib/user-interface/react/src/components/repository-management/createRepository/RdsConfigForm.tsx
+++ b/lib/user-interface/react/src/components/repository-management/createRepository/RdsConfigForm.tsx
@@ -19,7 +19,7 @@ import { Header, SpaceBetween } from '@cloudscape-design/components';
import FormField from '@cloudscape-design/components/form-field';
import Input from '@cloudscape-design/components/input';
import React, { ReactElement } from 'react';
-import { FormProps } from '../../../shared/form/form-props';
+import { FormProps } from '@/shared/form/form-props';
import { RdsConfig as RdsConfigSchema, RdsInstanceConfig } from '#root/lib/schema';
type RdsConfigProps = {
diff --git a/lib/user-interface/react/src/components/types.tsx b/lib/user-interface/react/src/components/types.tsx
index 1b4e089ef..87a7f49d4 100644
--- a/lib/user-interface/react/src/components/types.tsx
+++ b/lib/user-interface/react/src/components/types.tsx
@@ -47,6 +47,11 @@ export type ImageGenerationParams = {
prompt: string;
};
+export type VideoGenerationParams = {
+ prompt: string;
+ model?: string;
+};
+
/**
* Stores metadata for messages returned from LISA
*/
@@ -59,6 +64,13 @@ export type LisaChatMessageMetadata = {
ragDocuments?: string;
imageGeneration?: boolean;
imageGenerationParams?: ImageGenerationParams;
+ imageGenerationStatus?: string;
+ videoGeneration?: boolean;
+ videoGenerationParams?: VideoGenerationParams;
+ videoId?: string;
+ videoStatus?: string;
+ hasFileContext?: boolean;
+ isImageEdit?: boolean;
};
/**
* Usage information from OpenAI API responses
@@ -81,6 +93,8 @@ export type LisaChatMessageFields = {
toolCalls?: any[];
usage?: UsageInfo;
guardrailTriggered?: boolean;
+ reasoningContent?: string;
+ reasoningSignature?: string;
} & BaseMessageFields;
/**
@@ -92,6 +106,8 @@ export class LisaChatMessage extends BaseMessage implements LisaChatMessageField
toolCalls?: any[];
usage?: UsageInfo;
guardrailTriggered?: boolean;
+ reasoningContent?: string;
+ reasoningSignature?: string;
constructor (fields: LisaChatMessageFields) {
super(fields);
@@ -100,6 +116,8 @@ export class LisaChatMessage extends BaseMessage implements LisaChatMessageField
this.toolCalls = fields.toolCalls ?? [];
this.usage = fields.usage;
this.guardrailTriggered = fields.guardrailTriggered ?? false;
+ this.reasoningContent = fields.reasoningContent;
+ this.reasoningSignature = fields.reasoningSignature;
}
static lc_name () {
@@ -248,6 +266,7 @@ export enum ModelFeatures {
SUMMARIZATION = 'summarization',
IMAGE_INPUT = 'imageInput',
TOOL_CALLS = 'toolCalls',
+ REASONING = 'reasoning',
}
/**
diff --git a/lib/user-interface/react/src/components/utils.ts b/lib/user-interface/react/src/components/utils.ts
index 28bafc9ca..8bd378c46 100644
--- a/lib/user-interface/react/src/components/utils.ts
+++ b/lib/user-interface/react/src/components/utils.ts
@@ -108,6 +108,13 @@ export function messageContainsImage (content: MessageContent): boolean {
return false;
}
+export function messageContainsVideo (content: MessageContent): boolean {
+ if (Array.isArray(content)) {
+ return !!content.find((item) => item.type === 'video_url');
+ }
+ return false;
+}
+
export function base64ToBlob (base64: string, mimeType: string): Blob {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
diff --git a/lib/user-interface/react/src/config/store.ts b/lib/user-interface/react/src/config/store.ts
index 98080d149..adfa90720 100644
--- a/lib/user-interface/react/src/config/store.ts
+++ b/lib/user-interface/react/src/config/store.ts
@@ -20,7 +20,6 @@ import storage from 'redux-persist/lib/storage';
import { persistReducer, persistStore } from 'redux-persist';
import sharedReducers, { rootMiddleware } from '../shared/reducers';
-import { rtkQueryErrorMiddleware } from '../shared/reducers/rtkQueryErrorMiddleware';
const persistConfig = {
key: 'lisa',
@@ -35,7 +34,6 @@ const store = configureStore({
getDefaultMiddleware({
serializableCheck: false,
})
- .concat(rtkQueryErrorMiddleware)
.concat(...rootMiddleware),
});
diff --git a/lib/user-interface/react/src/index.css b/lib/user-interface/react/src/index.css
index 83e6cd73b..850e7e74e 100644
--- a/lib/user-interface/react/src/index.css
+++ b/lib/user-interface/react/src/index.css
@@ -1,2 +1,3 @@
-@import "tailwindcss";
+@import "tailwindcss/theme" layer(theme);
+@import "tailwindcss/utilities" layer(utilities);
@import "katex/dist/katex.min.css";
diff --git a/lib/user-interface/react/src/main.tsx b/lib/user-interface/react/src/main.tsx
index b72629dde..dfc33d499 100644
--- a/lib/user-interface/react/src/main.tsx
+++ b/lib/user-interface/react/src/main.tsx
@@ -22,6 +22,27 @@ import AppConfigured from './components/app-configured';
import '@cloudscape-design/global-styles/index.css';
import getStore from './config/store';
+import { applyTheme } from '@cloudscape-design/components/theming';
+import { Theme } from '@cloudscape-design/components/theming';
+
+// Conditionally apply custom theme if branding is enabled
+if (window.env?.USE_CUSTOM_BRANDING) {
+ try {
+ // Vite will only include files that actually exist
+ const themeModules = import.meta.glob('./theme*.ts');
+
+ // Try custom first, fall back to base
+ const themeModule = themeModules['./theme-custom.ts']
+ ? await themeModules['./theme-custom.ts']()
+ : await themeModules['./theme.ts']();
+
+ const { brandTheme } = themeModule as { brandTheme: Theme };
+ applyTheme({ theme: brandTheme });
+ console.log('Theme loaded:', themeModules['./theme-custom.ts'] ? 'custom' : 'base');
+ } catch {
+ console.warn('No theme file found, using Cloudscape default theme');
+ }
+}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
@@ -39,6 +60,8 @@ declare global {
RAG_ENABLED: boolean;
HOSTED_MCP_ENABLED: boolean;
API_BASE_URL: string;
+ USE_CUSTOM_BRANDING: boolean;
+ CUSTOM_DISPLAY_NAME: string;
};
gitInfo?: {
revisionTag?: string;
diff --git a/lib/user-interface/react/src/pages/Chatbot.test.tsx b/lib/user-interface/react/src/pages/Chatbot.test.tsx
index 3a955c4f8..d06814642 100644
--- a/lib/user-interface/react/src/pages/Chatbot.test.tsx
+++ b/lib/user-interface/react/src/pages/Chatbot.test.tsx
@@ -19,12 +19,12 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../auth/useAuth';
import { Chatbot } from './Chatbot';
// Mock all the dependencies
-vi.mock('react-oidc-context');
+vi.mock('../auth/useAuth');
vi.mock('@/config/store', () => ({
useAppDispatch: () => vi.fn(),
}));
diff --git a/lib/user-interface/react/src/pages/Chatbot.tsx b/lib/user-interface/react/src/pages/Chatbot.tsx
index a2e44159d..d150c67fb 100644
--- a/lib/user-interface/react/src/pages/Chatbot.tsx
+++ b/lib/user-interface/react/src/pages/Chatbot.tsx
@@ -36,7 +36,11 @@ export function Chatbot ({ setNav }) {
dispatch(sessionApi.util.invalidateTags([{ type: 'session', id: sessionId }]));
}
- // Navigate to clear the sessionId from URL
+ // Always update the key to force Chat component remount and clear state
+ // This ensures state is cleared even when already on /ai-assistant (no UUID in URL)
+ setKey(new Date().toISOString());
+
+ // Navigate to clear the sessionId from URL (if not already there)
navigate('/ai-assistant', { replace: true });
}, [navigate, dispatch, sessionId]);
@@ -44,7 +48,7 @@ export function Chatbot ({ setNav }) {
useEffect(() => {
if (prevSessionIdRef.current && !sessionId) {
// We transitioned from having a sessionId to not having one (new session)
- setKey(new Date().toISOString());
+ queueMicrotask(() => setKey(new Date().toISOString()));
}
prevSessionIdRef.current = sessionId;
}, [sessionId]);
diff --git a/lib/user-interface/react/src/pages/Home.tsx b/lib/user-interface/react/src/pages/Home.tsx
index cd4e6e1f2..4c922df08 100644
--- a/lib/user-interface/react/src/pages/Home.tsx
+++ b/lib/user-interface/react/src/pages/Home.tsx
@@ -16,11 +16,11 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useAuth } from 'react-oidc-context';
+import { useAuth } from '../auth/useAuth';
-import chatImg from '../assets/chat.png';
import { Alert, Box, Button, Modal } from '@cloudscape-design/components';
import { purgeStore } from '../config/store';
+import { getBrandingAssetPath } from '../shared/util/branding';
export function Home ({ setNav }) {
const navigate = useNavigate();
@@ -70,16 +70,13 @@ export function Home ({ setNav }) {
-
- Image generated via StableDiffusion-XL on Amazon Bedrock
-
diff --git a/lib/user-interface/react/src/pages/ModelComparison.tsx b/lib/user-interface/react/src/pages/ModelComparison.tsx
index 590c50666..962cdabab 100644
--- a/lib/user-interface/react/src/pages/ModelComparison.tsx
+++ b/lib/user-interface/react/src/pages/ModelComparison.tsx
@@ -54,7 +54,7 @@ export default function ModelComparisonPage (): ReactElement {
refetchOnMountOrArgChange: 5,
selectFromResult: (state) => ({
data: (state.data || []).filter((model) =>
- (model.modelType === ModelType.textgen || model.modelType === ModelType.imagegen) &&
+ (model.modelType === ModelType.textgen || model.modelType === ModelType.imagegen || model.modelType === ModelType.videogen) &&
model.status === ModelStatus.InService
),
})
diff --git a/lib/user-interface/react/src/shared/modal/confirmation-modal.tsx b/lib/user-interface/react/src/shared/modal/confirmation-modal.tsx
index ad986e029..6b22a5c31 100644
--- a/lib/user-interface/react/src/shared/modal/confirmation-modal.tsx
+++ b/lib/user-interface/react/src/shared/modal/confirmation-modal.tsx
@@ -49,7 +49,7 @@ function ConfirmationModal ({
const headerText = title || [
action,
- resourceName ? `"${resourceName}"` : ''
+ resourceName ? `${resourceName}` : ''
].join(' ');
return (
diff --git a/lib/user-interface/react/src/shared/model/chat.configurations.model.ts b/lib/user-interface/react/src/shared/model/chat.configurations.model.ts
index b8d7361eb..5cf70d589 100644
--- a/lib/user-interface/react/src/shared/model/chat.configurations.model.ts
+++ b/lib/user-interface/react/src/shared/model/chat.configurations.model.ts
@@ -36,6 +36,7 @@ export type ISessionConfiguration = {
markdownDisplay: boolean
streaming: boolean,
showMetadata: boolean,
+ showReasoningContent: boolean,
max_tokens: number,
chatHistoryBufferSize: number,
ragTopK: number,
@@ -47,12 +48,18 @@ export type ISessionConfiguration = {
temperature: number;
seed: number;
stop: string[];
+ reasoning_effort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | null;
},
imageGenerationArgs: {
size: string,
numberOfImages: number,
quality: string,
- }
+ },
+ videoGenerationArgs: {
+ seconds: string,
+ size: string,
+ },
+ remixVideoId?: string;
};
export type GenerateLLMRequestParams = {
@@ -69,6 +76,7 @@ export const baseConfig: IChatConfiguration = {
sessionConfiguration: {
streaming: false,
markdownDisplay: true,
+ showReasoningContent: true,
showMetadata: false,
max_tokens: null,
chatHistoryBufferSize: 7,
@@ -81,11 +89,16 @@ export const baseConfig: IChatConfiguration = {
temperature: null,
seed: null,
stop: [],
+ reasoning_effort: null,
},
imageGenerationArgs: {
size: '1024x1024',
numberOfImages: 1,
quality: 'standard',
+ },
+ videoGenerationArgs: {
+ seconds: '4',
+ size: '720x1280',
}
}
};
diff --git a/lib/user-interface/react/src/shared/model/model-management.model.ts b/lib/user-interface/react/src/shared/model/model-management.model.ts
index b65fa3f67..8250117bf 100644
--- a/lib/user-interface/react/src/shared/model/model-management.model.ts
+++ b/lib/user-interface/react/src/shared/model/model-management.model.ts
@@ -16,6 +16,12 @@
import { z } from 'zod';
import { AttributeEditorSchema } from '../form/environment-variables';
import { IChatConfiguration } from './chat.configurations.model';
+import {
+ ContainerHealthCheckConfigSchema,
+ LoadBalancerHealthCheckConfigSchema,
+ MetricConfigSchema,
+} from '#root/lib/schema';
+
export enum ModelStatus {
Creating = 'Creating',
@@ -31,7 +37,8 @@ export enum ModelStatus {
export enum ModelType {
textgen = 'textgen',
embedding = 'embedding',
- imagegen = 'imagegen'
+ imagegen = 'imagegen',
+ videogen = 'videogen'
}
export enum InferenceContainer {
@@ -240,32 +247,18 @@ export type IModelUpdateRequest = {
guardrailsConfig?: IGuardrailsConfig;
};
-const containerHealthCheckConfigSchema = z.object({
- command: z.array(z.string()).default(['CMD-SHELL', 'exit 0']),
- interval: z.number().default(10),
- startPeriod: z.number().default(30),
- timeout: z.number().default(5),
- retries: z.number().default(3),
-});
-
-
const containerConfigImageSchema = z.object({
baseImage: z.string().default(''),
type: z.string().default('asset'),
});
-export const metricConfigSchema = z.object({
- albMetricName: z.string().default('RequestCountPerTarget'),
- targetValue: z.number().default(30),
- duration: z.number().default(60),
- estimatedInstanceWarmup: z.number().default(330),
-});
+// Re-export for convenience
+export { ContainerHealthCheckConfigSchema as containerHealthCheckConfigSchema };
-export const loadBalancerHealthCheckConfigSchema = z.object({
+export const loadBalancerHealthCheckConfigSchema = LoadBalancerHealthCheckConfigSchema.extend({
path: z.string().default('/health'),
interval: z.number().default(60),
timeout: z.number().default(30),
- healthyThresholdCount: z.number().default(2),
unhealthyThresholdCount: z.number().default(10),
});
@@ -368,7 +361,7 @@ export const autoScalingConfigSchema = z.object({
desiredCapacity: z.number().optional(),
cooldown: z.number().min(1).default(420),
defaultInstanceWarmup: z.number().default(180),
- metricConfig: metricConfigSchema.default(metricConfigSchema.parse({})),
+ metricConfig: MetricConfigSchema.default(MetricConfigSchema.parse({})),
scheduling: scheduleConfigSchema.optional(),
}).superRefine((value, context) => {
// ensure the desired capacity stays between minCapacity/maxCapacity if not empty
@@ -400,11 +393,11 @@ export const guardrailsConfigSchema = z.record(z.string(), guardrailConfigSchema
export const containerConfigSchema = z.object({
image: containerConfigImageSchema.default(containerConfigImageSchema.parse({})),
sharedMemorySize: z.number().min(0).default(2048),
- healthCheckConfig: containerHealthCheckConfigSchema.default(containerHealthCheckConfigSchema.parse({})),
+ healthCheckConfig: ContainerHealthCheckConfigSchema.default(ContainerHealthCheckConfigSchema.parse({})),
environment: AttributeEditorSchema,
});
-export const ModelRequestSchema = z.object({
+export const ModelRequestBaseSchema = z.object({
modelId: z.string()
.regex(/^[a-z\d-]+$/, {message: 'Only lowercase alphanumeric characters and hyphens allowed'})
.regex(/^[a-z0-9].*[a-z0-9]$/, {message: 'Must start and end with a lowercase alphanumeric character.'})
@@ -427,7 +420,10 @@ export const ModelRequestSchema = z.object({
loadBalancerConfig: loadBalancerConfigSchema.default(loadBalancerConfigSchema.parse({})),
allowedGroups: z.array(z.string()).default([]),
guardrailsConfig: guardrailsConfigSchema.optional(),
-}).superRefine((value, context) => {
+});
+
+// Full schema with refinements - use this for validation
+export const ModelRequestSchema = ModelRequestBaseSchema.superRefine((value, context) => {
if (value.lisaHostedModel) {
const instanceTypeValidator = z.string().min(1, {message: 'Required for LISA hosted models.'});
const instanceTypeResult = instanceTypeValidator.safeParse(value.instanceType);
diff --git a/lib/user-interface/react/src/shared/reducers/index.ts b/lib/user-interface/react/src/shared/reducers/index.ts
index 38422328d..d09ce2fc7 100644
--- a/lib/user-interface/react/src/shared/reducers/index.ts
+++ b/lib/user-interface/react/src/shared/reducers/index.ts
@@ -38,7 +38,7 @@ const rootReducer: ReducersMapObject = {
[modelManagementApi.reducerPath]: modelManagementApi.reducer,
[configurationApi.reducerPath]: configurationApi.reducer,
[sessionApi.reducerPath]: sessionApi.reducer,
- [ragApi.reducerPath]: ragApi.reducer,
+ ...(window.env.RAG_ENABLED ? { [ragApi.reducerPath]: ragApi.reducer } : {}),
[promptTemplateApi.reducerPath]: promptTemplateApi.reducer,
[mcpServerApi.reducerPath]: mcpServerApi.reducer,
[mcpToolsApi.reducerPath]: mcpToolsApi.reducer,
@@ -46,6 +46,16 @@ const rootReducer: ReducersMapObject = {
[apiTokenApi.reducerPath]: apiTokenApi.reducer,
};
-export const rootMiddleware = [modelManagementApi.middleware, configurationApi.middleware, sessionApi.middleware, ragApi.middleware, promptTemplateApi.middleware, mcpServerApi.middleware, mcpToolsApi.middleware, userPreferencesApi.middleware, apiTokenApi.middleware];
+export const rootMiddleware = [
+ modelManagementApi.middleware,
+ configurationApi.middleware,
+ sessionApi.middleware,
+ ...(window.env.RAG_ENABLED ? [ragApi.middleware] : []),
+ promptTemplateApi.middleware,
+ mcpServerApi.middleware,
+ mcpToolsApi.middleware,
+ userPreferencesApi.middleware,
+ apiTokenApi.middleware
+];
export default rootReducer;
diff --git a/lib/user-interface/react/src/shared/reducers/rtkQueryErrorMiddleware.test.ts b/lib/user-interface/react/src/shared/reducers/rtkQueryErrorMiddleware.test.ts
deleted file mode 100644
index a80d880f3..000000000
--- a/lib/user-interface/react/src/shared/reducers/rtkQueryErrorMiddleware.test.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- 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 { describe, it, expect, vi, beforeEach } from 'vitest';
-import { configureStore } from '@reduxjs/toolkit';
-import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
-import { rtkQueryErrorMiddleware } from './rtkQueryErrorMiddleware';
-
-describe('rtkQueryErrorMiddleware', () => {
- let store: ReturnType;
- let testApi: ReturnType;
- let consoleDebugSpy: ReturnType;
-
- beforeEach(() => {
- // Clear all mocks before each test
- vi.clearAllMocks();
-
- // Spy on console.debug to verify logging
- consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
-
- // Create a test API
- testApi = createApi({
- reducerPath: 'testApi',
- baseQuery: fetchBaseQuery({ baseUrl: 'http://test.com' }),
- endpoints: (builder) => ({
- getTest: builder.query<{ data: string }, void>({
- query: () => '/test',
- }),
- }),
- });
-
- // Create store with our middleware
- store = configureStore({
- reducer: {
- [testApi.reducerPath]: testApi.reducer,
- },
- middleware: (getDefaultMiddleware) =>
- getDefaultMiddleware()
- .concat(rtkQueryErrorMiddleware)
- .concat(testApi.middleware),
- });
- });
-
- it('should pass through non-error actions', () => {
- const action = { type: 'SOME_ACTION', payload: 'test' };
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- expect(consoleDebugSpy).not.toHaveBeenCalled();
- });
-
- it('should detect and log AbortError from string error', () => {
- const action = {
- type: 'testApi/executeQuery/rejected',
- error: {
- name: 'AbortError',
- message: 'The user aborted a request',
- },
- meta: {
- arg: {
- endpointName: 'getTest',
- },
- },
- };
-
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- expect(consoleDebugSpy).toHaveBeenCalledWith(
- '[RTK Query] Detected cancelled request:',
- 'getTest'
- );
- });
-
- it('should detect AbortError from Error object', () => {
- const abortError = new Error('Request aborted');
- abortError.name = 'AbortError';
-
- const action = {
- type: 'testApi/executeQuery/rejected',
- error: abortError,
- meta: {
- arg: {
- endpointName: 'getTest',
- },
- },
- };
-
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- expect(consoleDebugSpy).toHaveBeenCalledWith(
- '[RTK Query] Detected cancelled request:',
- 'getTest'
- );
- });
-
- it('should detect abort from error message containing "abort"', () => {
- const action = {
- type: 'testApi/executeQuery/rejected',
- error: new Error('The operation was aborted'),
- meta: {
- arg: {
- endpointName: 'getTest',
- },
- },
- };
-
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- expect(consoleDebugSpy).toHaveBeenCalledWith(
- '[RTK Query] Detected cancelled request:',
- 'getTest'
- );
- });
-
- it('should not log for non-abort errors', () => {
- const action = {
- type: 'testApi/executeQuery/rejected',
- error: {
- name: 'NetworkError',
- message: 'Network error',
- },
- meta: {
- arg: {
- endpointName: 'getTest',
- },
- },
- };
-
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- expect(consoleDebugSpy).not.toHaveBeenCalled();
- });
-
- it('should handle actions without error payload', () => {
- const action = {
- type: 'testApi/executeQuery/rejected',
- payload: {},
- meta: {
- arg: {
- endpointName: 'getTest',
- },
- },
- error: { message: 'Rejected' },
- };
-
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- expect(consoleDebugSpy).not.toHaveBeenCalled();
- });
-
- it('should handle actions without meta', () => {
- const action = {
- type: 'testApi/executeQuery/rejected',
- error: {
- name: 'AbortError',
- message: 'Aborted',
- },
- };
-
- const next = vi.fn();
- const middleware = rtkQueryErrorMiddleware(store)(next);
-
- middleware(action);
-
- expect(next).toHaveBeenCalledWith(action);
- // Should still log but with undefined endpoint name
- expect(consoleDebugSpy).toHaveBeenCalledWith(
- '[RTK Query] Detected cancelled request:',
- undefined
- );
- });
-});
diff --git a/lib/user-interface/react/src/shared/reducers/rtkQueryErrorMiddleware.ts b/lib/user-interface/react/src/shared/reducers/rtkQueryErrorMiddleware.ts
deleted file mode 100644
index df9b2ce02..000000000
--- a/lib/user-interface/react/src/shared/reducers/rtkQueryErrorMiddleware.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- 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 { Middleware } from '@reduxjs/toolkit';
-
-/**
- * Middleware to handle RTK Query cancelled/aborted requests.
- *
- * When a component unmounts during navigation (common in Cypress tests),
- * RTK Query cancels in-flight requests. Without this middleware, those
- * queries can get stuck in a loading state when the component remounts.
- *
- * This middleware detects cancelled queries and logs them for debugging.
- * The combination of this middleware with keepUnusedDataFor and
- * refetchOnMountOrArgChange ensures queries retry properly on remount.
- */
-export const rtkQueryErrorMiddleware: Middleware = () => (next) => (action: any) => {
- // Check if this is a rejected action by looking at the type
- if (action.type && action.type.endsWith('/rejected')) {
- // Check various error formats that indicate cancellation
- const error = action.error;
- const payload = action.payload;
-
- let isAborted = false;
-
- // Check action.error first (standard rejected actions)
- if (error) {
- isAborted =
- error.name === 'AbortError' ||
- error.message?.includes('abort') ||
- error.message?.includes('cancel');
- }
-
- // Check action.payload.error (rejectedWithValue actions)
- if (!isAborted && payload && typeof payload === 'object' && 'error' in payload) {
- const errorToCheck = payload.error;
-
- if (typeof errorToCheck === 'string') {
- isAborted = errorToCheck.toLowerCase().includes('abort');
- } else if (errorToCheck instanceof Error) {
- isAborted =
- errorToCheck.name === 'AbortError' ||
- errorToCheck.message?.includes('abort') ||
- errorToCheck.message?.includes('cancel');
- }
- }
-
- if (isAborted) {
- // Log for debugging
- console.debug('[RTK Query] Detected cancelled request:', action.meta?.arg?.endpointName);
- }
- }
-
- return next(action);
-};
diff --git a/lib/user-interface/react/src/shared/reducers/session.reducer.ts b/lib/user-interface/react/src/shared/reducers/session.reducer.ts
index fc9c64e1d..c2ea67d12 100644
--- a/lib/user-interface/react/src/shared/reducers/session.reducer.ts
+++ b/lib/user-interface/react/src/shared/reducers/session.reducer.ts
@@ -83,6 +83,8 @@ export const sessionApi = createApi({
toolCalls: elem.toolCalls,
usage: elem.usage,
guardrailTriggered: elem.guardrailTriggered,
+ reasoningContent: elem.reasoningContent,
+ reasoningSignature: elem.reasoningSignature,
};
return message;
}),
diff --git a/lib/user-interface/react/src/shared/util/branding.ts b/lib/user-interface/react/src/shared/util/branding.ts
new file mode 100644
index 000000000..f20dee1d7
--- /dev/null
+++ b/lib/user-interface/react/src/shared/util/branding.ts
@@ -0,0 +1,60 @@
+/**
+ 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.
+*/
+
+/**
+ * Determines the full branding path including base URL.
+ * Returns the base path for branding assets, accounting for:
+ * - Vite's BASE_URL (for sub-path deployments like /lisa/)
+ * - USE_CUSTOM_BRANDING setting (custom vs base branding)
+ *
+ */
+function getBrandingPath (): string {
+ const brandingDir = (window.env as any)?.USE_CUSTOM_BRANDING ? 'custom' : 'base';
+ const baseUrl = import.meta.env.BASE_URL || '/';
+ const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+
+ return `${normalizedBase}branding/${brandingDir}/`;
+}
+
+/**
+ * Gets the branding asset path for the specified asset type.
+ * @param asset - The asset type ('favicon', 'logo', or 'login')
+ * @returns The full path to the asset, including base URL for sub-path deployments
+ */
+export function getBrandingAssetPath (asset: 'favicon' | 'logo' | 'login'): string {
+ const basePath = getBrandingPath();
+
+ switch (asset) {
+ case 'favicon':
+ return `${basePath}favicon.ico`;
+ case 'logo':
+ return `${basePath}logo.svg`;
+ case 'login':
+ return `${basePath}login.png`;
+ }
+}
+
+/**
+ * Gets the custom display name for branding.
+ * If a custom display name is configured,
+ * returns that name. Otherwise returns 'LISA' as the default.
+ * @returns The display name to use throughout the application
+ */
+export function getDisplayName (): string {
+ const customDisplayName = (window.env as any)?.CUSTOM_DISPLAY_NAME;
+
+ return customDisplayName ? customDisplayName : 'LISA';
+}
diff --git a/lib/user-interface/react/src/shared/util/hooks.ts b/lib/user-interface/react/src/shared/util/hooks.ts
index 3f2289340..e5c6176f0 100644
--- a/lib/user-interface/react/src/shared/util/hooks.ts
+++ b/lib/user-interface/react/src/shared/util/hooks.ts
@@ -15,7 +15,7 @@
*/
import { Action, ThunkDispatch } from '@reduxjs/toolkit';
-import { useCallback, useMemo } from 'react';
+import { useMemo } from 'react';
import NotificationService from '../notification/notification.service';
import { debounce, DebouncedFunc } from 'lodash';
@@ -43,5 +43,6 @@ export function useDebounce void> (callback: T, de
// useMemo is necessary because useCallback doesn't understand the dependencies for the debounced function
const debounced = useMemo(() => debounce(callback, delay), [callback, delay]);
- return useCallback(debounced, [debounced]);
+ // Return debounced directly - it's already memoized and stable until callback/delay change
+ return debounced;
}
diff --git a/lib/user-interface/react/src/shared/validation/index.ts b/lib/user-interface/react/src/shared/validation/index.ts
index 19579dad9..37f998490 100644
--- a/lib/user-interface/react/src/shared/validation/index.ts
+++ b/lib/user-interface/react/src/shared/validation/index.ts
@@ -296,12 +296,10 @@ export const useValidationReducer = >
fields,
} as ValidationTouchAction);
const parseResult = formSchema.safeParse({...state.form, ...{touched: fields}});
- if (!parseResult.success) {
- errors = issuesToErrors(parseResult.error.issues, fields.reduce((acc, key) => {
- acc[key] = true; return acc;
- }, {}));
- }
- return Object.keys(errors).length === 0;
+ const touchErrors = parseResult.success ? errors : issuesToErrors(parseResult.error.issues, fields.reduce((acc, key) => {
+ acc[key] = true; return acc;
+ }, {}));
+ return Object.keys(touchErrors).length === 0;
},
};
};
diff --git a/lib/user-interface/react/src/theme.ts b/lib/user-interface/react/src/theme.ts
new file mode 100644
index 000000000..2b9a340c7
--- /dev/null
+++ b/lib/user-interface/react/src/theme.ts
@@ -0,0 +1,21 @@
+/**
+ 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 { Theme } from '@cloudscape-design/components/theming';
+
+export const brandTheme: Theme = {
+ tokens: {}
+};
diff --git a/lib/user-interface/userInterfaceConstruct.ts b/lib/user-interface/userInterfaceConstruct.ts
index 3e98ac431..abd3dafeb 100644
--- a/lib/user-interface/userInterfaceConstruct.ts
+++ b/lib/user-interface/userInterfaceConstruct.ts
@@ -205,6 +205,8 @@ export class UserInterfaceConstruct extends Construct {
RAG_ENABLED: config.deployRag,
HOSTED_MCP_ENABLED: config.deployMcp,
API_BASE_URL: config.apiGatewayConfig?.domainName ? '/' : `/${config.deploymentStage}/`,
+ USE_CUSTOM_BRANDING: config.useCustomBranding,
+ CUSTOM_DISPLAY_NAME: config.customDisplayName,
};
const appEnvSource = Source.data('env.js', `window.env = ${JSON.stringify(appEnvConfig)}`);
diff --git a/lib/util/codeFactory.ts b/lib/util/codeFactory.ts
index 49aedd920..f376d535a 100644
--- a/lib/util/codeFactory.ts
+++ b/lib/util/codeFactory.ts
@@ -15,12 +15,15 @@
limitations under the License.
*/
-import { Code, DockerImageCode } from 'aws-cdk-lib/aws-lambda';
+import { Code } from 'aws-cdk-lib/aws-lambda';
import { EcsSourceType, ImageAsset } from '../schema';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import { Construct } from 'constructs';
-import { ContainerImage } from 'aws-cdk-lib/aws-ecs';
+import { AssetImageProps, ContainerImage } from 'aws-cdk-lib/aws-ecs';
import { createCdkId } from '../core/utils';
+import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
+
+export const DEFAULT_PLATFORM = Platform.LINUX_AMD64;
export class CodeFactory {
static createImage (image: ImageAsset, scope?: Construct, id?: string, buildArgs?: any): ContainerImage {
@@ -47,26 +50,15 @@ export class CodeFactory {
}
case EcsSourceType.ASSET:
default: {
- return ContainerImage.fromAsset(image.path, { buildArgs });
+ const assetProps: AssetImageProps = {
+ buildArgs,
+ platform: DEFAULT_PLATFORM
+ };
+ return ContainerImage.fromAsset(image.path, assetProps);
}
}
}
- static createDockerImageCode (image: ImageAsset | string, buildArgs?: any): DockerImageCode {
- if (typeof image === 'string') {
- return DockerImageCode.fromImageAsset(image, { buildArgs, exclude: ['cdk.out'] });
- }
-
- switch (image.type) {
- case EcsSourceType.EXTERNAL:
- return image.code;
- case EcsSourceType.ASSET:
- return DockerImageCode.fromImageAsset(image.path, { buildArgs, exclude: ['cdk.out'] });
- default:
- throw Error(`Unimplemented image type for DockerImageCode: ${image.type}`);
- }
- }
-
static createCode (image: ImageAsset | string, scope?: Construct, id?: string): Code {
if (typeof image === 'string') {
return Code.fromAsset(image);
diff --git a/lisa-sdk/lisapy/api.py b/lisa-sdk/lisapy/api.py
index 1b0284c25..f34509bf2 100644
--- a/lisa-sdk/lisapy/api.py
+++ b/lisa-sdk/lisapy/api.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Any, Dict, Optional, Union
+from typing import Any
from pydantic import BaseModel, Field
from requests import Session
@@ -28,9 +28,9 @@
class LisaApi(BaseModel, RepositoryMixin, ModelMixin, ConfigMixin, DocsMixin, RagMixin, SessionMixin, CollectionMixin):
url: str = Field(..., description="REST API url for LiteLLM")
- headers: Optional[Dict[str, str]] = Field(None, description="Headers for request.")
- cookies: Optional[Dict[str, str]] = Field(None, description="Cookies for request.")
- verify: Optional[Union[str, bool]] = Field(None, description="Whether to verify SSL certificates.")
+ headers: dict[str, str] | None = Field(None, description="Headers for request.")
+ cookies: dict[str, str] | None = Field(None, description="Cookies for request.")
+ verify: str | bool | None = Field(None, description="Whether to verify SSL certificates.")
timeout: int = Field(10, description="Timeout in minutes request.")
_session: Session
@@ -39,8 +39,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
self._session = Session()
if self.headers:
- self._session.headers = self.headers # type: ignore
+ self._session.headers.update(self.headers)
if self.verify is not None:
self._session.verify = self.verify
if self.cookies:
- self._session.cookies = self.cookies # type: ignore
+ self._session.cookies.update(self.cookies)
diff --git a/lisa-sdk/lisapy/authentication.py b/lisa-sdk/lisapy/authentication.py
index 324b88c64..f25722b20 100644
--- a/lisa-sdk/lisapy/authentication.py
+++ b/lisa-sdk/lisapy/authentication.py
@@ -14,12 +14,12 @@
"""Cognito Authentication Helper."""
import getpass
-from typing import Any, Dict
+from typing import Any
import boto3
-def get_cognito_token(client_id: str, username: str, region: str = "us-east-1") -> Dict[str, Any]:
+def get_cognito_token(client_id: str, username: str, region: str = "us-east-1") -> dict[str, Any]:
"""Get a token from Cognito.
Parameters
diff --git a/lisa-sdk/lisapy/collection.py b/lisa-sdk/lisapy/collection.py
index f2fc9294a..462aa173d 100644
--- a/lisa-sdk/lisapy/collection.py
+++ b/lisa-sdk/lisapy/collection.py
@@ -14,7 +14,6 @@
"""Collection management operations for LISA SDK."""
-from typing import Dict, List, Optional
from .common import BaseMixin
from .errors import parse_error
@@ -27,13 +26,13 @@ def create_collection(
self,
repository_id: str,
name: str,
- description: Optional[str] = None,
- embedding_model: Optional[str] = None,
- chunking_strategy: Optional[Dict] = None,
- allowed_groups: Optional[List[str]] = None,
- metadata: Optional[Dict] = None,
+ description: str | None = None,
+ embedding_model: str | None = None,
+ chunking_strategy: dict | None = None,
+ allowed_groups: list[str] | None = None,
+ metadata: dict | None = None,
allow_chunking_override: bool = False,
- ) -> Dict:
+ ) -> dict:
"""Create a new collection in a repository.
Args:
@@ -67,11 +66,11 @@ def create_collection(
response = self._session.post(f"{self.url}/repository/{repository_id}/collection", json=payload)
if response.status_code in [200, 201]:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
- def get_collection(self, repository_id: str, collection_id: str) -> Dict:
+ def get_collection(self, repository_id: str, collection_id: str) -> dict:
"""Get a collection by ID.
Args:
@@ -86,7 +85,7 @@ def get_collection(self, repository_id: str, collection_id: str) -> Dict:
"""
response = self._session.get(f"{self.url}/repository/{repository_id}/collection/{collection_id}")
if response.status_code == 200:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
@@ -94,14 +93,14 @@ def update_collection(
self,
repository_id: str,
collection_id: str,
- name: Optional[str] = None,
- description: Optional[str] = None,
- chunking_strategy: Optional[Dict] = None,
- allowed_groups: Optional[List[str]] = None,
- metadata: Optional[Dict] = None,
- allow_chunking_override: Optional[bool] = None,
- status: Optional[str] = None,
- ) -> Dict:
+ name: str | None = None,
+ description: str | None = None,
+ chunking_strategy: dict | None = None,
+ allowed_groups: list[str] | None = None,
+ metadata: dict | None = None,
+ allow_chunking_override: bool | None = None,
+ status: str | None = None,
+ ) -> dict:
"""Update a collection.
Args:
@@ -136,7 +135,7 @@ def update_collection(
response = self._session.put(f"{self.url}/repository/{repository_id}/collection/{collection_id}", json=payload)
if response.status_code == 200:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
@@ -164,11 +163,11 @@ def list_collections(
repository_id: str,
page: int = 1,
page_size: int = 20,
- filter_text: Optional[str] = None,
- status_filter: Optional[str] = None,
+ filter_text: str | None = None,
+ status_filter: str | None = None,
sort_by: str = "createdAt",
sort_order: str = "desc",
- ) -> Dict:
+ ) -> dict:
"""List collections in a repository.
Args:
@@ -186,7 +185,7 @@ def list_collections(
Raises:
Exception: If the request fails
"""
- params = {
+ params: dict[str, str | int] = {
"page": page,
"pageSize": min(page_size, 100),
"sortBy": sort_by,
@@ -200,18 +199,18 @@ def list_collections(
response = self._session.get(f"{self.url}/repository/{repository_id}/collections", params=params)
if response.status_code == 200:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
def get_user_collections(
self,
page_size: int = 20,
- filter_text: Optional[str] = None,
+ filter_text: str | None = None,
sort_by: str = "createdAt",
sort_order: str = "desc",
- last_evaluated_key: Optional[str] = None,
- ) -> List[Dict]:
+ last_evaluated_key: str | None = None,
+ ) -> list[dict]:
"""Get all collections user has access to across all repositories.
Args:
@@ -227,7 +226,7 @@ def get_user_collections(
Raises:
Exception: If the request fails
"""
- params = {
+ params: dict[str, str | int] = {
"pageSize": min(page_size, 100),
"sortBy": sort_by,
"sortOrder": sort_order,
@@ -241,6 +240,6 @@ def get_user_collections(
response = self._session.get(f"{self.url}/repository/collections", params=params)
if response.status_code == 200:
result = response.json()
- return result.get("collections", [])
+ return result.get("collections", []) # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
diff --git a/lisa-sdk/lisapy/common.py b/lisa-sdk/lisapy/common.py
index c0c2da1bf..d54a63908 100644
--- a/lisa-sdk/lisapy/common.py
+++ b/lisa-sdk/lisapy/common.py
@@ -12,15 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Dict, Optional, Union
-from requests import Session
+from requests import Session # type: ignore[import-untyped,unused-ignore]
class BaseMixin:
url: str
- headers: Optional[Dict[str, str]]
- cookies: Optional[Dict[str, str]]
- verify: Optional[Union[str, bool]]
+ headers: dict[str, str] | None
+ cookies: dict[str, str] | None
+ verify: str | bool | None
timeout: int
_session: Session
diff --git a/lisa-sdk/lisapy/config.py b/lisa-sdk/lisapy/config.py
index d69dff101..944d32296 100644
--- a/lisa-sdk/lisapy/config.py
+++ b/lisa-sdk/lisapy/config.py
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Dict, List
from .common import BaseMixin
from .errors import parse_error
@@ -21,10 +20,10 @@
class ConfigMixin(BaseMixin):
"""Mixin for config-related operations."""
- def get_configs(self, config_scope: str = "global") -> List[Dict]:
+ def get_configs(self, config_scope: str = "global") -> list[dict]:
response = self._session.get(f"{self.url}/configuration?configScope={config_scope}")
if response.status_code == 200:
- json_configs: List[Dict] = response.json()
+ json_configs: list[dict] = response.json()
return json_configs
else:
raise parse_error(response.status_code, response)
diff --git a/lisa-sdk/lisapy/errors.py b/lisa-sdk/lisapy/errors.py
index 64c45ac24..8ce133f68 100644
--- a/lisa-sdk/lisapy/errors.py
+++ b/lisa-sdk/lisapy/errors.py
@@ -13,7 +13,7 @@
# limitations under the License.
"""Custom errors."""
-from requests import Response
+from requests import Response # type: ignore[import-untyped,unused-ignore]
class RateLimitExceededError(Exception):
diff --git a/lisa-sdk/lisapy/langchain.py b/lisa-sdk/lisapy/langchain.py
index ee5fd1881..9fa1dc297 100644
--- a/lisa-sdk/lisapy/langchain.py
+++ b/lisa-sdk/lisapy/langchain.py
@@ -13,16 +13,17 @@
# limitations under the License.
"""Langchain adapter."""
-from typing import Any, cast, Iterator, List, Mapping, Optional, Union
+from collections.abc import Iterator, Mapping
+from typing import Any, cast
from httpx import AsyncClient as HttpAsyncClient
from httpx import Client as HttpClient
-from langchain.callbacks.manager import CallbackManagerForLLMRun
-from langchain.llms.base import LLM
-from langchain.schema.output import GenerationChunk
+from langchain_core.callbacks.manager import CallbackManagerForLLMRun
from langchain_core.embeddings import Embeddings
+from langchain_core.language_models.llms import LLM
+from langchain_core.outputs import GenerationChunk
from langchain_openai import OpenAIEmbeddings
-from pydantic import BaseModel, Extra, PrivateAttr
+from pydantic import BaseModel, ConfigDict, PrivateAttr
from .main import FoundationModel, LisaLlm
@@ -68,8 +69,8 @@ def _identifying_params(self) -> Mapping[str, Any]:
def _call(
self,
prompt: str,
- stop: Optional[List[str]] = None,
- run_manager: Optional[CallbackManagerForLLMRun] = None,
+ stop: list[str] | None = None,
+ run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> str:
if self._foundation_model.streaming:
@@ -78,18 +79,18 @@ def _call(
completion += chunk.text
return completion
- text, _ = self.client.generate(prompt, self._foundation_model)
-
- return text # type: ignore
+ response = self.client.generate(prompt, self._foundation_model)
+ result: str = response.generated_text
+ return result
def _stream(
self,
prompt: str,
- stop: Optional[List[str]] = None,
- run_manager: Optional[CallbackManagerForLLMRun] = None,
+ stop: list[str] | None = None,
+ run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> Iterator[GenerationChunk]:
- for resp in self.client.generate_stream(prompt, self.foundation_model):
+ for resp in self.client.generate_stream(prompt, self._foundation_model):
# yield text, if any
if resp.token:
chunk = GenerationChunk(text=resp.token)
@@ -101,6 +102,8 @@ def _stream(
class LisaOpenAIEmbeddings(BaseModel, Embeddings):
"""LISA text embedding adapter."""
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
+
lisa_openai_api_base: str
"""LISA REST API URI."""
@@ -110,18 +113,12 @@ class LisaOpenAIEmbeddings(BaseModel, Embeddings):
api_token: str
"""API Token for communicating with LISA Serve. This can be a custom API token or the IdP Bearer token."""
- verify: Union[bool, str]
+ verify: bool | str
"""Cert path or option for verifying SSL"""
_embedding_model: OpenAIEmbeddings = PrivateAttr(default_factory=None)
"""OpenAI-compliant client for making requests against embedding model."""
- class Config:
- """Configuration for this pydantic object."""
-
- extra = Extra.forbid
- arbitrary_types_allowed = True
-
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._embedding_model = OpenAIEmbeddings(
@@ -135,21 +132,21 @@ def __init__(self, **kwargs: Any) -> None:
http_client=HttpClient(verify=self.verify, timeout=120.0),
)
- def embed_documents(self, texts: List[str]) -> List[List[float]]:
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Use OpenAI API to embed a list of documents."""
- return cast(List[List[float]], self._embedding_model.embed_documents(texts=texts))
+ return cast(list[list[float]], self._embedding_model.embed_documents(texts=texts))
- def embed_query(self, text: str) -> List[float]:
+ def embed_query(self, text: str) -> list[float]:
"""Use OpenAI API to embed a text."""
- return cast(List[float], self._embedding_model.embed_query(text=text))
+ return cast(list[float], self._embedding_model.embed_query(text=text))
- async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
+ async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
"""Use OpenAI API to embed a list of documents."""
- return cast(List[List[float]], await self._embedding_model.aembed_documents(texts=texts))
+ return cast(list[list[float]], await self._embedding_model.aembed_documents(texts=texts))
- async def aembed_query(self, text: str) -> List[float]:
+ async def aembed_query(self, text: str) -> list[float]:
"""Use OpenAI API to embed a text."""
- return cast(List[float], await self._embedding_model.aembed_query(text=text))
+ return cast(list[float], await self._embedding_model.aembed_query(text=text))
class LisaEmbeddings(BaseModel, Embeddings):
@@ -159,6 +156,8 @@ class LisaEmbeddings(BaseModel, Embeddings):
a Lisa API available.
"""
+ model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
+
provider: str
"""Provider of the LISA serve model e.g., ecs.textgen.tgi."""
@@ -170,17 +169,11 @@ class LisaEmbeddings(BaseModel, Embeddings):
_foundation_model: FoundationModel = PrivateAttr(default_factory=None)
- class Config:
- """Configuration for this pydantic object."""
-
- extra = Extra.forbid
- arbitrary_types_allowed = True
-
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._foundation_model = self.client.describe_model(self.provider, self.model_name)
- def embed_documents(self, texts: List[str]) -> List[List[float]]:
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Compute doc embeddings using a LISA model.
Parameters
@@ -195,7 +188,7 @@ def embed_documents(self, texts: List[str]) -> List[List[float]]:
"""
return self.client.embed(texts, self._foundation_model)
- def embed_query(self, text: str) -> List[float]:
+ def embed_query(self, text: str) -> list[float]:
"""Compute query embeddings using a LISA model.
Parameters
@@ -210,7 +203,7 @@ def embed_query(self, text: str) -> List[float]:
"""
return self.client.embed(text, self._foundation_model)[0]
- async def aembed_query(self, text: str) -> List[float]:
+ async def aembed_query(self, text: str) -> list[float]:
"""Asynchronous compute query embeddings using a LISA model.
Parameters
@@ -225,7 +218,7 @@ async def aembed_query(self, text: str) -> List[float]:
"""
return (await self.client.aembed(text, self._foundation_model))[0]
- async def aembed_documents(self, texts: List[str]) -> List[List[float]]:
+ async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
"""Asynchronous compute doc embeddings using a LISA model.
Parameters
diff --git a/lisa-sdk/lisapy/main.py b/lisa-sdk/lisapy/main.py
index ac99e2cf2..2ea20d279 100644
--- a/lisa-sdk/lisapy/main.py
+++ b/lisa-sdk/lisapy/main.py
@@ -16,7 +16,8 @@
import json
import logging
import sys
-from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Union
+from collections.abc import AsyncGenerator, Generator
+from typing import Any
import requests
from aiohttp import ClientSession, ClientTimeout
@@ -43,11 +44,11 @@ class LisaLlm(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
url: str = Field(..., description="REST API url for LiteLLM")
- headers: Optional[Dict[str, str]] = Field(None, description="Headers for request.")
- cookies: Optional[Dict[str, str]] = Field(None, description="Cookies for request.")
+ headers: dict[str, str] | None = Field(None, description="Headers for request.")
+ cookies: dict[str, str] | None = Field(None, description="Cookies for request.")
timeout: int = Field(10, description="Timeout in minutes request.")
- verify: Optional[Union[str, bool]] = Field(None, description="Whether to verify SSL certificates.")
- async_timeout: Optional[ClientTimeout] = None # Do not provide a default value here
+ verify: str | bool | None = Field(None, description="Whether to verify SSL certificates.")
+ async_timeout: ClientTimeout | None = None # Do not provide a default value here
_session: Session
@field_validator("url")
@@ -63,15 +64,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
self._session = requests.Session()
if self.headers:
- self._session.headers = self.headers # type: ignore
+ self._session.headers.update(self.headers)
if self.verify is not None:
self._session.verify = self.verify
if self.cookies:
- self._session.cookies = self.cookies # type: ignore
+ self._session.cookies.update(self.cookies)
self.async_timeout = ClientTimeout(self.timeout * 60)
- def list_models(self) -> List[Dict[str, Any]]:
+ def list_models(self) -> list[dict[str, Any]]:
"""List all foundation models.
Returns
@@ -82,7 +83,7 @@ def list_models(self) -> List[Dict[str, Any]]:
response = self._session.get(f"{self.url}/serve/models")
if response.status_code == 200:
json_models = response.json()
- models: List[Dict] = json_models.get("data")
+ models: list[dict] = json_models.get("data")
else:
raise parse_error(response.status_code, response)
return models
@@ -163,7 +164,7 @@ async def agenerate(
else:
raise parse_error(response.status_code, response)
- def generate_stream(self, prompt: str, model: FoundationModel) -> Generator[StreamingResponse, None, None]:
+ def generate_stream(self, prompt: str, model: FoundationModel) -> Generator[StreamingResponse]:
"""Generate text with streaming based on the provided prompt using a specific model.
Parameters
@@ -210,7 +211,7 @@ async def agenerate_stream(
self,
prompt: str,
model: FoundationModel,
- ) -> AsyncGenerator[StreamingResponse, None]:
+ ) -> AsyncGenerator[StreamingResponse]:
"""Generate text with streaming based on the provided prompt using a specific model.
Parameters
@@ -237,7 +238,11 @@ async def agenerate_stream(
cookies=self.cookies,
timeout=self.async_timeout,
) as session:
- async with session.post(f"{self.url}/generateStream", json=request, ssl=self.verify) as response:
+ async with session.post(
+ f"{self.url}/generateStream",
+ json=request,
+ ssl=self.verify,
+ ) as response:
if response.status != 200:
payload = await response.json()
# TODO this probably won't work
@@ -259,7 +264,7 @@ async def agenerate_stream(
token=json_payload["token"]["text"],
)
- def embed(self, texts: Union[str, List[str]], model: FoundationModel) -> List[List[float]]:
+ def embed(self, texts: str | list[str], model: FoundationModel) -> list[list[float]]:
"""Generate text embeddings based on the provided prompt using a specific model.
Parameters
@@ -284,11 +289,12 @@ def embed(self, texts: Union[str, List[str]], model: FoundationModel) -> List[Li
response = self._session.post(f"{self.url}/embeddings", json=payload)
if response.status_code == 200:
output = response.json()
- return output["embeddings"] # type: ignore
+ embeddings: list[list[float]] = output["embeddings"]
+ return embeddings
else:
raise parse_error(response.status_code, response)
- async def aembed(self, texts: Union[str, List[str]], model: FoundationModel) -> List[List[float]]:
+ async def aembed(self, texts: str | list[str], model: FoundationModel) -> list[list[float]]:
"""Generate text embeddings based on the provided prompt using a specific model.
Parameters
@@ -321,7 +327,8 @@ async def aembed(self, texts: Union[str, List[str]], model: FoundationModel) ->
raise parse_error(response.status_code, response)
output = await response.json()
- return output["embeddings"] # type: ignore
+ embeddings: list[list[float]] = output["embeddings"]
+ return embeddings
def __del__(self) -> None:
"""Close session."""
diff --git a/lisa-sdk/lisapy/model.py b/lisa-sdk/lisapy/model.py
index 2a00bd4bb..0b44873b8 100644
--- a/lisa-sdk/lisapy/model.py
+++ b/lisa-sdk/lisapy/model.py
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Dict, List
from .common import BaseMixin
from .errors import parse_error
@@ -21,29 +20,29 @@
class ModelMixin(BaseMixin):
"""Mixin for model-related operations."""
- def list_models(self) -> List[Dict]:
+ def list_models(self) -> list[dict]:
response = self._session.get(f"{self.url}/models")
if response.status_code == 200:
json_models = response.json()
- models: List[Dict] = json_models.get("models")
+ models: list[dict] = json_models.get("models")
return models
else:
raise parse_error(response.status_code, response)
- def list_embedding_models(self) -> List[Dict]:
+ def list_embedding_models(self) -> list[dict]:
models = self.list_models()
embeddings = [model for model in models if "embedding" == model["modelType"]]
return embeddings
- def list_instances(self) -> List[str]:
+ def list_instances(self) -> list[str]:
response = self._session.get(f"{self.url}/models/metadata/instances")
if response.status_code == 200:
- json_instances: List[str] = response.json()
+ json_instances: list[str] = response.json()
return json_instances
else:
raise parse_error(response.status_code, response)
- def create_bedrock_model(self, payload: Dict) -> Dict:
+ def create_bedrock_model(self, payload: dict) -> dict:
"""Create a Bedrock model configuration.
Args:
@@ -57,11 +56,11 @@ def create_bedrock_model(self, payload: Dict) -> Dict:
"""
response = self._session.post(f"{self.url}/models", json=payload)
if response.status_code in [200, 201]:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
- def create_self_hosted_model(self, payload: Dict) -> Dict:
+ def create_self_hosted_model(self, payload: dict) -> dict:
"""Create a self-hosted model configuration.
Args:
@@ -75,11 +74,11 @@ def create_self_hosted_model(self, payload: Dict) -> Dict:
"""
response = self._session.post(f"{self.url}/models", json=payload)
if response.status_code in [200, 201]:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
- def create_self_hosted_embedded_model(self, payload: Dict) -> Dict:
+ def create_self_hosted_embedded_model(self, payload: dict) -> dict:
"""Create a self-hosted embedding model configuration.
Args:
@@ -93,7 +92,7 @@ def create_self_hosted_embedded_model(self, payload: Dict) -> Dict:
"""
response = self._session.post(f"{self.url}/models", json=payload)
if response.status_code in [200, 201]:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
@@ -115,7 +114,7 @@ def delete_model(self, model_id: str) -> bool:
else:
raise parse_error(response.status_code, response)
- def get_model(self, model_id: str) -> Dict:
+ def get_model(self, model_id: str) -> dict:
"""Get details of a specific model.
Args:
diff --git a/lisa-sdk/lisapy/rag.py b/lisa-sdk/lisapy/rag.py
index 1ad53f396..2f686fa7a 100644
--- a/lisa-sdk/lisapy/rag.py
+++ b/lisa-sdk/lisapy/rag.py
@@ -13,9 +13,8 @@
# limitations under the License.
import logging
import os
-from typing import Dict, List
-import requests
+import requests # type: ignore[import-untyped,unused-ignore]
from .common import BaseMixin
from .errors import parse_error
@@ -24,7 +23,7 @@
class RagMixin(BaseMixin):
"""Mixin for rag-related operations."""
- def list_documents(self, repo_id: str, collection_id: str) -> List[Dict]:
+ def list_documents(self, repo_id: str, collection_id: str) -> list[dict]:
"""List documents in a collection.
Args:
@@ -42,11 +41,11 @@ def list_documents(self, repo_id: str, collection_id: str) -> List[Dict]:
if response.status_code == 200:
result = response.json()
# API returns {"documents": [...], "lastEvaluated": ..., ...}
- return result.get("documents", [])
+ return result.get("documents", []) # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
- def get_document(self, repo_id: str, document_id: str) -> Dict:
+ def get_document(self, repo_id: str, document_id: str) -> dict:
"""Get a single document by ID.
Args:
@@ -59,7 +58,7 @@ def get_document(self, repo_id: str, document_id: str) -> Dict:
url = f"{self.url}/repository/{repo_id}/{document_id}"
response = self._session.get(url)
if response.status_code == 200:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
@@ -116,7 +115,10 @@ def _upload_document(self, presigned_data: dict, filename: str) -> bool:
Returns:
True if upload successful
"""
- url = presigned_data.get("url")
+ url: str | None = presigned_data.get("url")
+ if not url:
+ raise ValueError("Presigned data missing 'url' field")
+
fields = presigned_data.get("fields", {})
with open(filename, "rb") as f:
@@ -153,21 +155,21 @@ def ingest_document(
file: str,
chuck_size: int = 512,
chuck_overlap: int = 51,
- collection_id: str = None,
- ) -> List[Dict]:
+ collection_id: str | None = None,
+ ) -> list[dict]:
"""Ingest a document and return job information.
Returns:
List of job dictionaries with jobId, documentId, status, s3Path
"""
url = f"{self.url}/repository/{repo_id}/bulk"
- params: Dict[str, str | int] = {
+ params: dict[str, str | int] = {
"repositoryType": repo_id,
"chunkSize": chuck_size,
"chunkOverlap": chuck_overlap,
}
- payload = {"embeddingModel": {"modelName": model_id}, "keys": [file]}
+ payload: dict[str, str | dict | list] = {"embeddingModel": {"modelName": model_id}, "keys": [file]}
# Add collectionId to body, not query params
if collection_id:
payload["collectionId"] = collection_id
@@ -179,13 +181,13 @@ def ingest_document(
logging.info(f"Full response: {result}")
jobs = result.get("jobs", [])
logging.info(f"Jobs extracted: {jobs}")
- return jobs
+ return jobs # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
def similarity_search(
- self, repo_id: str, query: str, k: int = 3, collection_id: str = None, model_name: str = None
- ) -> List[Dict]:
+ self, repo_id: str, query: str, k: int = 3, collection_id: str | None = None, model_name: str | None = None
+ ) -> list[dict]:
"""Perform similarity search.
Args:
@@ -207,7 +209,7 @@ def similarity_search(
response = self._session.get(url, params=params)
if response.status_code == 200:
results = response.json()
- docs: List[Dict] = results.get("docs", [])
+ docs: list[dict] = results.get("docs", [])
for doc in docs:
logging.info("Document content:", doc["Document"]["page_content"])
logging.info("Metadata:", doc["Document"]["metadata"])
diff --git a/lisa-sdk/lisapy/repository.py b/lisa-sdk/lisapy/repository.py
index d418d8bef..bf7cb4492 100644
--- a/lisa-sdk/lisapy/repository.py
+++ b/lisa-sdk/lisapy/repository.py
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Dict, List
from .common import BaseMixin
from .errors import parse_error
@@ -22,59 +21,59 @@
class RepositoryMixin(BaseMixin):
"""Mixin for repository-related operations."""
- def list_repositories(self) -> List[Dict]:
+ def list_repositories(self) -> list[dict]:
"""List all available repositories.
Returns:
- List[Dict]: List of repository configurations
+ List[dict]: List of repository configurations
Raises:
Exception: If the request fails
"""
response = self._session.get(f"{self.url}/repository")
if response.status_code == 200:
- json_models: List[Dict] = response.json()
+ json_models: list[dict] = response.json()
return json_models
else:
raise parse_error(response.status_code, response)
- def create_repository(self, rag_config: RagRepositoryConfig) -> Dict:
+ def create_repository(self, rag_config: RagRepositoryConfig) -> dict:
"""Create a new RAG repository.
Args:
rag_config: Configuration for the RAG repository
Returns:
- Dict: Created repository information
+ dict: Created repository information
Raises:
Exception: If the request fails
"""
response = self._session.post(f"{self.url}/repository", json=rag_config)
if response.status_code in [200, 201]:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
- def create_pgvector_repository(self, rag_config: Dict) -> Dict:
+ def create_pgvector_repository(self, rag_config: dict) -> dict:
"""Create a PGVector repository configuration.
Args:
rag_config: RAG configuration for the PGVector repository (will be wrapped in ragConfig)
Returns:
- Dict: Created repository information
+ dict: Created repository information
"""
- return self.create_repository(rag_config)
+ return self.create_repository(rag_config) # type: ignore[arg-type]
def create_opensearch_repository(
self,
repository_id: str,
- repository_name: str = None,
- embedding_model_id: str = None,
- opensearch_config: Dict = None,
- allowed_groups: List[str] = None,
- ) -> Dict:
+ repository_name: str | None = None,
+ embedding_model_id: str | None = None,
+ opensearch_config: dict | None = None,
+ allowed_groups: list[str] | None = None,
+ ) -> dict:
"""Create an OpenSearch repository configuration.
Args:
@@ -85,7 +84,7 @@ def create_opensearch_repository(
allowed_groups: List of groups allowed access
Returns:
- Dict: Created repository information
+ dict: Created repository information
"""
rag_config = {
"repositoryId": repository_id,
@@ -96,10 +95,10 @@ def create_opensearch_repository(
}
if opensearch_config:
- rag_config["opensearchConfig"] = opensearch_config
+ rag_config["opensearchConfig"] = opensearch_config # type: ignore[assignment]
else:
# Create new OpenSearch cluster config
- rag_config["opensearchConfig"] = {
+ rag_config["opensearchConfig"] = { # type: ignore[assignment]
"dataNodes": 2,
"dataNodeInstanceType": "r7g.large.search",
"masterNodes": 0,
@@ -109,7 +108,18 @@ def create_opensearch_repository(
"multiAzWithStandby": False,
}
- return self.create_repository(rag_config)
+ return self.create_repository(rag_config) # type: ignore[arg-type]
+
+ def create_bedrock_kb_repository(self, rag_config: dict) -> dict:
+ """Create a Bedrock Knowledge Base repository configuration.
+
+ Args:
+ rag_config: RAG configuration for the Bedrock KB repository
+
+ Returns:
+ dict: Created repository information
+ """
+ return self.create_repository(rag_config) # type: ignore[arg-type]
def delete_repository(self, repository_id: str) -> bool:
"""Delete a repository.
@@ -129,17 +139,17 @@ def delete_repository(self, repository_id: str) -> bool:
else:
raise parse_error(response.status_code, response)
- def get_repository_status(self) -> Dict:
+ def get_repository_status(self) -> dict:
"""Get the status of RAG repositories.
Returns:
- Dict: Repository status information
+ dict: Repository status information
Raises:
Exception: If the request fails
"""
response = self._session.get(f"{self.url}/repository/status")
if response.status_code == 200:
- return response.json()
+ return response.json() # type: ignore[no-any-return]
else:
raise parse_error(response.status_code, response)
diff --git a/lisa-sdk/lisapy/session.py b/lisa-sdk/lisapy/session.py
index bb2bf59ae..056989ccf 100644
--- a/lisa-sdk/lisapy/session.py
+++ b/lisa-sdk/lisapy/session.py
@@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from typing import Dict, List
from .common import BaseMixin
from .errors import parse_error
@@ -21,18 +20,18 @@
class SessionMixin(BaseMixin):
"""Mixin for session-related operations."""
- def list_sessions(self) -> List[Dict]:
+ def list_sessions(self) -> list[dict]:
response = self._session.get(f"{self.url}/session")
if response.status_code == 200:
- sessions: List[Dict] = response.json()
+ sessions: list[dict] = response.json()
return sessions
else:
raise parse_error(response.status_code, response)
- def get_session_by_user(self) -> Dict:
+ def get_session_by_user(self) -> dict:
response = self._session.get(f"{self.url}/session")
if response.status_code == 200:
- session: Dict = response.json()
+ session: dict = response.json()
return session
else:
raise parse_error(response.status_code, response)
diff --git a/lisa-sdk/lisapy/types.py b/lisa-sdk/lisapy/types.py
index 714c89cc6..87fad87a6 100644
--- a/lisa-sdk/lisapy/types.py
+++ b/lisa-sdk/lisapy/types.py
@@ -16,7 +16,7 @@
from __future__ import annotations
from enum import Enum
-from typing import Any, Dict, List, Optional, TypedDict
+from typing import Any, TypedDict
from pydantic import BaseModel, ConfigDict, Field
@@ -26,6 +26,7 @@ class ModelType(str, Enum):
TEXTGEN = "textgen"
EMBEDDING = "embedding"
+ VIDEOGEN = "videogen"
class ModelKwargs(BaseModel):
@@ -42,7 +43,10 @@ class FoundationModel(BaseModel):
provider: str = Field(..., description="The foundation model provider, e.g. ecs.textgen.tgi.")
model_type: ModelType = Field(..., description="The type of foundation model.")
model_name: str = Field(..., description="The model name.")
- model_kwargs: Optional[ModelKwargs] = Field(default_factory=None, description="The model arguments.")
+ model_kwargs: ModelKwargs | None = Field(
+ default_factory=None,
+ description="The model arguments.",
+ )
streaming: bool = Field(False, description="Whether the model supports streaming.")
def to_string(self) -> str:
@@ -73,8 +77,8 @@ class StreamingResponse(BaseModel):
"""Response from text generation with streaming endpoint."""
token: str = Field(..., description="Generated token")
- finish_reason: Optional[str] = Field(None, description="Generation finish reason when stream is complete.")
- generated_tokens: Optional[int] = Field(None, description="Number of generated tokens when stream is complete.")
+ finish_reason: str | None = Field(None, description="Generation finish reason when stream is complete.")
+ generated_tokens: int | None = Field(None, description="Number of generated tokens when stream is complete.")
class ModelRequest(TypedDict, total=False):
@@ -90,11 +94,11 @@ class ModelRequest(TypedDict, total=False):
instanceType: str
inferenceContainer: str
baseImage: str
- features: List[Dict[str, str]]
- allowedGroups: List[str]
- containerConfig: Dict[str, Any]
- autoScalingConfig: Dict[str, Any]
- loadBalancerConfig: Dict[str, Any]
+ features: list[dict[str, str]]
+ allowedGroups: list[str]
+ containerConfig: dict[str, Any]
+ autoScalingConfig: dict[str, Any]
+ loadBalancerConfig: dict[str, Any]
class BedrockModelRequest(TypedDict, total=False):
@@ -107,8 +111,8 @@ class BedrockModelRequest(TypedDict, total=False):
streaming: bool
multiModal: bool
modelType: str
- features: List[Dict[str, str]]
- allowedGroups: List[str]
+ features: list[dict[str, str]]
+ allowedGroups: list[str]
apiKey: str
@@ -119,8 +123,8 @@ class RagRepositoryConfig(TypedDict, total=False):
repositoryName: str
embeddingModelId: str
type: str
- opensearchConfig: Dict[str, Any]
- rdsConfig: Dict[str, Any]
- bedrockKnowledgeBaseConfig: Dict[str, Any]
- pipelines: List[Dict[str, Any]]
- allowedGroups: List[str]
+ opensearchConfig: dict[str, Any]
+ rdsConfig: dict[str, Any]
+ bedrockKnowledgeBaseConfig: dict[str, Any]
+ pipelines: list[dict[str, Any]]
+ allowedGroups: list[str]
diff --git a/lisa-sdk/pyproject.toml b/lisa-sdk/pyproject.toml
index e34ba944d..989951696 100644
--- a/lisa-sdk/pyproject.toml
+++ b/lisa-sdk/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lisapy"
-version = "6.1.1"
+version = "6.2.0"
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,19 +15,18 @@ dependencies = [
[tool.poetry]
name = "lisapy"
-version = "6.1.1"
+version = "6.2.0"
description = "A simple SDK to help you interact with LISA. LISA is an LLM hosting solution for AWS dedicated clouds or ADCs."
-authors = ["Steve Goley "]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.13"
pydantic = "^2.12.5"
-langchain = "1.1.3"
-langchain-core = "1.1.3"
+langchain = "1.2.7"
+langchain-core = "1.2.7"
langchain-community = "0.4.1"
-langchain-openai = "1.1.1"
-langchain-text-splitters = "1.0.0"
+langchain-openai = "1.1.7"
+langchain-text-splitters = "1.1.0"
numpy = ">2.1.0"
toml = "*"
@@ -49,5 +48,5 @@ markers = [
"asyncio: Async tests",
]
testpaths = [
- "tests/"
+ "../test/sdk/"
]
diff --git a/lisa-sdk/tests/test_langchain_management_key.py b/lisa-sdk/tests/test_langchain_management_key.py
deleted file mode 100644
index 6bb3d25cf..000000000
--- a/lisa-sdk/tests/test_langchain_management_key.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# 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 os
-import unittest
-from unittest.mock import Mock, patch
-
-from lisapy.langchain import LisaOpenAIEmbeddings
-
-
-class TestLisaOpenAIEmbeddingsManagementKey(unittest.TestCase):
- @patch.dict(os.environ, {"AWS_REGION": "us-west-2", "MANAGEMENT_KEY_SECRET_NAME_PS": "/test/secret"})
- @patch("boto3.client")
- def test_from_management_key(self, mock_boto3_client: Mock) -> None:
- # Mock SSM and Secrets Manager clients
- mock_ssm = Mock()
- mock_secrets = Mock()
-
- mock_boto3_client.side_effect = lambda service, **kwargs: {"ssm": mock_ssm, "secretsmanager": mock_secrets}[
- service
- ]
-
- # Mock SSM parameter response
- mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "test-secret-name"}}
-
- # Mock Secrets Manager response
- mock_secrets.get_secret_value.return_value = {"SecretString": "test-management-token"}
-
- # Test the method
- embeddings = LisaOpenAIEmbeddings.from_management_key(
- lisa_openai_api_base="https://api.test.com", model="test-model", verify=True
- )
-
- # Verify the result
- self.assertEqual(embeddings.lisa_openai_api_base, "https://api.test.com")
- self.assertEqual(embeddings.model, "test-model")
- self.assertEqual(embeddings.api_token, "test-management-token")
- self.assertEqual(embeddings.verify, True)
-
- # Verify AWS calls
- mock_ssm.get_parameter.assert_called_once_with(Name="/test/secret")
- mock_secrets.get_secret_value.assert_called_once_with(SecretId="test-secret-name")
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/mcp_server_deployer/src/lib/ecsMcpServer.ts b/mcp_server_deployer/src/lib/ecsMcpServer.ts
index 4b53267d0..f01df6524 100644
--- a/mcp_server_deployer/src/lib/ecsMcpServer.ts
+++ b/mcp_server_deployer/src/lib/ecsMcpServer.ts
@@ -539,14 +539,14 @@ export class EcsMcpServer extends Construct {
}
// Use the provided image as base, or default to a suitable base image
const baseImage = mcpServerConfig.image || (mcpServerConfig.serverType === 'stdio'
- ? 'python:3.13-slim-bookworm'
- : 'python:3.13-slim-bookworm');
+ ? 'public.ecr.aws/docker/library/python:3.13-slim-bookworm'
+ : 'public.ecr.aws/docker/library/python:3.13-slim-bookworm');
return ContainerImage.fromRegistry(baseImage);
}
// Default: use a common base image
// This should be replaced with a proper base image for MCP servers
- return ContainerImage.fromRegistry('node:24-slim');
+ return ContainerImage.fromRegistry('public.ecr.aws/docker/library/node:24-slim');
}
/**
diff --git a/mcp_server_deployer/src/lib/scripts/astral-install.sh b/mcp_server_deployer/src/lib/scripts/astral-install.sh
new file mode 100755
index 000000000..72645b0ed
--- /dev/null
+++ b/mcp_server_deployer/src/lib/scripts/astral-install.sh
@@ -0,0 +1,2080 @@
+#!/bin/sh
+# shellcheck shell=dash
+# shellcheck disable=SC2039 # local is non-POSIX
+#
+# Licensed under the MIT license
+# , at your
+# option. This file may not be copied, modified, or distributed
+# except according to those terms.
+
+# This runs on Unix shells like bash/dash/ksh/zsh. It uses the common `local`
+# extension. Note: Most shells limit `local` to 1 var per line, contra bash.
+
+# Some versions of ksh have no `local` keyword. Alias it to `typeset`, but
+# beware this makes variables global with f()-style function syntax in ksh93.
+# mksh has this alias by default.
+has_local() {
+ # shellcheck disable=SC2034 # deliberately unused
+ local _has_local
+}
+
+has_local 2>/dev/null || alias local=typeset
+
+set -u
+
+APP_NAME="uv"
+APP_VERSION="0.9.27"
+# Look for GitHub Enterprise-style base URL first
+if [ -n "${UV_INSTALLER_GHE_BASE_URL:-}" ]; then
+ INSTALLER_BASE_URL="$UV_INSTALLER_GHE_BASE_URL"
+else
+ INSTALLER_BASE_URL="${UV_INSTALLER_GITHUB_BASE_URL:-https://github.com}"
+fi
+if [ -n "${UV_DOWNLOAD_URL:-}" ]; then
+ ARTIFACT_DOWNLOAD_URL="$UV_DOWNLOAD_URL"
+elif [ -n "${INSTALLER_DOWNLOAD_URL:-}" ]; then
+ ARTIFACT_DOWNLOAD_URL="$INSTALLER_DOWNLOAD_URL"
+else
+ ARTIFACT_DOWNLOAD_URL="${INSTALLER_BASE_URL}/astral-sh/uv/releases/download/0.9.27"
+fi
+if [ -n "${UV_PRINT_VERBOSE:-}" ]; then
+ PRINT_VERBOSE="$UV_PRINT_VERBOSE"
+else
+ PRINT_VERBOSE=${INSTALLER_PRINT_VERBOSE:-0}
+fi
+if [ -n "${UV_PRINT_QUIET:-}" ]; then
+ PRINT_QUIET="$UV_PRINT_QUIET"
+else
+ PRINT_QUIET=${INSTALLER_PRINT_QUIET:-0}
+fi
+if [ -n "${UV_NO_MODIFY_PATH:-}" ]; then
+ NO_MODIFY_PATH="$UV_NO_MODIFY_PATH"
+else
+ NO_MODIFY_PATH=${INSTALLER_NO_MODIFY_PATH:-0}
+fi
+if [ "${UV_DISABLE_UPDATE:-0}" = "1" ]; then
+ INSTALL_UPDATER=0
+else
+ INSTALL_UPDATER=1
+fi
+UNMANAGED_INSTALL="${UV_UNMANAGED_INSTALL:-}"
+if [ -n "${UNMANAGED_INSTALL}" ]; then
+ NO_MODIFY_PATH=1
+ INSTALL_UPDATER=0
+fi
+AUTH_TOKEN="${UV_GITHUB_TOKEN:-}"
+
+read -r RECEIPT <&2
+ say_verbose " from $_url" 1>&2
+ say_verbose " to $_file" 1>&2
+
+ ensure mkdir -p "$_dir"
+
+ if ! downloader "$_url" "$_file"; then
+ say "failed to download $_url"
+ say "this may be a standard network error, but it may also indicate"
+ say "that $APP_NAME's release process is not working. When in doubt"
+ say "please feel free to open an issue!"
+ exit 1
+ fi
+
+ if [ -n "${_checksum_style:-}" ]; then
+ verify_checksum "$_file" "$_checksum_style" "$_checksum_value"
+ else
+ say "no checksums to verify"
+ fi
+
+ # ...and then the updater, if it exists
+ if [ -n "$_updater_name" ] && [ "$INSTALL_UPDATER" = "1" ]; then
+ local _updater_url="$ARTIFACT_DOWNLOAD_URL/$_updater_name"
+ # This renames the artifact while doing the download, removing the
+ # target triple and leaving just the appname-update format
+ local _updater_file="$_dir/$APP_NAME-update"
+
+ if ! downloader "$_updater_url" "$_updater_file"; then
+ say "failed to download $_updater_url"
+ say "this may be a standard network error, but it may also indicate"
+ say "that $APP_NAME's release process is not working. When in doubt"
+ say "please feel free to open an issue!"
+ exit 1
+ fi
+
+ # Add the updater to the list of binaries to install
+ _bins="$_bins $APP_NAME-update"
+ fi
+
+ # unpack the archive
+ case "$_zip_ext" in
+ ".zip")
+ ensure unzip -q "$_file" -d "$_dir"
+ ;;
+
+ ".tar."*)
+ ensure tar xf "$_file" --strip-components 1 -C "$_dir"
+ ;;
+ *)
+ err "unknown archive format: $_zip_ext"
+ ;;
+ esac
+
+ install "$_dir" "$_bins" "$_libs" "$_staticlibs" "$_arch" "$@"
+ local _retval=$?
+ if [ "$_retval" != 0 ]; then
+ return "$_retval"
+ fi
+
+ ignore rm -rf "$_dir"
+
+ # Install the install receipt
+ if [ "$INSTALL_UPDATER" = "1" ]; then
+ if ! mkdir -p "$RECEIPT_HOME"; then
+ err "unable to create receipt directory at $RECEIPT_HOME"
+ else
+ echo "$RECEIPT" > "$RECEIPT_HOME/$APP_NAME-receipt.json"
+ # shellcheck disable=SC2320
+ local _retval=$?
+ fi
+ else
+ local _retval=0
+ fi
+
+ return "$_retval"
+}
+
+# Replaces $HOME with the variable name for display to the user,
+# only if $HOME is defined.
+replace_home() {
+ local _str="$1"
+
+ if [ -n "${HOME:-}" ]; then
+ echo "$_str" | sed "s,$HOME,\$HOME,"
+ else
+ echo "$_str"
+ fi
+}
+
+json_binary_aliases() {
+ local _arch="$1"
+
+ case "$_arch" in
+ "aarch64-apple-darwin")
+ echo '{}'
+ ;;
+ "aarch64-pc-windows-gnu")
+ echo '{}'
+ ;;
+ "aarch64-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "aarch64-unknown-linux-musl-dynamic")
+ echo '{}'
+ ;;
+ "aarch64-unknown-linux-musl-static")
+ echo '{}'
+ ;;
+ "arm-unknown-linux-gnueabihf")
+ echo '{}'
+ ;;
+ "arm-unknown-linux-musl-dynamiceabihf")
+ echo '{}'
+ ;;
+ "arm-unknown-linux-musl-staticeabihf")
+ echo '{}'
+ ;;
+ "armv7-unknown-linux-gnueabihf")
+ echo '{}'
+ ;;
+ "armv7-unknown-linux-musl-dynamiceabihf")
+ echo '{}'
+ ;;
+ "armv7-unknown-linux-musl-staticeabihf")
+ echo '{}'
+ ;;
+ "i686-pc-windows-gnu")
+ echo '{}'
+ ;;
+ "i686-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "i686-unknown-linux-musl-dynamic")
+ echo '{}'
+ ;;
+ "i686-unknown-linux-musl-static")
+ echo '{}'
+ ;;
+ "powerpc64-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "powerpc64le-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "riscv64gc-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "s390x-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "x86_64-apple-darwin")
+ echo '{}'
+ ;;
+ "x86_64-pc-windows-gnu")
+ echo '{}'
+ ;;
+ "x86_64-unknown-linux-gnu")
+ echo '{}'
+ ;;
+ "x86_64-unknown-linux-musl-dynamic")
+ echo '{}'
+ ;;
+ "x86_64-unknown-linux-musl-static")
+ echo '{}'
+ ;;
+ *)
+ echo '{}'
+ ;;
+ esac
+}
+
+aliases_for_binary() {
+ local _bin="$1"
+ local _arch="$2"
+
+ case "$_arch" in
+ "aarch64-apple-darwin")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "aarch64-pc-windows-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "aarch64-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "aarch64-unknown-linux-musl-dynamic")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "aarch64-unknown-linux-musl-static")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "arm-unknown-linux-gnueabihf")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "arm-unknown-linux-musl-dynamiceabihf")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "arm-unknown-linux-musl-staticeabihf")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "armv7-unknown-linux-gnueabihf")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "armv7-unknown-linux-musl-dynamiceabihf")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "armv7-unknown-linux-musl-staticeabihf")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "i686-pc-windows-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "i686-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "i686-unknown-linux-musl-dynamic")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "i686-unknown-linux-musl-static")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "powerpc64-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "powerpc64le-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "riscv64gc-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "s390x-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "x86_64-apple-darwin")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "x86_64-pc-windows-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "x86_64-unknown-linux-gnu")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "x86_64-unknown-linux-musl-dynamic")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ "x86_64-unknown-linux-musl-static")
+ case "$_bin" in
+ *)
+ echo ""
+ ;;
+ esac
+ ;;
+ *)
+ echo ""
+ ;;
+ esac
+}
+
+select_archive_for_arch() {
+ local _true_arch="$1"
+ local _archive
+
+ # try each archive, checking runtime conditions like libc versions
+ # accepting the first one that matches, as it's the best match
+ case "$_true_arch" in
+ "aarch64-apple-darwin")
+ _archive="uv-aarch64-apple-darwin.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-x86_64-apple-darwin.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "aarch64-pc-windows-gnu")
+ _archive="uv-aarch64-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "aarch64-pc-windows-msvc")
+ _archive="uv-aarch64-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-x86_64-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-i686-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "aarch64-unknown-linux-gnu")
+ _archive="uv-aarch64-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "28"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-aarch64-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "aarch64-unknown-linux-musl-dynamic")
+ _archive="uv-aarch64-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "aarch64-unknown-linux-musl-static")
+ _archive="uv-aarch64-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "arm-unknown-linux-gnueabihf")
+ _archive="uv-arm-unknown-linux-musleabihf.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "arm-unknown-linux-musl-dynamiceabihf")
+ _archive="uv-arm-unknown-linux-musleabihf.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "arm-unknown-linux-musl-staticeabihf")
+ _archive="uv-arm-unknown-linux-musleabihf.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "armv7-unknown-linux-gnueabihf")
+ _archive="uv-armv7-unknown-linux-gnueabihf.tar.gz"
+ if ! check_glibc "2" "17"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-armv7-unknown-linux-musleabihf.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "armv7-unknown-linux-musl-dynamiceabihf")
+ _archive="uv-armv7-unknown-linux-musleabihf.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "armv7-unknown-linux-musl-staticeabihf")
+ _archive="uv-armv7-unknown-linux-musleabihf.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "i686-pc-windows-gnu")
+ _archive="uv-i686-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "i686-pc-windows-msvc")
+ _archive="uv-i686-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "i686-unknown-linux-gnu")
+ _archive="uv-i686-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "17"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-i686-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "i686-unknown-linux-musl-dynamic")
+ _archive="uv-i686-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "i686-unknown-linux-musl-static")
+ _archive="uv-i686-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "powerpc64-unknown-linux-gnu")
+ _archive="uv-powerpc64-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "17"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "powerpc64le-unknown-linux-gnu")
+ _archive="uv-powerpc64le-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "17"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "riscv64gc-unknown-linux-gnu")
+ _archive="uv-riscv64gc-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "31"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "s390x-unknown-linux-gnu")
+ _archive="uv-s390x-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "17"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "x86_64-apple-darwin")
+ _archive="uv-x86_64-apple-darwin.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "x86_64-pc-windows-gnu")
+ _archive="uv-x86_64-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "x86_64-pc-windows-msvc")
+ _archive="uv-x86_64-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-i686-pc-windows-msvc.zip"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "x86_64-unknown-linux-gnu")
+ _archive="uv-x86_64-unknown-linux-gnu.tar.gz"
+ if ! check_glibc "2" "17"; then
+ _archive=""
+ fi
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ _archive="uv-x86_64-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "x86_64-unknown-linux-musl-dynamic")
+ _archive="uv-x86_64-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ "x86_64-unknown-linux-musl-static")
+ _archive="uv-x86_64-unknown-linux-musl.tar.gz"
+ if [ -n "$_archive" ]; then
+ echo "$_archive"
+ return 0
+ fi
+ ;;
+ *)
+ err "there isn't a download for your platform $_true_arch"
+ ;;
+ esac
+ err "no compatible downloads were found for your platform $_true_arch"
+}
+
+check_glibc() {
+ local _min_glibc_major="$1"
+ local _min_glibc_series="$2"
+
+ # Parsing version out from line 1 like:
+ # ldd (Ubuntu GLIBC 2.35-0ubuntu3.1) 2.35
+ _local_glibc="$(ldd --version | awk -F' ' '{ if (FNR<=1) print $NF }')"
+
+ if [ "$(echo "${_local_glibc}" | awk -F. '{ print $1 }')" = "$_min_glibc_major" ] && [ "$(echo "${_local_glibc}" | awk -F. '{ print $2 }')" -ge "$_min_glibc_series" ]; then
+ return 0
+ else
+ say "System glibc version (\`${_local_glibc}') is too old; checking alternatives" >&2
+ return 1
+ fi
+}
+
+# See discussion of late-bound vs early-bound for why we use single-quotes with env vars
+# shellcheck disable=SC2016
+install() {
+ # This code needs to both compute certain paths for itself to write to, and
+ # also write them to shell/rc files so that they can look them up to e.g.
+ # add them to PATH. This requires an active distinction between paths
+ # and expressions that can compute them.
+ #
+ # The distinction lies in when we want env-vars to be evaluated. For instance
+ # if we determine that we want to install to $HOME/.myapp, which do we add
+ # to e.g. $HOME/.profile:
+ #
+ # * early-bound: export PATH="/home/myuser/.myapp:$PATH"
+ # * late-bound: export PATH="$HOME/.myapp:$PATH"
+ #
+ # In this case most people would prefer the late-bound version, but in other
+ # cases the early-bound version might be a better idea. In particular when using
+ # other env-vars than $HOME, they are more likely to be only set temporarily
+ # for the duration of this install script, so it's more advisable to erase their
+ # existence with early-bounding.
+ #
+ # This distinction is handled by "double-quotes" (early) vs 'single-quotes' (late).
+ #
+ # However if we detect that "$SOME_VAR/..." is a subdir of $HOME, we try to rewrite
+ # it to be '$HOME/...' to get the best of both worlds.
+ #
+ # This script has a few different variants, the most complex one being the
+ # CARGO_HOME version which attempts to install things to Cargo's bin dir,
+ # potentially setting up a minimal version if the user hasn't ever installed Cargo.
+ #
+ # In this case we need to:
+ #
+ # * Install to $HOME/.cargo/bin/
+ # * Create a shell script at $HOME/.cargo/env that:
+ # * Checks if $HOME/.cargo/bin/ is on PATH
+ # * and if not prepends it to PATH
+ # * Edits $INFERRED_HOME/.profile to run $HOME/.cargo/env (if the line doesn't exist)
+ #
+ # To do this we need these 4 values:
+
+ # The actual path we're going to install to
+ local _install_dir
+ # The directory C dynamic/static libraries install to
+ local _lib_install_dir
+ # The install prefix we write to the receipt.
+ # For organized install methods like CargoHome, which have
+ # subdirectories, this is the root without `/bin`. For other
+ # methods, this is the same as `_install_dir`.
+ local _receipt_install_dir
+ # Path to the an shell script that adds install_dir to PATH
+ local _env_script_path
+ # Potentially-late-bound version of install_dir to write env_script
+ local _install_dir_expr
+ # Potentially-late-bound version of env_script_path to write to rcfiles like $HOME/.profile
+ local _env_script_path_expr
+ # Forces the install to occur at this path, not the default
+ local _force_install_dir
+ # Which install layout to use - "flat" or "hierarchical"
+ local _install_layout="unspecified"
+ # A list of binaries which are shadowed in the PATH
+ local _shadowed_bins=""
+
+ # Check the newer app-specific variable before falling back
+ # to the older generic one
+ if [ -n "${UV_INSTALL_DIR:-}" ]; then
+ _force_install_dir="$UV_INSTALL_DIR"
+ _install_layout="flat"
+ elif [ -n "${CARGO_DIST_FORCE_INSTALL_DIR:-}" ]; then
+ _force_install_dir="$CARGO_DIST_FORCE_INSTALL_DIR"
+ _install_layout="flat"
+ elif [ -n "$UNMANAGED_INSTALL" ]; then
+ _force_install_dir="$UNMANAGED_INSTALL"
+ _install_layout="flat"
+ fi
+
+ # Check if the install layout should be changed from `flat` to `cargo-home`
+ # for backwards compatible updates of applications that switched layouts.
+ if [ -n "${_force_install_dir:-}" ]; then
+ if [ "$_install_layout" = "flat" ]; then
+ # If the install directory is targeting the Cargo home directory, then
+ # we assume this application was previously installed that layout
+ if [ "$_force_install_dir" = "${CARGO_HOME:-${INFERRED_HOME:-}/.cargo}" ]; then
+ _install_layout="cargo-home"
+ fi
+ fi
+ fi
+
+ # Before actually consulting the configured install strategy, see
+ # if we're overriding it.
+ if [ -n "${_force_install_dir:-}" ]; then
+ case "$_install_layout" in
+ "hierarchical")
+ _install_dir="$_force_install_dir/bin"
+ _lib_install_dir="$_force_install_dir/lib"
+ _receipt_install_dir="$_force_install_dir"
+ _env_script_path="$_force_install_dir/env"
+ _install_dir_expr="$(replace_home "$_force_install_dir/bin")"
+ _env_script_path_expr="$(replace_home "$_force_install_dir/env")"
+ ;;
+ "cargo-home")
+ _install_dir="$_force_install_dir/bin"
+ _lib_install_dir="$_force_install_dir/bin"
+ _receipt_install_dir="$_force_install_dir"
+ _env_script_path="$_force_install_dir/env"
+ _install_dir_expr="$(replace_home "$_force_install_dir/bin")"
+ _env_script_path_expr="$(replace_home "$_force_install_dir/env")"
+ ;;
+ "flat")
+ _install_dir="$_force_install_dir"
+ _lib_install_dir="$_force_install_dir"
+ _receipt_install_dir="$_install_dir"
+ _env_script_path="$_force_install_dir/env"
+ _install_dir_expr="$(replace_home "$_force_install_dir")"
+ _env_script_path_expr="$(replace_home "$_force_install_dir/env")"
+ ;;
+ *)
+ err "Unrecognized install layout: $_install_layout"
+ ;;
+ esac
+ fi
+ if [ -z "${_install_dir:-}" ]; then
+ _install_layout="flat"
+ # Install to $XDG_BIN_HOME
+ if [ -n "${XDG_BIN_HOME:-}" ]; then
+ _install_dir="$XDG_BIN_HOME"
+ _lib_install_dir="$_install_dir"
+ _receipt_install_dir="$_install_dir"
+ _env_script_path="$XDG_BIN_HOME/env"
+ _install_dir_expr="$(replace_home "$_install_dir")"
+ _env_script_path_expr="$(replace_home "$_env_script_path")"
+ fi
+ fi
+ if [ -z "${_install_dir:-}" ]; then
+ _install_layout="flat"
+ # Install to $XDG_DATA_HOME/../bin
+ if [ -n "${XDG_DATA_HOME:-}" ]; then
+ _install_dir="$XDG_DATA_HOME/../bin"
+ _lib_install_dir="$_install_dir"
+ _receipt_install_dir="$_install_dir"
+ _env_script_path="$XDG_DATA_HOME/../bin/env"
+ _install_dir_expr="$(replace_home "$_install_dir")"
+ _env_script_path_expr="$(replace_home "$_env_script_path")"
+ fi
+ fi
+ if [ -z "${_install_dir:-}" ]; then
+ _install_layout="flat"
+ # Install to $HOME/.local/bin
+ if [ -n "${INFERRED_HOME:-}" ]; then
+ _install_dir="$INFERRED_HOME/.local/bin"
+ _lib_install_dir="$INFERRED_HOME/.local/bin"
+ _receipt_install_dir="$_install_dir"
+ _env_script_path="$INFERRED_HOME/.local/bin/env"
+ _install_dir_expr="$INFERRED_HOME_EXPRESSION/.local/bin"
+ _env_script_path_expr="$INFERRED_HOME_EXPRESSION/.local/bin/env"
+ fi
+ fi
+
+ if [ -z "$_install_dir_expr" ]; then
+ err "could not find a valid path to install to!"
+ fi
+
+ # Identical to the sh version, just with a .fish file extension
+ # We place it down here to wait until it's been assigned in every
+ # path.
+ _fish_env_script_path="${_env_script_path}.fish"
+ _fish_env_script_path_expr="${_env_script_path_expr}.fish"
+
+ # Replace the temporary cargo home with the calculated one
+ RECEIPT=$(echo "$RECEIPT" | sed "s,AXO_INSTALL_PREFIX,$_receipt_install_dir,")
+ # Also replace the aliases with the arch-specific one
+ RECEIPT=$(echo "$RECEIPT" | sed "s'\"binary_aliases\":{}'\"binary_aliases\":$(json_binary_aliases "$_arch")'")
+ # And replace the install layout
+ RECEIPT=$(echo "$RECEIPT" | sed "s'\"install_layout\":\"unspecified\"'\"install_layout\":\"$_install_layout\"'")
+ if [ "$NO_MODIFY_PATH" = "1" ]; then
+ RECEIPT=$(echo "$RECEIPT" | sed "s'\"modify_path\":true'\"modify_path\":false'")
+ fi
+
+ say "installing to $_install_dir"
+ ensure mkdir -p "$_install_dir"
+ ensure mkdir -p "$_lib_install_dir"
+
+ # copy all the binaries to the install dir
+ local _src_dir="$1"
+ local _bins="$2"
+ local _libs="$3"
+ local _staticlibs="$4"
+ local _arch="$5"
+ for _bin_name in $_bins; do
+ local _bin="$_src_dir/$_bin_name"
+ ensure mv "$_bin" "$_install_dir"
+ # unzip seems to need this chmod
+ ensure chmod +x "$_install_dir/$_bin_name"
+ for _dest in $(aliases_for_binary "$_bin_name" "$_arch"); do
+ ln -sf "$_install_dir/$_bin_name" "$_install_dir/$_dest"
+ done
+ say " $_bin_name"
+ done
+ # Like the above, but no aliases
+ for _lib_name in $_libs; do
+ local _lib="$_src_dir/$_lib_name"
+ ensure mv "$_lib" "$_lib_install_dir"
+ # unzip seems to need this chmod
+ ensure chmod +x "$_lib_install_dir/$_lib_name"
+ say " $_lib_name"
+ done
+ for _lib_name in $_staticlibs; do
+ local _lib="$_src_dir/$_lib_name"
+ ensure mv "$_lib" "$_lib_install_dir"
+ # unzip seems to need this chmod
+ ensure chmod +x "$_lib_install_dir/$_lib_name"
+ say " $_lib_name"
+ done
+
+ say "everything's installed!"
+
+ # Avoid modifying the users PATH if they are managing their PATH manually
+ case :$PATH:
+ in *:$_install_dir:*) NO_MODIFY_PATH=1 ;;
+ *) ;;
+ esac
+
+ if [ "0" = "$NO_MODIFY_PATH" ]; then
+ add_install_dir_to_ci_path "$_install_dir"
+ add_install_dir_to_path "$_install_dir_expr" "$_env_script_path" "$_env_script_path_expr" ".profile" "sh"
+ exit1=$?
+ shotgun_install_dir_to_path "$_install_dir_expr" "$_env_script_path" "$_env_script_path_expr" ".profile .bashrc .bash_profile .bash_login" "sh"
+ exit2=$?
+ add_install_dir_to_path "$_install_dir_expr" "$_env_script_path" "$_env_script_path_expr" ".zshrc .zshenv" "sh"
+ exit3=$?
+ # This path may not exist by default
+ ensure mkdir -p "$INFERRED_HOME/.config/fish/conf.d"
+ exit4=$?
+ add_install_dir_to_path "$_install_dir_expr" "$_fish_env_script_path" "$_fish_env_script_path_expr" ".config/fish/conf.d/$APP_NAME.env.fish" "fish"
+ exit5=$?
+
+ if [ "${exit1:-0}" = 1 ] || [ "${exit2:-0}" = 1 ] || [ "${exit3:-0}" = 1 ] || [ "${exit4:-0}" = 1 ] || [ "${exit5:-0}" = 1 ]; then
+ say ""
+ say "To add $_install_dir_expr to your PATH, either restart your shell or run:"
+ say ""
+ say " source $_env_script_path_expr (sh, bash, zsh)"
+ say " source $_fish_env_script_path_expr (fish)"
+ fi
+ fi
+
+ _shadowed_bins="$(check_for_shadowed_bins "$_install_dir" "$_bins")"
+ if [ -n "$_shadowed_bins" ]; then
+ warn "The following commands are shadowed by other commands in your PATH:$_shadowed_bins"
+ fi
+}
+
+check_for_shadowed_bins() {
+ local _install_dir="$1"
+ local _bins="$2"
+ local _shadow
+
+ for _bin_name in $_bins; do
+ _shadow="$(command -v "$_bin_name")"
+ if [ -n "$_shadow" ] && [ "$_shadow" != "$_install_dir/$_bin_name" ]; then
+ _shadowed_bins="$_shadowed_bins $_bin_name"
+ fi
+ done
+
+ echo "$_shadowed_bins"
+}
+
+print_home_for_script() {
+ local script="$1"
+
+ local _home
+ case "$script" in
+ # zsh has a special ZDOTDIR directory, which if set
+ # should be considered instead of $HOME
+ .zsh*)
+ if [ -n "${ZDOTDIR:-}" ]; then
+ _home="$ZDOTDIR"
+ else
+ _home="$INFERRED_HOME"
+ fi
+ ;;
+ *)
+ _home="$INFERRED_HOME"
+ ;;
+ esac
+
+ echo "$_home"
+}
+
+add_install_dir_to_ci_path() {
+ # Attempt to do CI-specific rituals to get the install-dir on PATH faster
+ local _install_dir="$1"
+
+ # If GITHUB_PATH is present, then write install_dir to the file it refs.
+ # After each GitHub Action, the contents will be added to PATH.
+ # So if you put a curl | sh for this script in its own "run" step,
+ # the next step will have this dir on PATH.
+ #
+ # Note that GITHUB_PATH will not resolve any variables, so we in fact
+ # want to write install_dir and not install_dir_expr
+ if [ -n "${GITHUB_PATH:-}" ]; then
+ ensure echo "$_install_dir" >> "$GITHUB_PATH"
+ fi
+}
+
+add_install_dir_to_path() {
+ # Edit rcfiles ($HOME/.profile) to add install_dir to $PATH
+ #
+ # We do this slightly indirectly by creating an "env" shell script which checks if install_dir
+ # is on $PATH already, and prepends it if not. The actual line we then add to rcfiles
+ # is to just source that script. This allows us to blast it into lots of different rcfiles and
+ # have it run multiple times without causing problems. It's also specifically compatible
+ # with the system rustup uses, so that we don't conflict with it.
+ local _install_dir_expr="$1"
+ local _env_script_path="$2"
+ local _env_script_path_expr="$3"
+ local _rcfiles="$4"
+ local _shell="$5"
+
+ if [ -n "${INFERRED_HOME:-}" ]; then
+ local _target
+ local _home
+
+ # Find the first file in the array that exists and choose
+ # that as our target to write to
+ for _rcfile_relative in $_rcfiles; do
+ _home="$(print_home_for_script "$_rcfile_relative")"
+ local _rcfile="$_home/$_rcfile_relative"
+
+ if [ -f "$_rcfile" ]; then
+ _target="$_rcfile"
+ break
+ fi
+ done
+
+ # If we didn't find anything, pick the first entry in the
+ # list as the default to create and write to
+ if [ -z "${_target:-}" ]; then
+ local _rcfile_relative
+ _rcfile_relative="$(echo "$_rcfiles" | awk '{ print $1 }')"
+ _home="$(print_home_for_script "$_rcfile_relative")"
+ _target="$_home/$_rcfile_relative"
+ fi
+
+ # `source x` is an alias for `. x`, and the latter is more portable/actually-posix.
+ # This apparently comes up a lot on freebsd. It's easy enough to always add
+ # the more robust line to rcfiles, but when telling the user to apply the change
+ # to their current shell ". x" is pretty easy to misread/miscopy, so we use the
+ # prettier "source x" line there. Hopefully people with Weird Shells are aware
+ # this is a thing and know to tweak it (or just restart their shell).
+ local _robust_line=". \"$_env_script_path_expr\""
+ local _pretty_line="source \"$_env_script_path_expr\""
+
+ # Add the env script if it doesn't already exist
+ if [ ! -f "$_env_script_path" ]; then
+ say_verbose "creating $_env_script_path"
+ if [ "$_shell" = "sh" ]; then
+ write_env_script_sh "$_install_dir_expr" "$_env_script_path"
+ else
+ write_env_script_fish "$_install_dir_expr" "$_env_script_path"
+ fi
+ else
+ say_verbose "$_env_script_path already exists"
+ fi
+
+ # Check if the line is already in the rcfile
+ # grep: 0 if matched, 1 if no match, and 2 if an error occurred
+ #
+ # Ideally we could use quiet grep (-q), but that makes "match" and "error"
+ # have the same behaviour, when we want "no match" and "error" to be the same
+ # (on error we want to create the file, which >> conveniently does)
+ #
+ # We search for both kinds of line here just to do the right thing in more cases.
+ if ! grep -F "$_robust_line" "$_target" > /dev/null 2>/dev/null && \
+ ! grep -F "$_pretty_line" "$_target" > /dev/null 2>/dev/null
+ then
+ # If the script now exists, add the line to source it to the rcfile
+ # (This will also create the rcfile if it doesn't exist)
+ if [ -f "$_env_script_path" ]; then
+ local _line
+ # Fish has deprecated `.` as an alias for `source` and
+ # it will be removed in a later version.
+ # https://fishshell.com/docs/current/cmds/source.html
+ # By contrast, `.` is the traditional syntax in sh and
+ # `source` isn't always supported in all circumstances.
+ if [ "$_shell" = "fish" ]; then
+ _line="$_pretty_line"
+ else
+ _line="$_robust_line"
+ fi
+ say_verbose "adding $_line to $_target"
+ # prepend an extra newline in case the user's file is missing a trailing one
+ ensure echo "" >> "$_target"
+ ensure echo "$_line" >> "$_target"
+ return 1
+ fi
+ else
+ say_verbose "$_install_dir already on PATH"
+ fi
+ fi
+}
+
+shotgun_install_dir_to_path() {
+ # Edit rcfiles ($HOME/.profile) to add install_dir to $PATH
+ # (Shotgun edition - write to all provided files that exist rather than just the first)
+ local _install_dir_expr="$1"
+ local _env_script_path="$2"
+ local _env_script_path_expr="$3"
+ local _rcfiles="$4"
+ local _shell="$5"
+
+ if [ -n "${INFERRED_HOME:-}" ]; then
+ local _found=false
+ local _home
+
+ for _rcfile_relative in $_rcfiles; do
+ _home="$(print_home_for_script "$_rcfile_relative")"
+ local _rcfile_abs="$_home/$_rcfile_relative"
+
+ if [ -f "$_rcfile_abs" ]; then
+ _found=true
+ add_install_dir_to_path "$_install_dir_expr" "$_env_script_path" "$_env_script_path_expr" "$_rcfile_relative" "$_shell"
+ fi
+ done
+
+ # Fall through to previous "create + write to first file in list" behavior
+ if [ "$_found" = false ]; then
+ add_install_dir_to_path "$_install_dir_expr" "$_env_script_path" "$_env_script_path_expr" "$_rcfiles" "$_shell"
+ fi
+ fi
+}
+
+write_env_script_sh() {
+ # write this env script to the given path (this cat/EOF stuff is a "heredoc" string)
+ local _install_dir_expr="$1"
+ local _env_script_path="$2"
+ ensure cat < "$_env_script_path"
+#!/bin/sh
+# add binaries to PATH if they aren't added yet
+# affix colons on either side of \$PATH to simplify matching
+case ":\${PATH}:" in
+ *:"$_install_dir_expr":*)
+ ;;
+ *)
+ # Prepending path in case a system-installed binary needs to be overridden
+ export PATH="$_install_dir_expr:\$PATH"
+ ;;
+esac
+EOF
+}
+
+write_env_script_fish() {
+ # write this env script to the given path (this cat/EOF stuff is a "heredoc" string)
+ local _install_dir_expr="$1"
+ local _env_script_path="$2"
+ ensure cat < "$_env_script_path"
+if not contains "$_install_dir_expr" \$PATH
+ # Prepending path in case a system-installed binary needs to be overridden
+ set -x PATH "$_install_dir_expr" \$PATH
+end
+EOF
+}
+
+get_current_exe() {
+ # Returns the executable used for system architecture detection
+ # This is only run on Linux
+ local _current_exe
+ if test -L /proc/self/exe ; then
+ _current_exe=/proc/self/exe
+ else
+ warn "Unable to find /proc/self/exe. System architecture detection might be inaccurate."
+ if test -n "$SHELL" ; then
+ _current_exe=$SHELL
+ else
+ need_cmd /bin/sh
+ _current_exe=/bin/sh
+ fi
+ warn "Falling back to $_current_exe."
+ fi
+ echo "$_current_exe"
+}
+
+get_bitness() {
+ need_cmd head
+ # Architecture detection without dependencies beyond coreutils.
+ # ELF files start out "\x7fELF", and the following byte is
+ # 0x01 for 32-bit and
+ # 0x02 for 64-bit.
+ # The printf builtin on some shells like dash only supports octal
+ # escape sequences, so we use those.
+ local _current_exe=$1
+ local _current_exe_head
+ _current_exe_head=$(head -c 5 "$_current_exe")
+ if [ "$_current_exe_head" = "$(printf '\177ELF\001')" ]; then
+ echo 32
+ elif [ "$_current_exe_head" = "$(printf '\177ELF\002')" ]; then
+ echo 64
+ else
+ err "unknown platform bitness"
+ fi
+}
+
+is_host_amd64_elf() {
+ local _current_exe=$1
+
+ need_cmd head
+ need_cmd tail
+ # ELF e_machine detection without dependencies beyond coreutils.
+ # Two-byte field at offset 0x12 indicates the CPU,
+ # but we're interested in it being 0x3E to indicate amd64, or not that.
+ local _current_exe_machine
+ _current_exe_machine=$(head -c 19 "$_current_exe" | tail -c 1)
+ [ "$_current_exe_machine" = "$(printf '\076')" ]
+}
+
+get_endianness() {
+ local _current_exe=$1
+ local cputype=$2
+ local suffix_eb=$3
+ local suffix_el=$4
+
+ # detect endianness without od/hexdump, like get_bitness() does.
+ need_cmd head
+ need_cmd tail
+
+ local _current_exe_endianness
+ _current_exe_endianness="$(head -c 6 "$_current_exe" | tail -c 1)"
+ if [ "$_current_exe_endianness" = "$(printf '\001')" ]; then
+ echo "${cputype}${suffix_el}"
+ elif [ "$_current_exe_endianness" = "$(printf '\002')" ]; then
+ echo "${cputype}${suffix_eb}"
+ else
+ err "unknown platform endianness"
+ fi
+}
+
+# Detect the Linux/LoongArch UAPI flavor, with all errors being non-fatal.
+# Returns 0 or 234 in case of successful detection, 1 otherwise (/tmp being
+# noexec, or other causes).
+check_loongarch_uapi() {
+ need_cmd base64
+
+ local _tmp
+ if ! _tmp="$(ensure mktemp)"; then
+ return 1
+ fi
+
+ # Minimal Linux/LoongArch UAPI detection, exiting with 0 in case of
+ # upstream ("new world") UAPI, and 234 (-EINVAL truncated) in case of
+ # old-world (as deployed on several early commercial Linux distributions
+ # for LoongArch).
+ #
+ # See https://gist.github.com/xen0n/5ee04aaa6cecc5c7794b9a0c3b65fc7f for
+ # source to this helper binary.
+ ignore base64 -d > "$_tmp" <&1 | grep -q 'musl'; then
+ _clibtype="musl-dynamic"
+ else
+ # Assume all other linuxes are glibc (even if wrong, static libc fallback will apply)
+ _clibtype="gnu"
+ fi
+ fi
+
+ if [ "$_ostype" = Darwin ]; then
+ # Darwin `uname -m` can lie due to Rosetta shenanigans. If you manage to
+ # invoke a native shell binary and then a native uname binary, you can
+ # get the real answer, but that's hard to ensure, so instead we use
+ # `sysctl` (which doesn't lie) to check for the actual architecture.
+ if [ "$_cputype" = i386 ]; then
+ # Handling i386 compatibility mode in older macOS versions (<10.15)
+ # running on x86_64-based Macs.
+ # Starting from 10.15, macOS explicitly bans all i386 binaries from running.
+ # See:
+
+ # Avoid `sysctl: unknown oid` stderr output and/or non-zero exit code.
+ if sysctl hw.optional.x86_64 2> /dev/null || true | grep -q ': 1'; then
+ _cputype=x86_64
+ fi
+ elif [ "$_cputype" = x86_64 ]; then
+ # Handling x86-64 compatibility mode (a.k.a. Rosetta 2)
+ # in newer macOS versions (>=11) running on arm64-based Macs.
+ # Rosetta 2 is built exclusively for x86-64 and cannot run i386 binaries.
+
+ # Avoid `sysctl: unknown oid` stderr output and/or non-zero exit code.
+ if sysctl hw.optional.arm64 2> /dev/null || true | grep -q ': 1'; then
+ _cputype=arm64
+ fi
+ fi
+ fi
+
+ if [ "$_ostype" = SunOS ]; then
+ # Both Solaris and illumos presently announce as "SunOS" in "uname -s"
+ # so use "uname -o" to disambiguate. We use the full path to the
+ # system uname in case the user has coreutils uname first in PATH,
+ # which has historically sometimes printed the wrong value here.
+ if [ "$(/usr/bin/uname -o)" = illumos ]; then
+ _ostype=illumos
+ fi
+
+ # illumos systems have multi-arch userlands, and "uname -m" reports the
+ # machine hardware name; e.g., "i86pc" on both 32- and 64-bit x86
+ # systems. Check for the native (widest) instruction set on the
+ # running kernel:
+ if [ "$_cputype" = i86pc ]; then
+ _cputype="$(isainfo -n)"
+ fi
+ fi
+
+ local _current_exe
+ case "$_ostype" in
+
+ Android)
+ _ostype=linux-android
+ ;;
+
+ Linux)
+ _current_exe=$(get_current_exe)
+ _ostype=unknown-linux-$_clibtype
+ _bitness=$(get_bitness "$_current_exe")
+ ;;
+
+ FreeBSD)
+ _ostype=unknown-freebsd
+ ;;
+
+ NetBSD)
+ _ostype=unknown-netbsd
+ ;;
+
+ DragonFly)
+ _ostype=unknown-dragonfly
+ ;;
+
+ Darwin)
+ _ostype=apple-darwin
+ ;;
+
+ illumos)
+ _ostype=unknown-illumos
+ ;;
+
+ MINGW* | MSYS* | CYGWIN* | Windows_NT)
+ _ostype=pc-windows-gnu
+ ;;
+
+ *)
+ err "unrecognized OS type: $_ostype"
+ ;;
+
+ esac
+
+ case "$_cputype" in
+
+ i386 | i486 | i686 | i786 | x86)
+ _cputype=i686
+ ;;
+
+ xscale | arm)
+ _cputype=arm
+ if [ "$_ostype" = "linux-android" ]; then
+ _ostype=linux-androideabi
+ fi
+ ;;
+
+ armv6l)
+ _cputype=arm
+ if [ "$_ostype" = "linux-android" ]; then
+ _ostype=linux-androideabi
+ else
+ _ostype="${_ostype}eabihf"
+ fi
+ ;;
+
+ armv7l | armv8l)
+ _cputype=armv7
+ if [ "$_ostype" = "linux-android" ]; then
+ _ostype=linux-androideabi
+ else
+ _ostype="${_ostype}eabihf"
+ fi
+ ;;
+
+ aarch64 | arm64)
+ _cputype=aarch64
+ ;;
+
+ x86_64 | x86-64 | x64 | amd64)
+ _cputype=x86_64
+ ;;
+
+ mips)
+ _cputype=$(get_endianness "$_current_exe" mips '' el)
+ ;;
+
+ mips64)
+ if [ "$_bitness" -eq 64 ]; then
+ # only n64 ABI is supported for now
+ _ostype="${_ostype}abi64"
+ _cputype=$(get_endianness "$_current_exe" mips64 '' el)
+ fi
+ ;;
+
+ ppc)
+ _cputype=powerpc
+ ;;
+
+ ppc64)
+ _cputype=powerpc64
+ ;;
+
+ ppc64le)
+ _cputype=powerpc64le
+ ;;
+
+ s390x)
+ _cputype=s390x
+ ;;
+ riscv64)
+ _cputype=riscv64gc
+ ;;
+ loongarch64)
+ _cputype=loongarch64
+ ensure_loongarch_uapi
+ ;;
+ *)
+ err "unknown CPU type: $_cputype"
+
+ esac
+
+ # Detect 64-bit linux with 32-bit userland
+ if [ "${_ostype}" = unknown-linux-gnu ] && [ "${_bitness}" -eq 32 ]; then
+ case $_cputype in
+ x86_64)
+ # 32-bit executable for amd64 = x32
+ if is_host_amd64_elf "$_current_exe"; then {
+ err "x32 linux unsupported"
+ }; else
+ _cputype=i686
+ fi
+ ;;
+ mips64)
+ _cputype=$(get_endianness "$_current_exe" mips '' el)
+ ;;
+ powerpc64)
+ _cputype=powerpc
+ ;;
+ aarch64)
+ _cputype=armv7
+ if [ "$_ostype" = "linux-android" ]; then
+ _ostype=linux-androideabi
+ else
+ _ostype="${_ostype}eabihf"
+ fi
+ ;;
+ riscv64gc)
+ err "riscv64 with 32-bit userland unsupported"
+ ;;
+ esac
+ fi
+
+ # Detect armv7 but without the CPU features Rust needs in that build,
+ # and fall back to arm.
+ if [ "$_ostype" = "unknown-linux-gnueabihf" ] && [ "$_cputype" = armv7 ]; then
+ if ! (ensure grep '^Features' /proc/cpuinfo | grep -E -q 'neon|simd') ; then
+ # Either `/proc/cpuinfo` is malformed or unavailable, or
+ # at least one processor does not have NEON (which is asimd on armv8+).
+ _cputype=arm
+ fi
+ fi
+
+ _arch="${_cputype}-${_ostype}"
+
+ RETVAL="$_arch"
+}
+
+say() {
+ if [ "0" = "$PRINT_QUIET" ]; then
+ echo "$1"
+ fi
+}
+
+say_verbose() {
+ if [ "1" = "$PRINT_VERBOSE" ]; then
+ echo "$1"
+ fi
+}
+
+warn() {
+ if [ "0" = "$PRINT_QUIET" ]; then
+ local red
+ local reset
+ red=$(tput setaf 1 2>/dev/null || echo '')
+ reset=$(tput sgr0 2>/dev/null || echo '')
+ say "${red}WARN${reset}: $1" >&2
+ fi
+}
+
+err() {
+ if [ "0" = "$PRINT_QUIET" ]; then
+ local red
+ local reset
+ red=$(tput setaf 1 2>/dev/null || echo '')
+ reset=$(tput sgr0 2>/dev/null || echo '')
+ say "${red}ERROR${reset}: $1" >&2
+ fi
+ exit 1
+}
+
+need_cmd() {
+ if ! check_cmd "$1"
+ then err "need '$1' (command not found)"
+ fi
+}
+
+check_cmd() {
+ command -v "$1" > /dev/null 2>&1
+ return $?
+}
+
+assert_nz() {
+ if [ -z "$1" ]; then err "assert_nz $2"; fi
+}
+
+# Run a command that should never fail. If the command fails execution
+# will immediately terminate with an error showing the failing
+# command.
+ensure() {
+ if ! "$@"; then err "command failed: $*"; fi
+}
+
+# This is just for indicating that commands' results are being
+# intentionally ignored. Usually, because it's being executed
+# as part of error handling.
+ignore() {
+ "$@"
+}
+
+# This wraps curl or wget. Try curl first, if not installed,
+# use wget instead.
+downloader() {
+ # Check if we have a broken snap curl
+ # https://github.com/boukendesho/curl-snap/issues/1
+ _snap_curl=0
+ if command -v curl > /dev/null 2>&1; then
+ _curl_path=$(command -v curl)
+ if echo "$_curl_path" | grep "/snap/" > /dev/null 2>&1; then
+ _snap_curl=1
+ fi
+ fi
+
+ # Check if we have a working (non-snap) curl
+ if check_cmd curl && [ "$_snap_curl" = "0" ]
+ then _dld=curl
+ # Try wget for both no curl and the broken snap curl
+ elif check_cmd wget
+ then _dld=wget
+ # If we can't fall back from broken snap curl to wget, report the broken snap curl
+ elif [ "$_snap_curl" = "1" ]
+ then
+ say "curl installed with snap cannot be used to install $APP_NAME"
+ say "due to missing permissions. Please uninstall it and"
+ say "reinstall curl with a different package manager (e.g., apt)."
+ say "See https://github.com/boukendesho/curl-snap/issues/1"
+ exit 1
+ else _dld='curl or wget' # to be used in error message of need_cmd
+ fi
+
+ if [ "$1" = --check ]
+ then need_cmd "$_dld"
+ elif [ "$_dld" = curl ]; then
+ if [ -n "${AUTH_TOKEN:-}" ]; then
+ curl -sSfL --header "Authorization: Bearer ${AUTH_TOKEN}" "$1" -o "$2"
+ else
+ curl -sSfL "$1" -o "$2"
+ fi
+ elif [ "$_dld" = wget ]; then
+ if [ -n "${AUTH_TOKEN:-}" ]; then
+ wget --header "Authorization: Bearer ${AUTH_TOKEN}" "$1" -O "$2"
+ else
+ wget "$1" -O "$2"
+ fi
+ else err "Unknown downloader" # should not reach here
+ fi
+}
+
+verify_checksum() {
+ local _file="$1"
+ local _checksum_style="$2"
+ local _checksum_value="$3"
+ local _calculated_checksum
+
+ if [ -z "$_checksum_value" ]; then
+ return 0
+ fi
+ case "$_checksum_style" in
+ sha256)
+ if ! check_cmd sha256sum; then
+ say "skipping sha256 checksum verification (it requires the 'sha256sum' command)"
+ return 0
+ fi
+ _calculated_checksum="$(sha256sum -b "$_file" | awk '{printf $1}')"
+ ;;
+ sha512)
+ if ! check_cmd sha512sum; then
+ say "skipping sha512 checksum verification (it requires the 'sha512sum' command)"
+ return 0
+ fi
+ _calculated_checksum="$(sha512sum -b "$_file" | awk '{printf $1}')"
+ ;;
+ sha3-256)
+ if ! check_cmd openssl; then
+ say "skipping sha3-256 checksum verification (it requires the 'openssl' command)"
+ return 0
+ fi
+ _calculated_checksum="$(openssl dgst -sha3-256 "$_file" | awk '{printf $NF}')"
+ ;;
+ sha3-512)
+ if ! check_cmd openssl; then
+ say "skipping sha3-512 checksum verification (it requires the 'openssl' command)"
+ return 0
+ fi
+ _calculated_checksum="$(openssl dgst -sha3-512 "$_file" | awk '{printf $NF}')"
+ ;;
+ blake2s)
+ if ! check_cmd b2sum; then
+ say "skipping blake2s checksum verification (it requires the 'b2sum' command)"
+ return 0
+ fi
+ # Test if we have official b2sum with blake2s support
+ local _well_known_blake2s_checksum="93314a61f470985a40f8da62df10ba0546dc5216e1d45847bf1dbaa42a0e97af" # pragma: allowlist secret
+ local _test_blake2s
+ _test_blake2s="$(printf "can do blake2s" | b2sum -a blake2s | awk '{printf $1}')" || _test_blake2s=""
+
+ if [ "X$_test_blake2s" = "X$_well_known_blake2s_checksum" ]; then
+ _calculated_checksum="$(b2sum -a blake2s "$_file" | awk '{printf $1}')" || _calculated_checksum=""
+ else
+ say "skipping blake2s checksum verification (installed b2sum doesn't support blake2s)"
+ return 0
+ fi
+ ;;
+ blake2b)
+ if ! check_cmd b2sum; then
+ say "skipping blake2b checksum verification (it requires the 'b2sum' command)"
+ return 0
+ fi
+ _calculated_checksum="$(b2sum "$_file" | awk '{printf $1}')"
+ ;;
+ false)
+ ;;
+ *)
+ say "skipping unknown checksum style: $_checksum_style"
+ return 0
+ ;;
+ esac
+
+ if [ "$_calculated_checksum" != "$_checksum_value" ]; then
+ err "checksum mismatch
+ want: $_checksum_value
+ got: $_calculated_checksum"
+ fi
+}
+
+download_binary_and_run_installer "$@" || exit 1
diff --git a/mcp_server_deployer/src/lib/scripts/stdio-s3-entrypoint.sh b/mcp_server_deployer/src/lib/scripts/stdio-s3-entrypoint.sh
index 81776a8f2..ceff1abcb 100644
--- a/mcp_server_deployer/src/lib/scripts/stdio-s3-entrypoint.sh
+++ b/mcp_server_deployer/src/lib/scripts/stdio-s3-entrypoint.sh
@@ -14,7 +14,15 @@ if ! command -v mcp-proxy >/dev/null 2>&1 && [ ! -f /root/.local/bin/mcp-proxy ]
if ! command -v nodejs >/dev/null 2>&1; then
apt-get update && apt-get install -y --no-install-recommends nodejs npm && apt-get clean && rm -rf /var/lib/apt/lists/*
fi
- curl -LsSf https://astral.sh/uv/install.sh | sh || true
+ # Use local copy of astral install script instead of curling from internet
+ SCRIPT_DIR="$(dirname "$0")"
+ if [ -f "$SCRIPT_DIR/astral-install.sh" ]; then
+ sh "$SCRIPT_DIR/astral-install.sh" || true
+ elif [ -f /app/scripts/astral-install.sh ]; then
+ sh /app/scripts/astral-install.sh || true
+ else
+ echo "WARNING: astral-install.sh not found, uv installation may fail"
+ fi
export PATH="/root/.local/bin:$PATH"
/root/.local/bin/uv tool install mcp-proxy || true
fi
@@ -44,5 +52,13 @@ elif command -v mcp-proxy >/dev/null 2>&1; then
eval exec mcp-proxy --stateless --transport streamablehttp --port=8080 --host=0.0.0.0 --allow-origin="*" "$START_COMMAND"
else
echo "ERROR: mcp-proxy not found. Attempting to install..."
- curl -LsSf https://astral.sh/uv/install.sh | sh && /root/.local/bin/uv tool install mcp-proxy && eval exec /root/.local/bin/mcp-proxy --stateless --transport streamablehttp --port=8080 --host=0.0.0.0 --allow-origin="*" "$START_COMMAND"
+ SCRIPT_DIR="$(dirname "$0")"
+ if [ -f "$SCRIPT_DIR/astral-install.sh" ]; then
+ sh "$SCRIPT_DIR/astral-install.sh" && /root/.local/bin/uv tool install mcp-proxy && eval exec /root/.local/bin/mcp-proxy --stateless --transport streamablehttp --port=8080 --host=0.0.0.0 --allow-origin="*" "$START_COMMAND"
+ elif [ -f /app/scripts/astral-install.sh ]; then
+ sh /app/scripts/astral-install.sh && /root/.local/bin/uv tool install mcp-proxy && eval exec /root/.local/bin/mcp-proxy --stateless --transport streamablehttp --port=8080 --host=0.0.0.0 --allow-origin="*" "$START_COMMAND"
+ else
+ echo "ERROR: astral-install.sh not found, cannot install uv/mcp-proxy"
+ exit 1
+ fi
fi
diff --git a/package-lock.json b/package-lock.json
index 9df597399..6c26021ff 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,13 @@
{
"name": "@awslabs/lisa",
- "version": "6.1.1",
+ "version": "6.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@awslabs/lisa",
- "version": "6.1.1",
+ "version": "6.2.0",
+ "hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
"lib/user-interface/react",
@@ -18,7 +19,8 @@
"cypress"
],
"dependencies": {
- "aws-cdk-lib": "^2.232.1",
+ "@aws-sdk/util-dynamodb": "^3.948.0",
+ "aws-cdk-lib": "^2.236.0",
"aws-sdk": "^2.1693.0",
"cdk-ecr-deployment": "^4.0.5",
"cdk-nag": "^2.37.55",
@@ -30,7 +32,7 @@
"zod": "^4.1.13"
},
"devDependencies": {
- "@aws-cdk/aws-lambda-python-alpha": "2.232.1-alpha.0",
+ "@aws-cdk/aws-lambda-python-alpha": "^2.236.0-alpha.0",
"@aws-sdk/client-iam": "^3.948.0",
"@aws-sdk/client-ssm": "^3.948.0",
"@cdklabs/cdk-enterprise-iac": "^0.1.0",
@@ -44,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.1033.0",
+ "aws-cdk": "^2.1103.0",
"depcheck": "^1.4.7",
"esbuild": "^0.27.1",
"eslint": "^9.39.1",
@@ -100,6 +102,7 @@
"dependencies": {
"aws-cdk": "^2.1033.0",
"aws-cdk-lib": "^2.232.1",
+ "constructs": "^10.4.3",
"zod": "^4.1.13"
}
},
@@ -114,7 +117,7 @@
},
"lib/user-interface/react": {
"name": "lisa-web",
- "version": "6.1.1",
+ "version": "6.2.0",
"dependencies": {
"@cloudscape-design/chat-components": "^1.0.77",
"@cloudscape-design/collection-hooks": "^1.0.78",
@@ -126,7 +129,7 @@
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.1",
"@langchain/core": "^1.1.4",
- "@langchain/openai": "^1.1.3",
+ "@langchain/openai": "1.2.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@reduxjs/toolkit": "^2.11.1",
"@swc/core": "^1.15.3",
@@ -141,6 +144,7 @@
"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",
@@ -173,7 +177,7 @@
"@types/ace": "^0.0.52",
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.10.2",
- "@types/react": "^19.2.7",
+ "@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@types/redux-mock-store": "^1.5.0",
"@types/redux-persist": "^4.3.1",
@@ -193,6 +197,8 @@
"jsdom": "^27.3.0",
"linkify-it": "^5.0.0",
"markdown-it": "^14.1.0",
+ "patch-package": "^8.0.1",
+ "postinstall-postinstall": "^2.1.0",
"prettier": "^3.7.4",
"redux-mock-store": "^1.5.5",
"uuid": "^13.0.0",
@@ -200,6 +206,38 @@
"vitest": "^4.0.15"
}
},
+ "lib/user-interface/react/node_modules/@tailwindcss/vite": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+ "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "tailwindcss": "4.1.18"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "lib/user-interface/react/node_modules/@vitejs/plugin-react-swc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz",
+ "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-beta.47",
+ "@swc/core": "^1.13.5"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5 || ^6 || ^7"
+ }
+ },
"lib/user-interface/react/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -217,8 +255,6 @@
},
"lib/user-interface/react/node_modules/uuid": {
"version": "13.0.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
- "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
@@ -231,8 +267,6 @@
},
"lib/user-interface/react/node_modules/vite": {
"version": "7.3.0",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
- "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -318,9 +352,9 @@
}
},
"node_modules/@acemir/cssom": {
- "version": "0.9.29",
- "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz",
- "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==",
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
"dev": true,
"license": "MIT"
},
@@ -332,15 +366,15 @@
"license": "MIT"
},
"node_modules/@algolia/abtesting": {
- "version": "1.12.1",
- "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.12.1.tgz",
- "integrity": "sha512-Y+7e2uPe376OH5O73OB1+vR40ZhbV2kzGh/AR/dPCWguoBOp1IK0o+uZQLX+7i32RMMBEKl3pj6KVEav100Kvg==",
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.13.0.tgz",
+ "integrity": "sha512-Zrqam12iorp3FjiKMXSTpedGYznZ3hTEOAr2oCxI8tbF8bS1kQHClyDYNq/eV0ewMNLyFkgZVWjaS+8spsOYiQ==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
@@ -392,181 +426,180 @@
}
},
"node_modules/@algolia/client-abtesting": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.46.1.tgz",
- "integrity": "sha512-5SWfl0UGuKxMBYlU2Y9BnlIKKEyhFU5jHE9F9jAd8nbhxZNLk0y7fXE+AZeFtyK1lkVw6O4B/e6c3XIVVCkmqw==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.47.0.tgz",
+ "integrity": "sha512-aOpsdlgS9xTEvz47+nXmw8m0NtUiQbvGWNuSEb7fA46iPL5FxOmOUZkh8PREBJpZ0/H8fclSc7BMJCVr+Dn72w==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-analytics": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.46.1.tgz",
- "integrity": "sha512-496K6B1l/0Jvyp3MbW/YIgmm1a6nkTrKXBM7DoEy9YAOJ8GywGpa2UYjNCW1UrOTt+em1ECzDjRx7PIzTR9YvA==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.47.0.tgz",
+ "integrity": "sha512-EcF4w7IvIk1sowrO7Pdy4Ako7x/S8+nuCgdk6En+u5jsaNQM4rTT09zjBPA+WQphXkA2mLrsMwge96rf6i7Mow==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-common": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.46.1.tgz",
- "integrity": "sha512-3u6AuZ1Kiss6V5JPuZfVIUYfPi8im06QBCgKqLg82GUBJ3SwhiTdSZFIEgz2mzFuitFdW1PQi3c/65zE/3FgIw==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.47.0.tgz",
+ "integrity": "sha512-Wzg5Me2FqgRDj0lFuPWFK05UOWccSMsIBL2YqmTmaOzxVlLZ+oUqvKbsUSOE5ud8Fo1JU7JyiLmEXBtgDKzTwg==",
"license": "MIT",
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-insights": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.46.1.tgz",
- "integrity": "sha512-LwuWjdO35HHl1rxtdn48t920Xl26Dl0SMxjxjFeAK/OwK/pIVfYjOZl/f3Pnm7Kixze+6HjpByVxEaqhTuAFaw==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.47.0.tgz",
+ "integrity": "sha512-Ci+cn/FDIsDxSKMRBEiyKrqybblbk8xugo6ujDN1GSTv9RIZxwxqZYuHfdLnLEwLlX7GB8pqVyqrUSlRnR+sJA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-personalization": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.46.1.tgz",
- "integrity": "sha512-6LvJAlfEsn9SVq63MYAFX2iUxztUK2Q7BVZtI1vN87lDiJ/tSVFKgKS/jBVO03A39ePxJQiFv6EKv7lmoGlWtQ==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.47.0.tgz",
+ "integrity": "sha512-gsLnHPZmWcX0T3IigkDL2imCNtsQ7dR5xfnwiFsb+uTHCuYQt+IwSNjsd8tok6HLGLzZrliSaXtB5mfGBtYZvQ==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-query-suggestions": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.46.1.tgz",
- "integrity": "sha512-9GLUCyGGo7YOXHcNqbzca82XYHJTbuiI6iT0FTGc0BrnV2N4OcrznUuVKic/duiLSun5gcy/G2Bciw5Sav9f9w==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.47.0.tgz",
+ "integrity": "sha512-PDOw0s8WSlR2fWFjPQldEpmm/gAoUgLigvC3k/jCSi/DzigdGX6RdC0Gh1RR1P8Cbk5KOWYDuL3TNzdYwkfDyA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-search": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.46.1.tgz",
- "integrity": "sha512-NL76o/BoEgU4ObY5oBEC3o6KSPpuXsnSta00tAxTm1iKUWOGR34DQEKhUt8xMHhMKleUNPM/rLPFiIVtfsGU8w==",
+ "version": "5.47.0",
+ "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.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/ingestion": {
- "version": "1.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.46.1.tgz",
- "integrity": "sha512-52Nc8WKC1FFXsdlXlTMl1Re/pTAbd2DiJiNdYmgHiikZcfF96G+Opx4qKiLUG1q7zp9e+ahNwXF6ED0XChMywg==",
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.47.0.tgz",
+ "integrity": "sha512-WvwwXp5+LqIGISK3zHRApLT1xkuEk320/EGeD7uYy+K8WwDd5OjXnhjuXRhYr1685KnkvWkq1rQ/ihCJjOfHpQ==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/monitoring": {
- "version": "1.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.46.1.tgz",
- "integrity": "sha512-1x2/2Y/eqz6l3QcEZ8u/zMhSCpjlhePyizJd3sXrmg031HjayYT5+IxikjpqkdF7TU/deCTd/TFUcxLJ2ZHXiQ==",
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.47.0.tgz",
+ "integrity": "sha512-j2EUFKAlzM0TE4GRfkDE3IDfkVeJdcbBANWzK16Tb3RHz87WuDfQ9oeEW6XiRE1/bEkq2xf4MvZesvSeQrZRDA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/recommend": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.46.1.tgz",
- "integrity": "sha512-SSd3KlQuplxV3aRs5+Z09XilFesgpPjtCG7BGRxLTVje5hn9BLmhjO4W3gKw01INUt44Z1r0Fwx5uqnhAouunA==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.47.0.tgz",
+ "integrity": "sha512-+kTSE4aQ1ARj2feXyN+DMq0CIDHJwZw1kpxIunedkmpWUg8k3TzFwWsMCzJVkF2nu1UcFbl7xsIURz3Q3XwOXA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "@algolia/client-common": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-browser-xhr": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.46.1.tgz",
- "integrity": "sha512-3GfCwudeW6/3caKSdmOP6RXZEL4F3GiemCaXEStkTt2Re8f7NcGYAAZnGlHsCzvhlNEuDzPYdYxh4UweY8l/2w==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.47.0.tgz",
+ "integrity": "sha512-Ja+zPoeSA2SDowPwCNRbm5Q2mzDvVV8oqxCQ4m6SNmbKmPlCfe30zPfrt9ho3kBHnsg37pGucwOedRIOIklCHw==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1"
+ "@algolia/client-common": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-fetch": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.46.1.tgz",
- "integrity": "sha512-JUAxYfmnLYTVtAOFxVvXJ4GDHIhMuaP7JGyZXa/nCk3P8RrN5FCNTdRyftSnxyzwSIAd8qH3CjdBS9WwxxqcHQ==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.47.0.tgz",
+ "integrity": "sha512-N6nOvLbaR4Ge+oVm7T4W/ea1PqcSbsHR4O58FJ31XtZjFPtOyxmnhgCmGCzP9hsJI6+x0yxJjkW5BMK/XI8OvA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1"
+ "@algolia/client-common": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-node-http": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.46.1.tgz",
- "integrity": "sha512-VwbhV1xvTGiek3d2pOS6vNBC4dtbNadyRT+i1niZpGhOJWz1XnfhxNboVbXPGAyMJYz7kDrolbDvEzIDT93uUA==",
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.47.0.tgz",
+ "integrity": "sha512-z1oyLq5/UVkohVXNDEY70mJbT/sv/t6HYtCvCwNrOri6pxBJDomP9R83KOlwcat+xqBQEdJHjbrPh36f1avmZA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.46.1"
+ "@algolia/client-common": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
@@ -641,9 +674,9 @@
"license": "MIT"
},
"node_modules/@aws-cdk/asset-awscli-v1": {
- "version": "2.2.242",
- "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz",
- "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==",
+ "version": "2.2.263",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.263.tgz",
+ "integrity": "sha512-X9JvcJhYcb7PHs8R7m4zMablO5C9PGb/hYfLnxds9h/rKJu6l7MiXE/SabCibuehxPnuO/vk+sVVJiUWrccarQ==",
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
@@ -653,16 +686,16 @@
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/aws-lambda-python-alpha": {
- "version": "2.232.1-alpha.0",
- "resolved": "https://registry.npmjs.org/@aws-cdk/aws-lambda-python-alpha/-/aws-lambda-python-alpha-2.232.1-alpha.0.tgz",
- "integrity": "sha512-r9okCgLbEadLdeCM7kz9Q44lcLRzGtll0XymrGPX0DCQSbd2a4ffr+/o9+GXSCeaQ0wc7MO/+44G+n08fuoJyg==",
+ "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==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
- "aws-cdk-lib": "^2.232.1",
+ "aws-cdk-lib": "^2.236.0",
"constructs": "^10.0.0"
}
},
@@ -706,7 +739,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-js": "^5.2.0",
@@ -722,7 +754,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -735,7 +766,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
@@ -749,7 +779,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
@@ -763,7 +792,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/util": "^5.2.0",
@@ -778,7 +806,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -788,7 +815,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.222.0",
@@ -800,7 +826,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -813,7 +838,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^2.2.0",
@@ -827,7 +851,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^2.2.0",
@@ -837,576 +860,689 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@aws-sdk/client-dynamodb": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.974.0.tgz",
+ "integrity": "sha512-Nw4/sL0IbBMvQdBvLlnBTFBrG+Aqg22ewMVJWOXSVdktfomtpPJ+jpJXBMJDMF9LsrgTNF/zsSVRJSEHYlBkOA==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-node": "^3.972.1",
+ "@aws-sdk/dynamodb-codec": "^3.972.1",
+ "@aws-sdk/middleware-endpoint-discovery": "^3.972.1",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@aws-sdk/client-iam": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.954.0.tgz",
- "integrity": "sha512-7nDZzGEZXrm03RTtEYOylSRVCch76wn3FSvOqE+V62656nKHZUFPnQuhQqxrmMzVta6/4fWowshDd89JE5+W2A==",
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.974.0.tgz",
+ "integrity": "sha512-Bcwdt12/ksaF/T7TAqDKivrZEc04Viq7Sl6TM0avyDQknPjz2XDW/XxfUEN8WNDYR4Kt7jwz5p5M1CSb7AfdmA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/credential-provider-node": "3.954.0",
- "@aws-sdk/middleware-host-header": "3.953.0",
- "@aws-sdk/middleware-logger": "3.953.0",
- "@aws-sdk/middleware-recursion-detection": "3.953.0",
- "@aws-sdk/middleware-user-agent": "3.954.0",
- "@aws-sdk/region-config-resolver": "3.953.0",
- "@aws-sdk/types": "3.953.0",
- "@aws-sdk/util-endpoints": "3.953.0",
- "@aws-sdk/util-user-agent-browser": "3.953.0",
- "@aws-sdk/util-user-agent-node": "3.954.0",
- "@smithy/config-resolver": "^4.4.4",
- "@smithy/core": "^3.19.0",
- "@smithy/fetch-http-handler": "^5.3.7",
- "@smithy/hash-node": "^4.2.6",
- "@smithy/invalid-dependency": "^4.2.6",
- "@smithy/middleware-content-length": "^4.2.6",
- "@smithy/middleware-endpoint": "^4.4.0",
- "@smithy/middleware-retry": "^4.4.16",
- "@smithy/middleware-serde": "^4.2.7",
- "@smithy/middleware-stack": "^4.2.6",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/node-http-handler": "^4.4.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-node": "^3.972.1",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
- "@smithy/util-defaults-mode-browser": "^4.3.15",
- "@smithy/util-defaults-mode-node": "^4.2.18",
- "@smithy/util-endpoints": "^3.2.6",
- "@smithy/util-middleware": "^4.2.6",
- "@smithy/util-retry": "^4.2.6",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
- "@smithy/util-waiter": "^4.2.6",
+ "@smithy/util-waiter": "^4.2.8",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-ssm": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.954.0.tgz",
- "integrity": "sha512-gDIhehDMBrijH33wYROALwYsG5igOHUZwglXZBOpZ/9f2cNabMua6igKQmfkv+AmBwN494xmqlK4neMO2JjPbw==",
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.974.0.tgz",
+ "integrity": "sha512-zJi4uwU74P0HNwtFcGbWRF+BHYbdXlIWfKq0qu4Ds+PWq5HIXiS+zMydI8GfRgsVjm7otiCblYByJ9B/RmeMtA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/credential-provider-node": "3.954.0",
- "@aws-sdk/middleware-host-header": "3.953.0",
- "@aws-sdk/middleware-logger": "3.953.0",
- "@aws-sdk/middleware-recursion-detection": "3.953.0",
- "@aws-sdk/middleware-user-agent": "3.954.0",
- "@aws-sdk/region-config-resolver": "3.953.0",
- "@aws-sdk/types": "3.953.0",
- "@aws-sdk/util-endpoints": "3.953.0",
- "@aws-sdk/util-user-agent-browser": "3.953.0",
- "@aws-sdk/util-user-agent-node": "3.954.0",
- "@smithy/config-resolver": "^4.4.4",
- "@smithy/core": "^3.19.0",
- "@smithy/fetch-http-handler": "^5.3.7",
- "@smithy/hash-node": "^4.2.6",
- "@smithy/invalid-dependency": "^4.2.6",
- "@smithy/middleware-content-length": "^4.2.6",
- "@smithy/middleware-endpoint": "^4.4.0",
- "@smithy/middleware-retry": "^4.4.16",
- "@smithy/middleware-serde": "^4.2.7",
- "@smithy/middleware-stack": "^4.2.6",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/node-http-handler": "^4.4.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-node": "^3.972.1",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
- "@smithy/util-defaults-mode-browser": "^4.3.15",
- "@smithy/util-defaults-mode-node": "^4.2.18",
- "@smithy/util-endpoints": "^3.2.6",
- "@smithy/util-middleware": "^4.2.6",
- "@smithy/util-retry": "^4.2.6",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
- "@smithy/util-waiter": "^4.2.6",
+ "@smithy/util-waiter": "^4.2.8",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-sso": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.954.0.tgz",
- "integrity": "sha512-FVyMAvlFhLK68DHWB1lSkCRTm25xl38bIZDd+jKt5+yDolCrG5+n9aIN8AA8jNO1HNGhZuMjSIQm9r5rGmJH8g==",
- "dev": true,
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.974.0.tgz",
+ "integrity": "sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/middleware-host-header": "3.953.0",
- "@aws-sdk/middleware-logger": "3.953.0",
- "@aws-sdk/middleware-recursion-detection": "3.953.0",
- "@aws-sdk/middleware-user-agent": "3.954.0",
- "@aws-sdk/region-config-resolver": "3.953.0",
- "@aws-sdk/types": "3.953.0",
- "@aws-sdk/util-endpoints": "3.953.0",
- "@aws-sdk/util-user-agent-browser": "3.953.0",
- "@aws-sdk/util-user-agent-node": "3.954.0",
- "@smithy/config-resolver": "^4.4.4",
- "@smithy/core": "^3.19.0",
- "@smithy/fetch-http-handler": "^5.3.7",
- "@smithy/hash-node": "^4.2.6",
- "@smithy/invalid-dependency": "^4.2.6",
- "@smithy/middleware-content-length": "^4.2.6",
- "@smithy/middleware-endpoint": "^4.4.0",
- "@smithy/middleware-retry": "^4.4.16",
- "@smithy/middleware-serde": "^4.2.7",
- "@smithy/middleware-stack": "^4.2.6",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/node-http-handler": "^4.4.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
- "@smithy/util-defaults-mode-browser": "^4.3.15",
- "@smithy/util-defaults-mode-node": "^4.2.18",
- "@smithy/util-endpoints": "^3.2.6",
- "@smithy/util-middleware": "^4.2.6",
- "@smithy/util-retry": "^4.2.6",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.954.0.tgz",
- "integrity": "sha512-5oYO5RP+mvCNXNj8XnF9jZo0EP0LTseYOJVNQYcii1D9DJqzHL3HJWurYh7cXxz7G7eDyvVYA01O9Xpt34TdoA==",
- "dev": true,
+ "version": "3.973.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.0.tgz",
+ "integrity": "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
- "@aws-sdk/xml-builder": "3.953.0",
- "@smithy/core": "^3.19.0",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/signature-v4": "^5.3.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/xml-builder": "^3.972.1",
+ "@smithy/core": "^3.21.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
"@smithy/util-base64": "^4.3.0",
- "@smithy/util-middleware": "^4.2.6",
+ "@smithy/util-middleware": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.954.0.tgz",
- "integrity": "sha512-2HNkqBjfsvyoRuPAiFh86JBFMFyaCNhL4VyH6XqwTGKZffjG7hdBmzXPy7AT7G3oFh1k/1Zc27v0qxaKoK7mBA==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.1.tgz",
+ "integrity": "sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.954.0.tgz",
- "integrity": "sha512-CrWD5300+NE1OYRnSVDxoG7G0b5cLIZb7yp+rNQ5Jq/kqnTmyJXpVAsivq+bQIDaGzPXhadzpAMIoo7K/aHaag==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.1.tgz",
+ "integrity": "sha512-AeopObGW5lpWbDRZ+t4EAtS7wdfSrHPLeFts7jaBzgIaCCD7TL7jAyAB9Y5bCLOPF+17+GL54djCCsjePljUAw==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/fetch-http-handler": "^5.3.7",
- "@smithy/node-http-handler": "^4.4.6",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
- "@smithy/util-stream": "^4.5.7",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.10",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.954.0.tgz",
- "integrity": "sha512-WAFD8pVwRSoBsuXcoD+s/hrdsP9Z0PNUedSgkOGExuJVAabpM2cIIMzYNsdHio9XFZUSqHkv8mF5mQXuIZvuzg==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.1.tgz",
+ "integrity": "sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/credential-provider-env": "3.954.0",
- "@aws-sdk/credential-provider-http": "3.954.0",
- "@aws-sdk/credential-provider-login": "3.954.0",
- "@aws-sdk/credential-provider-process": "3.954.0",
- "@aws-sdk/credential-provider-sso": "3.954.0",
- "@aws-sdk/credential-provider-web-identity": "3.954.0",
- "@aws-sdk/nested-clients": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/credential-provider-imds": "^4.2.6",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-env": "^3.972.1",
+ "@aws-sdk/credential-provider-http": "^3.972.1",
+ "@aws-sdk/credential-provider-login": "^3.972.1",
+ "@aws-sdk/credential-provider-process": "^3.972.1",
+ "@aws-sdk/credential-provider-sso": "^3.972.1",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.1",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.954.0.tgz",
- "integrity": "sha512-EYqaBWwdVbVK7prmsmgTWLPptoWREplPkFMFscOpVmseDvf/0IjYNbNLLtfuhy/6L7ZBGI9wat2k4u0MRivvxA==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.1.tgz",
+ "integrity": "sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/nested-clients": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.954.0.tgz",
- "integrity": "sha512-UPBjw7Lnly5i+/rES8Z5U+nPaumzEUYOE/wrHkxyH6JjwFWn8w7R07fE5Z5cgYlIq1U1lQ7sxYwB3wHPpQ65Aw==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.1.tgz",
+ "integrity": "sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/credential-provider-env": "3.954.0",
- "@aws-sdk/credential-provider-http": "3.954.0",
- "@aws-sdk/credential-provider-ini": "3.954.0",
- "@aws-sdk/credential-provider-process": "3.954.0",
- "@aws-sdk/credential-provider-sso": "3.954.0",
- "@aws-sdk/credential-provider-web-identity": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/credential-provider-imds": "^4.2.6",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/credential-provider-env": "^3.972.1",
+ "@aws-sdk/credential-provider-http": "^3.972.1",
+ "@aws-sdk/credential-provider-ini": "^3.972.1",
+ "@aws-sdk/credential-provider-process": "^3.972.1",
+ "@aws-sdk/credential-provider-sso": "^3.972.1",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.954.0.tgz",
- "integrity": "sha512-Y1/0O2LgbKM8iIgcVj/GNEQW6p90LVTCOzF2CI1pouoKqxmZ/1F7F66WHoa6XUOfKaCRj/R6nuMR3om9ThaM5A==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.1.tgz",
+ "integrity": "sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.954.0.tgz",
- "integrity": "sha512-UXxGfkp/plFRdyidMLvNul5zoLKmHhVQOCrD2OgR/lg9jNqNmJ7abF+Qu8abo902iDkhU21Qj4M398cx6l8Kng==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.1.tgz",
+ "integrity": "sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/client-sso": "3.954.0",
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/token-providers": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/client-sso": "3.974.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/token-providers": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.954.0.tgz",
- "integrity": "sha512-XEyf1T08q1tG4zkTS4Dnf1cAQyrJUo/xlvi6XNpqGhY3bOmKUYE2h/K6eITIdytDL9VuCpWYQ6YRcIVtL29E0w==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.1.tgz",
+ "integrity": "sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/nested-clients": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/dynamodb-codec": {
+ "version": "3.972.1",
+ "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",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-dynamodb": "3.974.0"
+ }
+ },
+ "node_modules/@aws-sdk/endpoint-cache": {
+ "version": "3.972.1",
+ "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"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-endpoint-discovery": {
+ "version": "3.972.1",
+ "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",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-host-header": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.953.0.tgz",
- "integrity": "sha512-jTGhfkONav+r4E6HLOrl5SzBqDmPByUYCkyB/c/3TVb8jX3wAZx8/q9bphKpCh+G5ARi3IdbSisgkZrJYqQ19Q==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.1.tgz",
+ "integrity": "sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-logger": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.953.0.tgz",
- "integrity": "sha512-PlWdVYgcuptkIC0ZKqVUhWNtSHXJSx7U9V8J7dJjRmsXC40X7zpEycvrkzDMJjeTDGcCceYbyYAg/4X1lkcIMw==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.1.tgz",
+ "integrity": "sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-recursion-detection": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.953.0.tgz",
- "integrity": "sha512-cmIJx0gWeesUKK4YwgE+VQL3mpACr3/J24fbwnc1Z5tntC86b+HQFzU5vsBDw6lLwyD46dBgWdsXFh1jL+ZaFw==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.1.tgz",
+ "integrity": "sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
+ "@aws-sdk/types": "^3.973.0",
"@aws/lambda-invoke-store": "^0.2.2",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.954.0.tgz",
- "integrity": "sha512-5PX8JDe3dB2+MqXeGIhmgFnm2rbVsSxhz+Xyuu1oxLtbOn+a9UDA+sNBufEBjt3UxWy5qwEEY1fxdbXXayjlGg==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.1.tgz",
+ "integrity": "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@aws-sdk/util-endpoints": "3.953.0",
- "@smithy/core": "^3.19.0",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@smithy/core": "^3.21.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.954.0.tgz",
- "integrity": "sha512-JLUhf35fTQIDPLk6G5KPggL9tV//Hjhy6+N2zZeis76LuBRNhKDq8z1CFyKhjf00vXi/tDYdn9D7y9emI+5Y/g==",
- "dev": true,
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.974.0.tgz",
+ "integrity": "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/middleware-host-header": "3.953.0",
- "@aws-sdk/middleware-logger": "3.953.0",
- "@aws-sdk/middleware-recursion-detection": "3.953.0",
- "@aws-sdk/middleware-user-agent": "3.954.0",
- "@aws-sdk/region-config-resolver": "3.953.0",
- "@aws-sdk/types": "3.953.0",
- "@aws-sdk/util-endpoints": "3.953.0",
- "@aws-sdk/util-user-agent-browser": "3.953.0",
- "@aws-sdk/util-user-agent-node": "3.954.0",
- "@smithy/config-resolver": "^4.4.4",
- "@smithy/core": "^3.19.0",
- "@smithy/fetch-http-handler": "^5.3.7",
- "@smithy/hash-node": "^4.2.6",
- "@smithy/invalid-dependency": "^4.2.6",
- "@smithy/middleware-content-length": "^4.2.6",
- "@smithy/middleware-endpoint": "^4.4.0",
- "@smithy/middleware-retry": "^4.4.16",
- "@smithy/middleware-serde": "^4.2.7",
- "@smithy/middleware-stack": "^4.2.6",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/node-http-handler": "^4.4.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
- "@smithy/util-defaults-mode-browser": "^4.3.15",
- "@smithy/util-defaults-mode-node": "^4.2.18",
- "@smithy/util-endpoints": "^3.2.6",
- "@smithy/util-middleware": "^4.2.6",
- "@smithy/util-retry": "^4.2.6",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/region-config-resolver": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.953.0.tgz",
- "integrity": "sha512-5MJgnsc+HLO+le0EK1cy92yrC7kyhGZSpaq8PcQvKs9qtXCXT5Tb6tMdkr5Y07JxYsYOV1omWBynvL6PWh08tQ==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.1.tgz",
+ "integrity": "sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
- "@smithy/config-resolver": "^4.4.4",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.954.0.tgz",
- "integrity": "sha512-rDyN3oQQKMOJgyQ9/LNbh4fAGAj8ePMGOAQzSP/kyzizmViI6STpBW1o/VRqiTgMNi1bvA9ZasDtfrJqcVt0iA==",
- "dev": true,
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.974.0.tgz",
+ "integrity": "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/core": "3.954.0",
- "@aws-sdk/nested-clients": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz",
- "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==",
- "dev": true,
+ "version": "3.973.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.0.tgz",
+ "integrity": "sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-dynamodb": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.974.0.tgz",
+ "integrity": "sha512-n/VlmXl4Yp9RS3i9+ALSr7Y+f1VrKJGX88Q8C67cg+xWzgv0n1+sE/7ZAxJZKEBNpP9tjmpgqWej8f1pWalbIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-dynamodb": "3.974.0"
}
},
"node_modules/@aws-sdk/util-endpoints": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.953.0.tgz",
- "integrity": "sha512-rjaS6jrFksopXvNg6YeN+D1lYwhcByORNlFuYesFvaQNtPOufbE5tJL4GJ3TMXyaY0uFR28N5BHHITPyWWfH/g==",
- "dev": true,
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz",
+ "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
- "@smithy/util-endpoints": "^3.2.6",
+ "@aws-sdk/types": "3.972.0",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz",
+ "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/util-locate-window": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.953.0.tgz",
- "integrity": "sha512-mPxK+I1LcrgC/RSa3G5AMAn8eN2Ay0VOgw8lSRmV1jCtO+iYvNeCqOdxoJUjOW6I5BA4niIRWqVORuRP07776Q==",
- "dev": true,
+ "version": "3.965.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.3.tgz",
+ "integrity": "sha512-FNUqAjlKAGA7GM05kywE99q8wiPHPZqrzhq3wXRga6PRD6A0kzT85Pb0AzYBVTBRpSrKyyr6M92Y6bnSBVp2BA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/util-user-agent-browser": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.953.0.tgz",
- "integrity": "sha512-UF5NeqYesWuFao+u7LJvpV1SJCaLml5BtFZKUdTnNNMeN6jvV+dW/eQoFGpXF94RCqguX0XESmRuRRPQp+/rzQ==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.1.tgz",
+ "integrity": "sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/types": "3.953.0",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
- "version": "3.954.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.954.0.tgz",
- "integrity": "sha512-fB5S5VOu7OFkeNzcblQlez4AjO5hgDFaa7phYt7716YWisY3RjAaQPlxgv+G3GltHHDJIfzEC5aRxdf62B9zMg==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.1.tgz",
+ "integrity": "sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws-sdk/middleware-user-agent": "3.954.0",
- "@aws-sdk/types": "3.953.0",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/types": "^4.10.0",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
},
"peerDependencies": {
"aws-crt": ">=1.0.0"
@@ -1418,25 +1554,23 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
- "version": "3.953.0",
- "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.953.0.tgz",
- "integrity": "sha512-Zmrj21jQ2OeOJGr9spPiN00aQvXa/WUqRXcTVENhrMt+OFoSOfDFpYhUj9NQ09QmQ8KMWFoWuWW6iKurNqLvAA==",
- "dev": true,
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.1.tgz",
+ "integrity": "sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"fast-xml-parser": "5.2.5",
"tslib": "^2.6.2"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
- "version": "0.2.2",
- "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz",
- "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==",
- "dev": true,
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
+ "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
@@ -1451,13 +1585,13 @@
"link": true
},
"node_modules/@babel/code-frame": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
- "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
+ "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1466,9 +1600,9 @@
}
},
"node_modules/@babel/compat-data": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
- "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz",
+ "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1476,22 +1610,21 @@
}
},
"node_modules/@babel/core": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
- "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
+ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
- "@babel/helper-compilation-targets": "^7.27.2",
- "@babel/helper-module-transforms": "^7.28.3",
- "@babel/helpers": "^7.28.4",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/traverse": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -1518,14 +1651,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
- "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz",
+ "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/parser": "^7.28.5",
- "@babel/types": "^7.28.5",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -1535,13 +1668,13 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
- "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.27.2",
+ "@babel/compat-data": "^7.28.6",
"@babel/helper-validator-option": "^7.27.1",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
@@ -1572,29 +1705,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
- "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.28.3",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
- "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.28.3"
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1604,9 +1737,9 @@
}
},
"node_modules/@babel/helper-plugin-utils": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
- "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1642,26 +1775,26 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
- "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.4"
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
- "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz",
+ "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==",
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.28.5"
+ "@babel/types": "^7.28.6"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1726,13 +1859,13 @@
}
},
"node_modules/@babel/plugin-syntax-import-attributes": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
- "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz",
+ "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1768,13 +1901,13 @@
}
},
"node_modules/@babel/plugin-syntax-jsx": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
- "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+ "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1894,13 +2027,13 @@
}
},
"node_modules/@babel/plugin-syntax-typescript": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
- "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz",
+ "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-plugin-utils": "^7.27.1"
+ "@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -1910,42 +2043,42 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.28.4",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
- "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
- "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/parser": "^7.27.2",
- "@babel/types": "^7.27.1"
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
- "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz",
+ "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.28.5",
+ "@babel/code-frame": "^7.28.6",
+ "@babel/generator": "^7.28.6",
"@babel/helper-globals": "^7.28.0",
- "@babel/parser": "^7.28.5",
- "@babel/template": "^7.27.2",
- "@babel/types": "^7.28.5",
+ "@babel/parser": "^7.28.6",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.28.6",
"debug": "^4.3.1"
},
"engines": {
@@ -1953,9 +2086,9 @@
}
},
"node_modules/@babel/types": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
- "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz",
+ "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -1966,14 +2099,11 @@
}
},
"node_modules/@bcoe/v8-coverage": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
- "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
+ "license": "MIT"
},
"node_modules/@braintree/sanitize-url": {
"version": "7.1.1",
@@ -1996,8 +2126,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",
@@ -2051,9 +2180,9 @@
"license": "Apache-2.0"
},
"node_modules/@cloudscape-design/chat-components": {
- "version": "1.0.81",
- "resolved": "https://registry.npmjs.org/@cloudscape-design/chat-components/-/chat-components-1.0.81.tgz",
- "integrity": "sha512-r3+r6y+X80a+vfSvnPtOEfgIJ0jtozNV+ZcmE4NkWvBi0bkdw7vaHuhqOajuUp/z5bl4CdJhC5HtksmepBuDcA==",
+ "version": "1.0.90",
+ "resolved": "https://registry.npmjs.org/@cloudscape-design/chat-components/-/chat-components-1.0.90.tgz",
+ "integrity": "sha512-Ypqygl1/m9syc++ckNrtEA7KyCcdYs7ybtT2mkC806NHOL1gEwXZmbevRDGZOh9LJ5Gl1AG7tiUfg7fbP+IuGQ==",
"license": "Apache-2.0",
"dependencies": {
"@cloudscape-design/component-toolkit": "^1.0.0-beta",
@@ -2066,9 +2195,9 @@
}
},
"node_modules/@cloudscape-design/collection-hooks": {
- "version": "1.0.79",
- "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.79.tgz",
- "integrity": "sha512-StCX0ngMscurUbSyYVH/StHDRoJa4mXOaMv2i2T/Lcs7vzz9XoNGk15bpxdpFFsh08Z3yMiAD50FxS/K2Gdtug==",
+ "version": "1.0.80",
+ "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.80.tgz",
+ "integrity": "sha512-rE8AwpHb7tpo+POGQlWSFAH7CY6pTQ68za4y3g8kGxRtlTfVEE4k2iGwrzocTJHH+9WUEUz2bkvux4nWnRa5Sw==",
"license": "Apache-2.0",
"peerDependencies": {
"react": ">=16.8.0"
@@ -2085,11 +2214,10 @@
}
},
"node_modules/@cloudscape-design/components": {
- "version": "3.0.1157",
- "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1157.tgz",
- "integrity": "sha512-5dv5PFlaZAD8Kb8tftRGinfhqTSAxD1SsESYMTKMXK+fMJoEn5X5WipoVQHfI5AzCmHQa2UUw1uQ7FRrCECqlA==",
+ "version": "3.0.1181",
+ "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",
@@ -2104,7 +2232,7 @@
"date-fns": "^2.25.0",
"intl-messageformat": "^10.3.1",
"mnth": "^2.0.0",
- "react-keyed-flatten-children": "^2.2.1",
+ "react-is": ">=16.8.0",
"react-transition-group": "^4.4.2",
"tslib": "^2.4.0",
"weekstart": "^1.1.0"
@@ -2257,7 +2385,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -2266,9 +2393,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.0.21",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.21.tgz",
- "integrity": "sha512-plP8N8zKfEZ26figX4Nvajx8DuzfuRpLTqglQ5d0chfnt35Qt3X+m6ASZ+rG0D0kxe/upDVNwSIVJP5n4FuNfw==",
+ "version": "1.0.25",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.25.tgz",
+ "integrity": "sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==",
"dev": true,
"funding": [
{
@@ -2301,15 +2428,14 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@cypress/request": {
- "version": "3.0.9",
- "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz",
- "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==",
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz",
+ "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2326,7 +2452,7 @@
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"performance-now": "^2.1.0",
- "qs": "6.14.0",
+ "qs": "~6.14.1",
"safe-buffer": "^5.1.2",
"tough-cookie": "^5.0.0",
"tunnel-agent": "^0.6.0",
@@ -2336,59 +2462,26 @@
"node": ">= 6"
}
},
- "node_modules/@cypress/request/node_modules/tldts": {
- "version": "6.1.86",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
- "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "node_modules/@cypress/request/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "tldts-core": "^6.1.86"
- },
"bin": {
- "tldts": "bin/cli.js"
+ "uuid": "dist/bin/uuid"
}
},
- "node_modules/@cypress/request/node_modules/tldts-core": {
- "version": "6.1.86",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
- "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "node_modules/@cypress/xvfb": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
+ "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==",
"dev": true,
- "license": "MIT"
- },
- "node_modules/@cypress/request/node_modules/tough-cookie": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
- "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
- "dev": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "tldts": "^6.1.32"
- },
- "engines": {
- "node": ">=16"
- }
- },
- "node_modules/@cypress/request/node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
- "dev": true,
- "license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
- "node_modules/@cypress/xvfb": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
- "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "debug": "^3.1.0",
- "lodash.once": "^4.1.1"
- }
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.1.0",
+ "lodash.once": "^4.1.1"
+ }
},
"node_modules/@cypress/xvfb/node_modules/debug": {
"version": "3.2.7",
@@ -2417,7 +2510,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",
@@ -2503,9 +2595,9 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
- "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
+ "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2515,9 +2607,9 @@
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
- "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2953,9 +3045,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
- "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3094,6 +3186,37 @@
"concat-map": "0.0.1"
}
},
+ "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -3167,6 +3290,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz",
+ "integrity": "sha512-lagqsvnk09NKogQaN/XrtlWeUF8SRhT12odMvbTIIaVObqzwAogL6jhR4DAp0gPuKoM1AOVrKUshJpRdpMFrww==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@formatjs/ecma402-abstract": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
@@ -3232,7 +3373,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"
},
@@ -3332,9 +3472,9 @@
}
},
"node_modules/@hono/node-server": {
- "version": "1.19.7",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
- "integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
+ "version": "1.19.9",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
+ "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -3396,9 +3536,9 @@
}
},
"node_modules/@iconify-json/simple-icons": {
- "version": "1.2.63",
- "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.63.tgz",
- "integrity": "sha512-xZl2UWCwE58VlqZ+pDPmaUhE2tq8MVSTJRr4/9nzzHlDdjJ0Ud1VxNXPrwTSgESKY29iCQw3S0r2nJTSNNngHw==",
+ "version": "1.2.67",
+ "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.67.tgz",
+ "integrity": "sha512-RGJRwlxyup54L1UDAjCshy3ckX5zcvYIU74YLSnUgHGvqh6B4mvksbGNHAIEp7dZQ6cM13RZVT5KC07CmnFNew==",
"license": "CC0-1.0",
"dependencies": {
"@iconify/types": "*"
@@ -3776,13 +3916,6 @@
}
}
},
- "node_modules/@jest/reporters/node_modules/@bcoe/v8-coverage": {
- "version": "0.2.3",
- "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
- "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@jest/schemas": {
"version": "30.0.5",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz",
@@ -3955,18 +4088,17 @@
}
},
"node_modules/@langchain/core": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.6.tgz",
- "integrity": "sha512-db5H+nLb6rwTW7noIlspN9nDi8b6NeuRWpelzS3+/BuasrJlZ6QbTDxkIqJRTEyLB2av3xOHlYHNmDHKal0XYA==",
+ "version": "1.1.16",
+ "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",
"camelcase": "6",
"decamelize": "1.2.0",
"js-tiktoken": "^1.0.12",
- "langsmith": "^0.3.82",
+ "langsmith": ">=0.4.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^10.0.0",
@@ -3976,6 +4108,18 @@
"node": ">=20"
}
},
+ "node_modules/@langchain/core/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/@langchain/core/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
@@ -3990,13 +4134,13 @@
}
},
"node_modules/@langchain/langgraph": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.0.7.tgz",
- "integrity": "sha512-EBGqNOWoRiEoLUaeuiXRpUM8/DE6QcwiirNyd97XhezStebBoTTilWH8CUt6S94JRGl5zwfBBRHfzotDnZS/eA==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.1.tgz",
+ "integrity": "sha512-FfFiaUwc2P5cy0AyALTA72S9OuutBLy+TRQECQSkwI5H40UNF+h7HM0U28xGTfmQ6MrqzbnnpRfXRTQX4jqsUw==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.0.0",
- "@langchain/langgraph-sdk": "~1.3.1",
+ "@langchain/langgraph-sdk": "~1.5.5",
"uuid": "^10.0.0"
},
"engines": {
@@ -4004,7 +4148,7 @@
},
"peerDependencies": {
"@langchain/core": "^1.0.1",
- "zod": "^3.25.32 || ^4.1.0",
+ "zod": "^3.25.32 || ^4.2.0",
"zod-to-json-schema": "^3.x"
},
"peerDependenciesMeta": {
@@ -4042,17 +4186,17 @@
}
},
"node_modules/@langchain/langgraph-sdk": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.3.1.tgz",
- "integrity": "sha512-zTi7DZHwqtMEzapvm3I1FL4Q7OZsxtq9tTXy6s2gcCxyIU3sphqRboqytqVN7dNHLdTCLb8nXy49QKurs2MIBg==",
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.5.tgz",
+ "integrity": "sha512-SyiAs6TVXPWlt/8cI9pj/43nbIvclY3ytKqUFbL5MplCUnItetEyqvH87EncxyVF5D7iJKRZRfSVYBMmOZbjbQ==",
"license": "MIT",
"dependencies": {
- "p-queue": "^6.6.2",
- "p-retry": "4",
- "uuid": "^9.0.0"
+ "p-queue": "^9.0.1",
+ "p-retry": "^7.1.1",
+ "uuid": "^13.0.0"
},
"peerDependencies": {
- "@langchain/core": "^1.0.1",
+ "@langchain/core": "^1.1.15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
},
@@ -4068,17 +4212,45 @@
}
}
},
+ "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
+ "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
- "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
- "uuid": "dist/bin/uuid"
+ "uuid": "dist-node/bin/uuid"
}
},
"node_modules/@langchain/langgraph/node_modules/uuid": {
@@ -4133,12 +4305,12 @@
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
- "version": "1.25.1",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
- "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
+ "version": "1.25.3",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz",
+ "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==",
"license": "MIT",
"dependencies": {
- "@hono/node-server": "^1.19.7",
+ "@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -4206,26 +4378,6 @@
"@tybys/wasm-util": "^0.10.0"
}
},
- "node_modules/@oxc-project/runtime": {
- "version": "0.97.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.97.0.tgz",
- "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@oxc-project/types": {
- "version": "0.97.0",
- "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.97.0.tgz",
- "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/Boshen"
- }
- },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -4283,413 +4435,188 @@
}
}
},
- "node_modules/@rolldown/binding-android-arm64": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz",
- "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==",
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.47",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
+ "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
+ "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
"cpu": [
- "arm64"
+ "arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
+ "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
+ "cpu": [
+ "arm64"
],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
},
- "node_modules/@rolldown/binding-darwin-arm64": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz",
- "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==",
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
+ "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-darwin-x64": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz",
- "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==",
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
+ "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
+ "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
+ "cpu": [
+ "arm64"
],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
},
- "node_modules/@rolldown/binding-freebsd-x64": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz",
- "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==",
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
+ "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz",
- "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==",
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
+ "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
+ "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
+ "cpu": [
+ "arm"
],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
},
- "node_modules/@rolldown/binding-linux-arm64-gnu": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz",
- "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==",
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
+ "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-arm64-musl": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz",
- "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==",
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
+ "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-x64-gnu": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz",
- "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
"cpu": [
- "x64"
+ "loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-linux-x64-musl": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz",
- "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==",
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
+ "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
"cpu": [
- "x64"
+ "loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ ]
},
- "node_modules/@rolldown/binding-openharmony-arm64": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz",
- "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==",
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
+ "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
"cpu": [
- "arm64"
+ "ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "openharmony"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
+ "linux"
+ ]
},
- "node_modules/@rolldown/binding-wasm32-wasi": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz",
- "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==",
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
+ "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
"cpu": [
- "wasm32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@napi-rs/wasm-runtime": "^1.0.7"
- },
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz",
- "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1",
- "@tybys/wasm-util": "^0.10.1"
- }
- },
- "node_modules/@rolldown/binding-win32-arm64-msvc": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz",
- "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-ia32-msvc": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz",
- "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/binding-win32-x64-msvc": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz",
- "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- }
- },
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.47",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
- "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz",
- "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-android-arm64": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz",
- "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ]
- },
- "node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
- "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz",
- "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz",
- "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz",
- "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz",
- "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz",
- "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz",
- "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz",
- "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-loong64-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz",
- "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@rollup/rollup-linux-ppc64-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz",
- "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==",
- "cpu": [
- "ppc64"
+ "ppc64"
],
"license": "MIT",
"optional": true,
@@ -4698,9 +4625,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz",
- "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
+ "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
"cpu": [
"riscv64"
],
@@ -4711,9 +4638,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz",
- "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
+ "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
"cpu": [
"riscv64"
],
@@ -4724,9 +4651,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz",
- "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
+ "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
"cpu": [
"s390x"
],
@@ -4737,9 +4664,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz",
- "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
"cpu": [
"x64"
],
@@ -4750,9 +4677,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz",
- "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
+ "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
"cpu": [
"x64"
],
@@ -4762,10 +4689,23 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
+ "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
"node_modules/@rollup/rollup-openharmony-arm64": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz",
- "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
+ "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
"cpu": [
"arm64"
],
@@ -4776,9 +4716,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz",
- "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
+ "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
"cpu": [
"arm64"
],
@@ -4789,9 +4729,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz",
- "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
+ "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
"cpu": [
"ia32"
],
@@ -4802,9 +4742,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
- "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
"cpu": [
"x64"
],
@@ -4815,9 +4755,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
- "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
+ "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
"cpu": [
"x64"
],
@@ -4914,9 +4854,9 @@
"license": "MIT"
},
"node_modules/@sinclair/typebox": {
- "version": "0.34.41",
- "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz",
- "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==",
+ "version": "0.34.47",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.47.tgz",
+ "integrity": "sha512-ZGIBQ+XDvO5JQku9wmwtabcVTHJsgSWAHYtVuM9pBNNR5E88v6Jcj/llpmsjivig5X8A8HHOb4/mbEKPS5EvAw==",
"dev": true,
"license": "MIT"
},
@@ -4941,13 +4881,12 @@
}
},
"node_modules/@smithy/abort-controller": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.6.tgz",
- "integrity": "sha512-P7JD4J+wxHMpGxqIg6SHno2tPkZbBUBLbPpR5/T1DEUvw/mEaINBMaPFZNM7lA+ToSCZ36j6nMHa+5kej+fhGg==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
+ "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -4955,17 +4894,16 @@
}
},
"node_modules/@smithy/config-resolver": {
- "version": "4.4.4",
- "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.4.tgz",
- "integrity": "sha512-s3U5ChS21DwU54kMmZ0UJumoS5cg0+rGVZvN6f5Lp6EbAVi0ZyP+qDSHdewfmXKUgNK1j3z45JyzulkDukrjAA==",
- "dev": true,
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz",
+ "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
"@smithy/util-config-provider": "^4.2.0",
- "@smithy/util-endpoints": "^3.2.6",
- "@smithy/util-middleware": "^4.2.6",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -4973,19 +4911,18 @@
}
},
"node_modules/@smithy/core": {
- "version": "3.19.0",
- "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.19.0.tgz",
- "integrity": "sha512-Y9oHXpBcXQgYHOcAEmxjkDilUbSTkgKjoHYed3WaYUH8jngq8lPWDBSpjHblJ9uOgBdy5mh3pzebrScDdYr29w==",
- "dev": true,
+ "version": "3.21.1",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.1.tgz",
+ "integrity": "sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/middleware-serde": "^4.2.7",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
- "@smithy/util-middleware": "^4.2.6",
- "@smithy/util-stream": "^4.5.7",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
"@smithy/util-utf8": "^4.2.0",
"@smithy/uuid": "^1.1.0",
"tslib": "^2.6.2"
@@ -4995,16 +4932,15 @@
}
},
"node_modules/@smithy/credential-provider-imds": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.6.tgz",
- "integrity": "sha512-xBmawExyTzOjbhzkZwg+vVm/khg28kG+rj2sbGlULjFd1jI70sv/cbpaR0Ev4Yfd6CpDUDRMe64cTqR//wAOyA==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz",
+ "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -5012,15 +4948,14 @@
}
},
"node_modules/@smithy/fetch-http-handler": {
- "version": "5.3.7",
- "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.7.tgz",
- "integrity": "sha512-fcVap4QwqmzQwQK9QU3keeEpCzTjnP9NJ171vI7GnD7nbkAIcP9biZhDUx88uRH9BabSsQDS0unUps88uZvFIQ==",
- "dev": true,
+ "version": "5.3.9",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz",
+ "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/querystring-builder": "^4.2.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"@smithy/util-base64": "^4.3.0",
"tslib": "^2.6.2"
},
@@ -5029,13 +4964,12 @@
}
},
"node_modules/@smithy/hash-node": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.6.tgz",
- "integrity": "sha512-k3Dy9VNR37wfMh2/1RHkFf/e0rMyN0pjY0FdyY6ItJRjENYyVPRMwad6ZR1S9HFm6tTuIOd9pqKBmtJ4VHxvxg==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz",
+ "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"@smithy/util-buffer-from": "^4.2.0",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
@@ -5045,13 +4979,12 @@
}
},
"node_modules/@smithy/invalid-dependency": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.6.tgz",
- "integrity": "sha512-E4t/V/q2T46RY21fpfznd1iSLTvCXKNKo4zJ1QuEFN4SE9gKfu2vb6bgq35LpufkQ+SETWIC7ZAf2GGvTlBaMQ==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz",
+ "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5062,7 +4995,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz",
"integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5072,14 +5004,13 @@
}
},
"node_modules/@smithy/middleware-content-length": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.6.tgz",
- "integrity": "sha512-0cjqjyfj+Gls30ntq45SsBtqF3dfJQCeqQPyGz58Pk8OgrAr5YiB7ZvDzjCA94p4r6DCI4qLm7FKobqBjf515w==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz",
+ "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5087,19 +5018,18 @@
}
},
"node_modules/@smithy/middleware-endpoint": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.0.tgz",
- "integrity": "sha512-M6qWfUNny6NFNy8amrCGIb9TfOMUkHVtg9bHtEFGRgfH7A7AtPpn/fcrToGPjVDK1ECuMVvqGQOXcZxmu9K+7A==",
- "dev": true,
+ "version": "4.4.11",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.11.tgz",
+ "integrity": "sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.19.0",
- "@smithy/middleware-serde": "^4.2.7",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
- "@smithy/url-parser": "^4.2.6",
- "@smithy/util-middleware": "^4.2.6",
+ "@smithy/core": "^3.21.1",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-middleware": "^4.2.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -5107,19 +5037,18 @@
}
},
"node_modules/@smithy/middleware-retry": {
- "version": "4.4.16",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.16.tgz",
- "integrity": "sha512-XPpNhNRzm3vhYm7YCsyw3AtmWggJbg1wNGAoqb7NBYr5XA5isMRv14jgbYyUV6IvbTBFZQdf2QpeW43LrRdStQ==",
- "dev": true,
+ "version": "4.4.27",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.27.tgz",
+ "integrity": "sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/service-error-classification": "^4.2.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
- "@smithy/util-middleware": "^4.2.6",
- "@smithy/util-retry": "^4.2.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
"@smithy/uuid": "^1.1.0",
"tslib": "^2.6.2"
},
@@ -5128,14 +5057,13 @@
}
},
"node_modules/@smithy/middleware-serde": {
- "version": "4.2.7",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.7.tgz",
- "integrity": "sha512-PFMVHVPgtFECeu4iZ+4SX6VOQT0+dIpm4jSPLLL6JLSkp9RohGqKBKD0cbiXdeIFS08Forp0UHI6kc0gIHenSA==",
- "dev": true,
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz",
+ "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5143,13 +5071,12 @@
}
},
"node_modules/@smithy/middleware-stack": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.6.tgz",
- "integrity": "sha512-JSbALU3G+JS4kyBZPqnJ3hxIYwOVRV7r9GNQMS6j5VsQDo5+Es5nddLfr9TQlxZLNHPvKSh+XSB0OuWGfSWFcA==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz",
+ "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5157,15 +5084,14 @@
}
},
"node_modules/@smithy/node-config-provider": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.6.tgz",
- "integrity": "sha512-fYEyL59Qe82Ha1p97YQTMEQPJYmBS+ux76foqluaTVWoG9Px5J53w6NvXZNE3wP7lIicLDF7Vj1Em18XTX7fsA==",
- "dev": true,
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz",
+ "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/property-provider": "^4.2.6",
- "@smithy/shared-ini-file-loader": "^4.4.1",
- "@smithy/types": "^4.10.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5173,16 +5099,15 @@
}
},
"node_modules/@smithy/node-http-handler": {
- "version": "4.4.6",
- "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.6.tgz",
- "integrity": "sha512-Gsb9jf4ido5BhPfani4ggyrKDd3ZK+vTFWmUaZeFg5G3E5nhFmqiTzAIbHqmPs1sARuJawDiGMGR/nY+Gw6+aQ==",
- "dev": true,
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz",
+ "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/abort-controller": "^4.2.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/querystring-builder": "^4.2.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5190,13 +5115,12 @@
}
},
"node_modules/@smithy/property-provider": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.6.tgz",
- "integrity": "sha512-a/tGSLPtaia2krbRdwR4xbZKO8lU67DjMk/jfY4QKt4PRlKML+2tL/gmAuhNdFDioO6wOq0sXkfnddNFH9mNUA==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz",
+ "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5204,13 +5128,12 @@
}
},
"node_modules/@smithy/protocol-http": {
- "version": "5.3.6",
- "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.6.tgz",
- "integrity": "sha512-qLRZzP2+PqhE3OSwvY2jpBbP0WKTZ9opTsn+6IWYI0SKVpbG+imcfNxXPq9fj5XeaUTr7odpsNpK6dmoiM1gJQ==",
- "dev": true,
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz",
+ "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5218,13 +5141,12 @@
}
},
"node_modules/@smithy/querystring-builder": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.6.tgz",
- "integrity": "sha512-MeM9fTAiD3HvoInK/aA8mgJaKQDvm8N0dKy6EiFaCfgpovQr4CaOkJC28XqlSRABM+sHdSQXbC8NZ0DShBMHqg==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz",
+ "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"@smithy/util-uri-escape": "^4.2.0",
"tslib": "^2.6.2"
},
@@ -5233,13 +5155,12 @@
}
},
"node_modules/@smithy/querystring-parser": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.6.tgz",
- "integrity": "sha512-YmWxl32SQRw/kIRccSOxzS/Ib8/b5/f9ex0r5PR40jRJg8X1wgM3KrR2In+8zvOGVhRSXgvyQpw9yOSlmfmSnA==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz",
+ "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5247,26 +5168,24 @@
}
},
"node_modules/@smithy/service-error-classification": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.6.tgz",
- "integrity": "sha512-Q73XBrzJlGTut2nf5RglSntHKgAG0+KiTJdO5QQblLfr4TdliGwIAha1iZIjwisc3rA5ulzqwwsYC6xrclxVQg==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz",
+ "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0"
+ "@smithy/types": "^4.12.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/shared-ini-file-loader": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.1.tgz",
- "integrity": "sha512-tph+oQYPbpN6NamF030hx1gb5YN2Plog+GLaRHpoEDwp8+ZPG26rIJvStG9hkWzN2HBn3HcWg0sHeB0tmkYzqA==",
- "dev": true,
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz",
+ "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5274,17 +5193,16 @@
}
},
"node_modules/@smithy/signature-v4": {
- "version": "5.3.6",
- "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.6.tgz",
- "integrity": "sha512-P1TXDHuQMadTMTOBv4oElZMURU4uyEhxhHfn+qOc2iofW9Rd4sZtBGx58Lzk112rIGVEYZT8eUMK4NftpewpRA==",
- "dev": true,
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz",
+ "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.0",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
"@smithy/util-hex-encoding": "^4.2.0",
- "@smithy/util-middleware": "^4.2.6",
+ "@smithy/util-middleware": "^4.2.8",
"@smithy/util-uri-escape": "^4.2.0",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
@@ -5294,18 +5212,17 @@
}
},
"node_modules/@smithy/smithy-client": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.1.tgz",
- "integrity": "sha512-1ovWdxzYprhq+mWqiGZlt3kF69LJthuQcfY9BIyHx9MywTFKzFapluku1QXoaBB43GCsLDxNqS+1v30ure69AA==",
- "dev": true,
+ "version": "4.10.12",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.12.tgz",
+ "integrity": "sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/core": "^3.19.0",
- "@smithy/middleware-endpoint": "^4.4.0",
- "@smithy/middleware-stack": "^4.2.6",
- "@smithy/protocol-http": "^5.3.6",
- "@smithy/types": "^4.10.0",
- "@smithy/util-stream": "^4.5.7",
+ "@smithy/core": "^3.21.1",
+ "@smithy/middleware-endpoint": "^4.4.11",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.10",
"tslib": "^2.6.2"
},
"engines": {
@@ -5313,10 +5230,9 @@
}
},
"node_modules/@smithy/types": {
- "version": "4.10.0",
- "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.10.0.tgz",
- "integrity": "sha512-K9mY7V/f3Ul+/Gz4LJANZ3vJ/yiBIwCyxe0sPT4vNJK63Srvd+Yk1IzP0t+nE7XFSpIGtzR71yljtnqpUTYFlQ==",
- "dev": true,
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
+ "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5326,14 +5242,13 @@
}
},
"node_modules/@smithy/url-parser": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.6.tgz",
- "integrity": "sha512-tVoyzJ2vXp4R3/aeV4EQjBDmCuWxRa8eo3KybL7Xv4wEM16nObYh7H1sNfcuLWHAAAzb0RVyxUz1S3sGj4X+Tg==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz",
+ "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/querystring-parser": "^4.2.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/querystring-parser": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5344,7 +5259,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz",
"integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.0",
@@ -5359,7 +5273,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz",
"integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5372,7 +5285,6 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz",
"integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5385,7 +5297,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz",
"integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.0",
@@ -5399,7 +5310,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz",
"integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5409,15 +5319,14 @@
}
},
"node_modules/@smithy/util-defaults-mode-browser": {
- "version": "4.3.15",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.15.tgz",
- "integrity": "sha512-LiZQVAg/oO8kueX4c+oMls5njaD2cRLXRfcjlTYjhIqmwHnCwkQO5B3dMQH0c5PACILxGAQf6Mxsq7CjlDc76A==",
- "dev": true,
+ "version": "4.3.26",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.26.tgz",
+ "integrity": "sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/property-provider": "^4.2.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5425,18 +5334,17 @@
}
},
"node_modules/@smithy/util-defaults-mode-node": {
- "version": "4.2.18",
- "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.18.tgz",
- "integrity": "sha512-Kw2J+KzYm9C9Z9nY6+W0tEnoZOofstVCMTshli9jhQbQCy64rueGfKzPfuFBnVUqZD9JobxTh2DzHmPkp/Va/Q==",
- "dev": true,
+ "version": "4.2.29",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.29.tgz",
+ "integrity": "sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/config-resolver": "^4.4.4",
- "@smithy/credential-provider-imds": "^4.2.6",
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/property-provider": "^4.2.6",
- "@smithy/smithy-client": "^4.10.1",
- "@smithy/types": "^4.10.0",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5444,14 +5352,13 @@
}
},
"node_modules/@smithy/util-endpoints": {
- "version": "3.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.6.tgz",
- "integrity": "sha512-v60VNM2+mPvgHCBXEfMCYrQ0RepP6u6xvbAkMenfe4Mi872CqNkJzgcnQL837e8NdeDxBgrWQRTluKq5Lqdhfg==",
- "dev": true,
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz",
+ "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/node-config-provider": "^4.3.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5462,7 +5369,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz",
"integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5472,13 +5378,12 @@
}
},
"node_modules/@smithy/util-middleware": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.6.tgz",
- "integrity": "sha512-qrvXUkxBSAFomM3/OEMuDVwjh4wtqK8D2uDZPShzIqOylPst6gor2Cdp6+XrH4dyksAWq/bE2aSDYBTTnj0Rxg==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz",
+ "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/types": "^4.10.0",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5486,14 +5391,13 @@
}
},
"node_modules/@smithy/util-retry": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.6.tgz",
- "integrity": "sha512-x7CeDQLPQ9cb6xN7fRJEjlP9NyGW/YeXWc4j/RUhg4I+H60F0PEeRc2c/z3rm9zmsdiMFzpV/rT+4UHW6KM1SA==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz",
+ "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/service-error-classification": "^4.2.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5501,15 +5405,14 @@
}
},
"node_modules/@smithy/util-stream": {
- "version": "4.5.7",
- "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.7.tgz",
- "integrity": "sha512-Uuy4S5Aj4oF6k1z+i2OtIBJUns4mlg29Ph4S+CqjR+f4XXpSFVgTCYLzMszHJTicYDBxKFtwq2/QSEDSS5l02A==",
- "dev": true,
+ "version": "4.5.10",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz",
+ "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/fetch-http-handler": "^5.3.7",
- "@smithy/node-http-handler": "^4.4.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/types": "^4.12.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-buffer-from": "^4.2.0",
"@smithy/util-hex-encoding": "^4.2.0",
@@ -5524,7 +5427,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz",
"integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5537,7 +5439,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz",
"integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.0",
@@ -5548,14 +5449,13 @@
}
},
"node_modules/@smithy/util-waiter": {
- "version": "4.2.6",
- "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.6.tgz",
- "integrity": "sha512-xU9HwUSik9UUCJmm530yvBy0AwlQFICveKmqvaaTukKkXEAhyiBdHtSrhPrH3rH+uz0ykyaE3LdgsX86C6mDCQ==",
- "dev": true,
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz",
+ "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==",
"license": "Apache-2.0",
"dependencies": {
- "@smithy/abort-controller": "^4.2.6",
- "@smithy/types": "^4.10.0",
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
"engines": {
@@ -5566,7 +5466,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz",
"integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==",
- "dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -5588,16 +5487,16 @@
"license": "MIT"
},
"node_modules/@stylistic/eslint-plugin": {
- "version": "5.6.1",
- "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.6.1.tgz",
- "integrity": "sha512-JCs+MqoXfXrRPGbGmho/zGS/jMcn3ieKl/A8YImqib76C8kjgZwq5uUFzc30lJkMvcchuRn6/v8IApLxli3Jyw==",
+ "version": "5.7.0",
+ "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.7.0.tgz",
+ "integrity": "sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.9.0",
- "@typescript-eslint/types": "^8.47.0",
- "eslint-visitor-keys": "^4.2.1",
- "espree": "^10.4.0",
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/types": "^8.52.0",
+ "eslint-visitor-keys": "^5.0.0",
+ "espree": "^11.0.0",
"estraverse": "^5.3.0",
"picomatch": "^4.0.3"
},
@@ -5609,9 +5508,9 @@
}
},
"node_modules/@swc/core": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.6.tgz",
- "integrity": "sha512-BpSCKSwE5DG4N4Um+ZZwvJzJ/4iyMVlzvhJQoR0wJSgccca9ES3+P/7SbPxTM/jtV9vE1llfLPphw+Y+MFhnZg==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.10.tgz",
+ "integrity": "sha512-udNofxftduMUEv7nqahl2nvodCiCDQ4Ge0ebzsEm6P8s0RC2tBM0Hqx0nNF5J/6t9uagFJyWIDjXy3IIWMHDJw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -5626,16 +5525,16 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
- "@swc/core-darwin-arm64": "1.15.6",
- "@swc/core-darwin-x64": "1.15.6",
- "@swc/core-linux-arm-gnueabihf": "1.15.6",
- "@swc/core-linux-arm64-gnu": "1.15.6",
- "@swc/core-linux-arm64-musl": "1.15.6",
- "@swc/core-linux-x64-gnu": "1.15.6",
- "@swc/core-linux-x64-musl": "1.15.6",
- "@swc/core-win32-arm64-msvc": "1.15.6",
- "@swc/core-win32-ia32-msvc": "1.15.6",
- "@swc/core-win32-x64-msvc": "1.15.6"
+ "@swc/core-darwin-arm64": "1.15.10",
+ "@swc/core-darwin-x64": "1.15.10",
+ "@swc/core-linux-arm-gnueabihf": "1.15.10",
+ "@swc/core-linux-arm64-gnu": "1.15.10",
+ "@swc/core-linux-arm64-musl": "1.15.10",
+ "@swc/core-linux-x64-gnu": "1.15.10",
+ "@swc/core-linux-x64-musl": "1.15.10",
+ "@swc/core-win32-arm64-msvc": "1.15.10",
+ "@swc/core-win32-ia32-msvc": "1.15.10",
+ "@swc/core-win32-x64-msvc": "1.15.10"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -5647,9 +5546,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.6.tgz",
- "integrity": "sha512-8pv6W49H70/yxNAC0k+W/Ko3nJW2Za706C1a8q6XhT4JtMLyaYqb+KeoBfIOR8F7qNhMdMa7wdOY5DLPk5cPSg==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.10.tgz",
+ "integrity": "sha512-U72pGqmJYbjrLhMndIemZ7u9Q9owcJczGxwtfJlz/WwMaGYAV/g4nkGiUVk/+QSX8sFCAjanovcU1IUsP2YulA==",
"cpu": [
"arm64"
],
@@ -5663,9 +5562,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.6.tgz",
- "integrity": "sha512-v4mDTwA+UdYEHKvzefc3VX/4a7QrRnAFZzNwL33PcLNUJhWbBg6ptcQpBDz/xWOjU6m+pC0IQfzcs16rkAFCHg==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.10.tgz",
+ "integrity": "sha512-NZpDXtwHH083L40xdyj1sY31MIwLgOxKfZEAGCI8xHXdHa+GWvEiVdGiu4qhkJctoHFzAEc7ZX3GN5phuJcPuQ==",
"cpu": [
"x64"
],
@@ -5679,9 +5578,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.6.tgz",
- "integrity": "sha512-OT8rIl24/mu4bgDPJT6FVcW+WF3ep9VTau69FspjeycNIa0U0est1ooHxxJyTcO8Qdv0Jy11oXHwtxslZ6KXcw==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.10.tgz",
+ "integrity": "sha512-ioieF5iuRziUF1HkH1gg1r93e055dAdeBAPGAk40VjqpL5/igPJ/WxFHGvc6WMLhUubSJI4S0AiZAAhEAp1jDg==",
"cpu": [
"arm"
],
@@ -5695,9 +5594,9 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.6.tgz",
- "integrity": "sha512-RKdeG9HBecClhtNJpGyZCYwvGrjzxDzQxGaVOQa44DbNSlVgupj6LnqNSt0RCTy8HEjra1WTD8dCJ9AR++dznQ==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.10.tgz",
+ "integrity": "sha512-tD6BClOrxSsNus9cJL7Gxdv7z7Y2hlyvZd9l0NQz+YXzmTWqnfzLpg16ovEI7gknH2AgDBB5ywOsqu8hUgSeEQ==",
"cpu": [
"arm64"
],
@@ -5711,9 +5610,9 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.6.tgz",
- "integrity": "sha512-+llo+x7fRyyYd5qGfeYyHgDoZy7M9jKQKmYjTKTJ1BMoydeBoujUWtw+L3tOHyrzKBWOdmVhwdyK+Rx8DeOaGQ==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.10.tgz",
+ "integrity": "sha512-4uAHO3nbfbrTcmO/9YcVweTQdx5fN3l7ewwl5AEK4yoC4wXmoBTEPHAVdKNe4r9+xrTgd4BgyPsy0409OjjlMw==",
"cpu": [
"arm64"
],
@@ -5727,9 +5626,9 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.6.tgz",
- "integrity": "sha512-1Ufezv5CtJOZaIzYUVMWPORNXgY1MuBrU6LPIeACkdpIaY2wiyfvTiMF57yZ3/c6RQQAY5ZmgV44wCe4dhUFew==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.10.tgz",
+ "integrity": "sha512-W0h9ONNw1pVIA0cN7wtboOSTl4Jk3tHq+w2cMPQudu9/+3xoCxpFb9ZdehwCAk29IsvdWzGzY6P7dDVTyFwoqg==",
"cpu": [
"x64"
],
@@ -5743,9 +5642,9 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.6.tgz",
- "integrity": "sha512-hKhR3mAvLvp1bmSrM68DyW+p8vKoFospxtffCTdC0fUR+Y6GEmSMTh+KcQ5vcGptnS2VB6QhZx3oLdzoBs0R6g==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.10.tgz",
+ "integrity": "sha512-XQNZlLZB62S8nAbw7pqoqwy91Ldy2RpaMRqdRN3T+tAg6Xg6FywXRKCsLh6IQOadr4p1+lGnqM/Wn35z5a/0Vw==",
"cpu": [
"x64"
],
@@ -5759,9 +5658,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.6.tgz",
- "integrity": "sha512-s3AMvEOxS+H4l2+bEYwKkfDBf34u1/i+t7OgflFCaZ9wSDA3f693bptPO3m1/DrMTq1iEztEV2MPbjMmQqOmBw==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.10.tgz",
+ "integrity": "sha512-qnAGrRv5Nj/DATxAmCnJQRXXQqnJwR0trxLndhoHoxGci9MuguNIjWahS0gw8YZFjgTinbTxOwzatkoySihnmw==",
"cpu": [
"arm64"
],
@@ -5775,9 +5674,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.6.tgz",
- "integrity": "sha512-oD9REGtkA/kU+d9xBa0jddrn4BEIfWA7Jx+O+KD1Dhvgd23aYVWwR98kote6DbC/5nAbt201JnW73SkYHBm4pQ==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.10.tgz",
+ "integrity": "sha512-i4X/q8QSvzVlaRtv1xfnfl+hVKpCfiJ+9th484rh937fiEZKxZGf51C+uO0lfKDP1FfnT6C1yBYwHy7FLBVXFw==",
"cpu": [
"ia32"
],
@@ -5791,9 +5690,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
- "version": "1.15.6",
- "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.6.tgz",
- "integrity": "sha512-oJ17Ouy1BkoUM5R8HJF8nX8IbiDror8tjW9x/PUoUVmtxxVb42vpXrS6xGDpH0mXx8K1wVVS6DOgH83uwKEBUQ==",
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.10.tgz",
+ "integrity": "sha512-HvY8XUFuoTXn6lSccDLYFlXv1SU/PzYi4PyUqGT++WfTnbw/68N/7BdUZqglGRwiSqr0qhYt/EhmBpULj0J9rA==",
"cpu": [
"x64"
],
@@ -5837,19 +5736,280 @@
"tailwindcss": "4.1.18"
}
},
- "node_modules/@tailwindcss/oxide": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
- "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "node_modules/@tailwindcss/node/node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
- "license": "MIT",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
"engines": {
- "node": ">= 10"
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.1.18",
- "@tailwindcss/oxide-darwin-arm64": "4.1.18",
- "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
@@ -6078,21 +6238,6 @@
"node": ">= 10"
}
},
- "node_modules/@tailwindcss/vite": {
- "version": "4.1.18",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
- "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@tailwindcss/node": "4.1.18",
- "@tailwindcss/oxide": "4.1.18",
- "tailwindcss": "4.1.18"
- },
- "peerDependencies": {
- "vite": "^5.2.0 || ^6 || ^7"
- }
- },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -6114,14 +6259,29 @@
"node": ">=18"
}
},
- "node_modules/@testing-library/dom/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "node_modules/@testing-library/dom/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
- "node": ">=8"
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@testing-library/dom/node_modules/pretty-format": {
@@ -6130,6 +6290,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6144,7 +6305,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",
@@ -6174,9 +6336,9 @@
"license": "MIT"
},
"node_modules/@testing-library/react": {
- "version": "16.3.1",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
- "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6266,7 +6428,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",
@@ -6539,9 +6702,9 @@
"license": "MIT"
},
"node_modules/@types/d3-shape": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
- "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
@@ -6690,9 +6853,9 @@
"license": "MIT"
},
"node_modules/@types/katex": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
- "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
+ "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
"license": "MIT"
},
"node_modules/@types/linkify-it": {
@@ -6702,9 +6865,9 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
"dev": true,
"license": "MIT"
},
@@ -6747,12 +6910,11 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "24.10.4",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
- "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
+ "version": "24.10.9",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz",
+ "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -6771,11 +6933,10 @@
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
- "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
+ "version": "19.2.9",
+ "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"
}
@@ -6786,7 +6947,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6829,12 +6989,6 @@
"redux-persist": "*"
}
},
- "node_modules/@types/retry": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
- "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
- "license": "MIT"
- },
"node_modules/@types/sinonjs__fake-timers": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz",
@@ -6928,20 +7082,20 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
- "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz",
+ "integrity": "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/regexpp": "^4.10.0",
- "@typescript-eslint/scope-manager": "8.50.0",
- "@typescript-eslint/type-utils": "8.50.0",
- "@typescript-eslint/utils": "8.50.0",
- "@typescript-eslint/visitor-keys": "8.50.0",
- "ignore": "^7.0.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.53.1",
+ "@typescript-eslint/type-utils": "8.53.1",
+ "@typescript-eslint/utils": "8.53.1",
+ "@typescript-eslint/visitor-keys": "8.53.1",
+ "ignore": "^7.0.5",
"natural-compare": "^1.4.0",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6951,24 +7105,23 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.50.0",
+ "@typescript-eslint/parser": "^8.53.1",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.0.tgz",
- "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.1.tgz",
+ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@typescript-eslint/scope-manager": "8.50.0",
- "@typescript-eslint/types": "8.50.0",
- "@typescript-eslint/typescript-estree": "8.50.0",
- "@typescript-eslint/visitor-keys": "8.50.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/scope-manager": "8.53.1",
+ "@typescript-eslint/types": "8.53.1",
+ "@typescript-eslint/typescript-estree": "8.53.1",
+ "@typescript-eslint/visitor-keys": "8.53.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -6983,15 +7136,15 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz",
- "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.1.tgz",
+ "integrity": "sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.50.0",
- "@typescript-eslint/types": "^8.50.0",
- "debug": "^4.3.4"
+ "@typescript-eslint/tsconfig-utils": "^8.53.1",
+ "@typescript-eslint/types": "^8.53.1",
+ "debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7005,14 +7158,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz",
- "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.1.tgz",
+ "integrity": "sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.50.0",
- "@typescript-eslint/visitor-keys": "8.50.0"
+ "@typescript-eslint/types": "8.53.1",
+ "@typescript-eslint/visitor-keys": "8.53.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7023,9 +7176,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz",
- "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.1.tgz",
+ "integrity": "sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7040,17 +7193,17 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz",
- "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.1.tgz",
+ "integrity": "sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.50.0",
- "@typescript-eslint/typescript-estree": "8.50.0",
- "@typescript-eslint/utils": "8.50.0",
- "debug": "^4.3.4",
- "ts-api-utils": "^2.1.0"
+ "@typescript-eslint/types": "8.53.1",
+ "@typescript-eslint/typescript-estree": "8.53.1",
+ "@typescript-eslint/utils": "8.53.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7065,9 +7218,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz",
- "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.1.tgz",
+ "integrity": "sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7079,21 +7232,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz",
- "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.1.tgz",
+ "integrity": "sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.50.0",
- "@typescript-eslint/tsconfig-utils": "8.50.0",
- "@typescript-eslint/types": "8.50.0",
- "@typescript-eslint/visitor-keys": "8.50.0",
- "debug": "^4.3.4",
- "minimatch": "^9.0.4",
- "semver": "^7.6.0",
+ "@typescript-eslint/project-service": "8.53.1",
+ "@typescript-eslint/tsconfig-utils": "8.53.1",
+ "@typescript-eslint/types": "8.53.1",
+ "@typescript-eslint/visitor-keys": "8.53.1",
+ "debug": "^4.4.3",
+ "minimatch": "^9.0.5",
+ "semver": "^7.7.3",
"tinyglobby": "^0.2.15",
- "ts-api-utils": "^2.1.0"
+ "ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7107,16 +7260,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz",
- "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.1.tgz",
+ "integrity": "sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@eslint-community/eslint-utils": "^4.7.0",
- "@typescript-eslint/scope-manager": "8.50.0",
- "@typescript-eslint/types": "8.50.0",
- "@typescript-eslint/typescript-estree": "8.50.0"
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.53.1",
+ "@typescript-eslint/types": "8.53.1",
+ "@typescript-eslint/typescript-estree": "8.53.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -7131,13 +7284,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.50.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz",
- "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==",
+ "version": "8.53.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz",
+ "integrity": "sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.50.0",
+ "@typescript-eslint/types": "8.53.1",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@@ -7148,6 +7301,19 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -7423,27 +7589,23 @@
"win32"
]
},
- "node_modules/@vitejs/plugin-react-swc": {
- "version": "4.2.2",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz",
- "integrity": "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==",
- "dev": true,
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "1.0.0-beta.47",
- "@swc/core": "^1.13.5"
- },
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
- "vite": "^4 || ^5 || ^6 || ^7"
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
}
},
"node_modules/@vitest/coverage-istanbul": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.0.16.tgz",
- "integrity": "sha512-CLyueXIHewDzmov97rGW/RNtg++UBwdtY/F9PZbEDvHlX16JWVyolg7OeGXZS3xkuuoaZMheef7luDFCoC6vsQ==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.0.18.tgz",
+ "integrity": "sha512-0OhjP30owEDihYTZGWuq20rNtV1RjjJs1Mv4MaZIKcFBmiLUXX7HJLX4fU7wE+Mrc3lQxI2HKq6WrSXi5FGuCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7453,7 +7615,6 @@
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-instrument": "^6.0.3",
"istanbul-lib-report": "^3.0.1",
- "istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
@@ -7463,22 +7624,21 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "4.0.16"
+ "vitest": "4.0.18"
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz",
- "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.0.16",
- "ast-v8-to-istanbul": "^0.3.8",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
- "istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
@@ -7489,8 +7649,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.0.16",
- "vitest": "4.0.16"
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -7498,17 +7658,27 @@
}
}
},
+ "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@vitest/expect": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
- "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.0.16",
- "@vitest/utils": "4.0.16",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
"chai": "^6.2.1",
"tinyrainbow": "^3.0.3"
},
@@ -7516,37 +7686,10 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@vitest/mocker": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz",
- "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vitest/spy": "4.0.16",
- "estree-walker": "^3.0.3",
- "magic-string": "^0.30.21"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "msw": "^2.4.9",
- "vite": "^6.0.0 || ^7.0.0-0"
- },
- "peerDependenciesMeta": {
- "msw": {
- "optional": true
- },
- "vite": {
- "optional": true
- }
- }
- },
"node_modules/@vitest/pretty-format": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz",
- "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7557,13 +7700,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz",
- "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.0.16",
+ "@vitest/utils": "4.0.18",
"pathe": "^2.0.3"
},
"funding": {
@@ -7571,13 +7714,13 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz",
- "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.16",
+ "@vitest/pretty-format": "4.0.18",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -7586,9 +7729,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz",
- "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -7596,14 +7739,13 @@
}
},
"node_modules/@vitest/ui": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz",
- "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz",
+ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@vitest/utils": "4.0.16",
+ "@vitest/utils": "4.0.18",
"fflate": "^0.8.2",
"flatted": "^3.3.3",
"pathe": "^2.0.3",
@@ -7615,17 +7757,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "4.0.16"
+ "vitest": "4.0.18"
}
},
"node_modules/@vitest/utils": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz",
- "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.0.16",
+ "@vitest/pretty-format": "4.0.18",
"tinyrainbow": "^3.0.3"
},
"funding": {
@@ -7633,65 +7775,53 @@
}
},
"node_modules/@vue/compiler-core": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
- "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
+ "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
- "@vue/shared": "3.5.25",
- "entities": "^4.5.0",
+ "@vue/shared": "3.5.27",
+ "entities": "^7.0.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
- "node_modules/@vue/compiler-core/node_modules/estree-walker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "license": "MIT"
- },
"node_modules/@vue/compiler-dom": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
- "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
+ "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-core": "3.5.25",
- "@vue/shared": "3.5.25"
+ "@vue/compiler-core": "3.5.27",
+ "@vue/shared": "3.5.27"
}
},
"node_modules/@vue/compiler-sfc": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
- "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
+ "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
- "@vue/compiler-core": "3.5.25",
- "@vue/compiler-dom": "3.5.25",
- "@vue/compiler-ssr": "3.5.25",
- "@vue/shared": "3.5.25",
+ "@vue/compiler-core": "3.5.27",
+ "@vue/compiler-dom": "3.5.27",
+ "@vue/compiler-ssr": "3.5.27",
+ "@vue/shared": "3.5.27",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
},
- "node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "license": "MIT"
- },
"node_modules/@vue/compiler-ssr": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
- "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
+ "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-dom": "3.5.25",
- "@vue/shared": "3.5.25"
+ "@vue/compiler-dom": "3.5.27",
+ "@vue/shared": "3.5.27"
}
},
"node_modules/@vue/devtools-api": {
@@ -7728,53 +7858,53 @@
}
},
"node_modules/@vue/reactivity": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
- "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
+ "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
"license": "MIT",
"dependencies": {
- "@vue/shared": "3.5.25"
+ "@vue/shared": "3.5.27"
}
},
"node_modules/@vue/runtime-core": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
- "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
+ "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
"license": "MIT",
"dependencies": {
- "@vue/reactivity": "3.5.25",
- "@vue/shared": "3.5.25"
+ "@vue/reactivity": "3.5.27",
+ "@vue/shared": "3.5.27"
}
},
"node_modules/@vue/runtime-dom": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
- "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
+ "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
"license": "MIT",
"dependencies": {
- "@vue/reactivity": "3.5.25",
- "@vue/runtime-core": "3.5.25",
- "@vue/shared": "3.5.25",
- "csstype": "^3.1.3"
+ "@vue/reactivity": "3.5.27",
+ "@vue/runtime-core": "3.5.27",
+ "@vue/shared": "3.5.27",
+ "csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
- "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
+ "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
"license": "MIT",
"dependencies": {
- "@vue/compiler-ssr": "3.5.25",
- "@vue/shared": "3.5.25"
+ "@vue/compiler-ssr": "3.5.27",
+ "@vue/shared": "3.5.27"
},
"peerDependencies": {
- "vue": "3.5.25"
+ "vue": "3.5.27"
}
},
"node_modules/@vue/shared": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
- "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
+ "version": "3.5.27",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
+ "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
@@ -7879,6 +8009,13 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/@yarnpkg/lockfile": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -7928,7 +8065,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"
},
@@ -8040,26 +8176,25 @@
"license": "MIT"
},
"node_modules/algoliasearch": {
- "version": "5.46.1",
- "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.46.1.tgz",
- "integrity": "sha512-39ol8Ulqb3MntofkXHlrcXKyU8BU0PXvQrXPBIX6eXj/EO4VT7651mhGVORI2oF8ydya9nFzT3fYDoqme/KL6w==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@algolia/abtesting": "1.12.1",
- "@algolia/client-abtesting": "5.46.1",
- "@algolia/client-analytics": "5.46.1",
- "@algolia/client-common": "5.46.1",
- "@algolia/client-insights": "5.46.1",
- "@algolia/client-personalization": "5.46.1",
- "@algolia/client-query-suggestions": "5.46.1",
- "@algolia/client-search": "5.46.1",
- "@algolia/ingestion": "1.46.1",
- "@algolia/monitoring": "1.46.1",
- "@algolia/recommend": "5.46.1",
- "@algolia/requester-browser-xhr": "5.46.1",
- "@algolia/requester-fetch": "5.46.1",
- "@algolia/requester-node-http": "5.46.1"
+ "version": "5.47.0",
+ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.47.0.tgz",
+ "integrity": "sha512-AGtz2U7zOV4DlsuYV84tLp2tBbA7RPtLA44jbVH4TTpDcc1dIWmULjHSsunlhscbzDydnjuFlNhflR3nV4VJaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/abtesting": "1.13.0",
+ "@algolia/client-abtesting": "5.47.0",
+ "@algolia/client-analytics": "5.47.0",
+ "@algolia/client-common": "5.47.0",
+ "@algolia/client-insights": "5.47.0",
+ "@algolia/client-personalization": "5.47.0",
+ "@algolia/client-query-suggestions": "5.47.0",
+ "@algolia/client-search": "5.47.0",
+ "@algolia/ingestion": "1.47.0",
+ "@algolia/monitoring": "1.47.0",
+ "@algolia/recommend": "5.47.0",
+ "@algolia/requester-browser-xhr": "5.47.0",
+ "@algolia/requester-fetch": "5.47.0",
+ "@algolia/requester-node-http": "5.47.0"
},
"engines": {
"node": ">= 14.0.0"
@@ -8105,12 +8240,15 @@
}
},
"node_modules/ansi-styles": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
- "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
"engines": {
- "node": ">=10"
+ "node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
@@ -8370,9 +8508,9 @@
}
},
"node_modules/ast-v8-to-istanbul": {
- "version": "0.3.9",
- "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz",
- "integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==",
+ "version": "0.3.10",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
+ "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8381,6 +8519,16 @@
"js-tokens": "^9.0.1"
}
},
+ "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
@@ -8440,24 +8588,21 @@
}
},
"node_modules/aws-cdk": {
- "version": "2.1100.1",
- "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1100.1.tgz",
- "integrity": "sha512-q2poFrQh90TK6eqeI0zznA8r1JkDI63WVOSqC7gFGo6qjQjAnvFk/utxHoNRgAC0RL0CLd19uCcHh3jfX9NiSg==",
+ "version": "2.1103.0",
+ "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1103.0.tgz",
+ "integrity": "sha512-bxEcqIeAT983x7525gf4Ya4zgpDt3Ou54El7j1ITCa/KqJ8ZaOP4F0ZHiiGuCbZduMcGJlszIXkaPJuvyNADgg==",
"license": "Apache-2.0",
"bin": {
"cdk": "bin/cdk"
},
"engines": {
"node": ">= 18.0.0"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
}
},
"node_modules/aws-cdk-lib": {
- "version": "2.232.2",
- "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.232.2.tgz",
- "integrity": "sha512-jrAxZy5mvSM9YVuF1M++hP8IIGyNmPzW8+C5nnvOnx+eYuySRiCSAzKVKlvB+UNpA0VEVCDNyTxKiu0mFBeg/g==",
+ "version": "2.236.0",
+ "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.236.0.tgz",
+ "integrity": "sha512-LauY4BX8vdYL9DvVKCgtJ2gZBwLEgfszTlFe6R2p2NUfEJ+PPpeRGxUbTaOdwLqJGN6mDqmzdoF4or8l2v69PA==",
"bundleDependencies": [
"@balena/dockerignore",
"case",
@@ -8473,12 +8618,12 @@
],
"license": "Apache-2.0",
"dependencies": {
- "@aws-cdk/asset-awscli-v1": "2.2.242",
+ "@aws-cdk/asset-awscli-v1": "2.2.263",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0",
"@aws-cdk/cloud-assembly-schema": "^48.20.0",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
- "fs-extra": "^11.3.2",
+ "fs-extra": "^11.3.3",
"ignore": "^5.3.2",
"jsonschema": "^1.5.0",
"mime-types": "^2.1.35",
@@ -8614,7 +8759,7 @@
"license": "BSD-3-Clause"
},
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
- "version": "11.3.2",
+ "version": "11.3.3",
"inBundle": true,
"license": "MIT",
"dependencies": {
@@ -8991,9 +9136,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.9.9",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.9.tgz",
- "integrity": "sha512-V8fbOCSeOFvlDj7LLChUcqbZrdKD9RU/VR260piF1790vT0mfLSwGc/Qzxv3IqiTukOpNtItePa0HBpMAj7MDg==",
+ "version": "2.9.17",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz",
+ "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -9044,9 +9189,9 @@
"license": "MIT"
},
"node_modules/body-parser": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
- "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
@@ -9055,7 +9200,7 @@
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
- "qs": "^6.14.0",
+ "qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
@@ -9068,9 +9213,9 @@
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
- "version": "0.7.1",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
- "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -9087,7 +9232,6 @@
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
"integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
- "dev": true,
"license": "MIT"
},
"node_modules/brace-expansion": {
@@ -9133,7 +9277,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -9312,9 +9455,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001760",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz",
- "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==",
+ "version": "1.0.30001765",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz",
+ "integrity": "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==",
"dev": true,
"funding": [
{
@@ -9350,9 +9493,9 @@
}
},
"node_modules/cdk-ecr-deployment": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/cdk-ecr-deployment/-/cdk-ecr-deployment-4.0.5.tgz",
- "integrity": "sha512-q6e08kBX6ZCJB30Oq/I0f0EaO32iEp3cJlK/UyarYQN1ZtLJeA1jU1WoTOnsuYJfM0f4i3nlfUUxgKkBBgpwjA==",
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/cdk-ecr-deployment/-/cdk-ecr-deployment-4.0.6.tgz",
+ "integrity": "sha512-FBf0rFt1QReeRWL0am6T+e5SsZQaB1RxHmmMMBrzxi6RG2Wy+L8FnjGkWFYoND5q5K4mrAZqdL57HaLTYiHzEg==",
"license": "Apache-2.0",
"peerDependencies": {
"aws-cdk-lib": "^2.80.0",
@@ -9370,9 +9513,9 @@
}
},
"node_modules/chai": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz",
- "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==",
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9395,21 +9538,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
- "node_modules/chalk/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/char-regex": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
@@ -9509,9 +9637,9 @@
}
},
"node_modules/cjs-module-lexer": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz",
- "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==",
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
+ "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"dev": true,
"license": "MIT"
},
@@ -9668,22 +9796,6 @@
"node": ">=8"
}
},
- "node_modules/cliui/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -9833,12 +9945,13 @@
}
},
"node_modules/commander": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
- "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
+ "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
+ "dev": true,
"license": "MIT",
"engines": {
- "node": ">= 12"
+ "node": ">=20"
}
},
"node_modules/common-tags": {
@@ -9874,11 +9987,10 @@
}
},
"node_modules/constructs": {
- "version": "10.4.4",
- "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.4.tgz",
- "integrity": "sha512-lP0qC1oViYf1cutHo9/KQ8QL637f/W29tDmv/6sy35F5zs+MD9f66nbAAIjicwc7fwyuF3rkg6PhZh4sfvWIpA==",
- "license": "Apache-2.0",
- "peer": true
+ "version": "10.4.5",
+ "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.5.tgz",
+ "integrity": "sha512-fOoP70YLevMZr5avJHx2DU3LNYmC6wM8OwdrNewMZou1kZnPGOeVzBrRjZNgFDHUlulYUjkpFRSpTE3D+n+ZSg==",
+ "license": "Apache-2.0"
},
"node_modules/content-disposition": {
"version": "1.0.1",
@@ -9953,9 +10065,9 @@
"license": "MIT"
},
"node_modules/cors": {
- "version": "2.8.5",
- "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
- "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
@@ -9963,6 +10075,10 @@
},
"engines": {
"node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/cose-base": {
@@ -9991,16 +10107,6 @@
"node": ">=10"
}
},
- "node_modules/cosmiconfig/node_modules/yaml": {
- "version": "1.10.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
- "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
@@ -10065,20 +10171,31 @@
}
},
"node_modules/cssstyle": {
- "version": "5.3.5",
- "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz",
- "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==",
+ "version": "5.3.7",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz",
+ "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^4.1.1",
"@csstools/css-syntax-patches-for-csstree": "^1.0.21",
- "css-tree": "^3.1.0"
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.4"
},
"engines": {
"node": ">=20"
}
},
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.4",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
+ "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -10086,14 +10203,14 @@
"license": "MIT"
},
"node_modules/cypress": {
- "version": "15.8.0",
- "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.8.0.tgz",
- "integrity": "sha512-/k/KT8IIvcxarRSNb5AIhT1Yxx1pXsNIrL96Ht/c0pBOO/XcsjgjD4ZlG16V/08dRmvU/gT7PW8FBz5YV+ahsA==",
+ "version": "15.9.0",
+ "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.9.0.tgz",
+ "integrity": "sha512-Ks6Bdilz3TtkLZtTQyqYaqtL/WT3X3APKaSLhTV96TmTyudzSjc6EJsJCHmBb7DxO+3R12q3Jkbjgm/iPgmwfg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
- "@cypress/request": "^3.0.9",
+ "@cypress/request": "^3.0.10",
"@cypress/xvfb": "^1.2.4",
"@types/sinonjs__fake-timers": "8.1.1",
"@types/sizzle": "^2.3.2",
@@ -10130,7 +10247,7 @@
"proxy-from-env": "1.0.0",
"request-progress": "^3.0.0",
"supports-color": "^8.1.1",
- "systeminformation": "5.27.7",
+ "systeminformation": "^5.27.14",
"tmp": "~0.2.4",
"tree-kill": "1.2.2",
"untildify": "^4.0.0",
@@ -10153,22 +10270,6 @@
"node": ">=8"
}
},
- "node_modules/cypress/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/cypress/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -10491,7 +10592,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"
}
@@ -10750,9 +10850,9 @@
}
},
"node_modules/d3-format": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
- "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
@@ -10883,7 +10983,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"
}
@@ -11010,19 +11109,29 @@
}
},
"node_modules/data-urls": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
- "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz",
+ "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "whatwg-mimetype": "^4.0.0",
- "whatwg-url": "^15.0.0"
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^15.1.0"
},
"engines": {
"node": ">=20"
}
},
+ "node_modules/data-urls/node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -11132,9 +11241,9 @@
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
- "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
"license": "MIT",
"dependencies": {
"character-entities": "^2.0.0"
@@ -11386,9 +11495,9 @@
}
},
"node_modules/diff": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
- "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
+ "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -11419,7 +11528,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",
@@ -11496,9 +11606,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.267",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
- "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
+ "version": "1.5.277",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.277.tgz",
+ "integrity": "sha512-wKXFZw4erWmmOz5N/grBoJ2XrNJGDFMu2+W5ACHza5rHtvsqrK4gb6rnLC7XxKB9WlJ+RmyQatuEXmtm86xbnw==",
"dev": true,
"license": "ISC"
},
@@ -11599,9 +11709,9 @@
}
},
"node_modules/entities": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
- "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -11791,7 +11901,6 @@
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -11862,7 +11971,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",
@@ -11923,7 +12031,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -12063,14 +12170,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
- "version": "5.5.4",
- "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
- "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
+ "version": "5.5.5",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
+ "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "prettier-linter-helpers": "^1.0.0",
- "synckit": "^0.11.7"
+ "prettier-linter-helpers": "^1.0.1",
+ "synckit": "^0.11.12"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -12141,13 +12248,13 @@
}
},
"node_modules/eslint-visitor-keys": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
- "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz",
+ "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -12164,6 +12271,37 @@
"concat-map": "0.0.1"
}
},
+ "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/eslint/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -12188,18 +12326,18 @@
}
},
"node_modules/espree": {
- "version": "10.4.0",
- "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
- "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.1.0.tgz",
+ "integrity": "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.15.0",
"acorn-jsx": "^5.3.2",
- "eslint-visitor-keys": "^4.2.1"
+ "eslint-visitor-keys": "^5.0.0"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ "node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
@@ -12220,9 +12358,9 @@
}
},
"node_modules/esquery": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
- "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -12266,14 +12404,10 @@
}
},
"node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
},
"node_modules/esutils": {
"version": "2.0.3",
@@ -12302,10 +12436,9 @@
"license": "MIT"
},
"node_modules/eventemitter3": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
- "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
- "dev": true,
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
@@ -12625,7 +12758,6 @@
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -12793,6 +12925,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/find-yarn-workspace-root": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
+ "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "micromatch": "^4.0.2"
+ }
+ },
"node_modules/findup-sync": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz",
@@ -12831,13 +12973,12 @@
"license": "ISC"
},
"node_modules/focus-trap": {
- "version": "7.6.6",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz",
- "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==",
+ "version": "7.8.0",
+ "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.3.0"
+ "tabbable": "^6.4.0"
}
},
"node_modules/follow-redirects": {
@@ -13732,9 +13873,9 @@
}
},
"node_modules/hono": {
- "version": "4.11.1",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
- "integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
+ "version": "4.11.5",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.5.tgz",
+ "integrity": "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==",
"license": "MIT",
"peer": true,
"engines": {
@@ -13748,16 +13889,16 @@
"license": "MIT"
},
"node_modules/html-encoding-sniffer": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
- "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "whatwg-encoding": "^3.1.1"
+ "@exodus/bytes": "^1.6.0"
},
"engines": {
- "node": ">=18"
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
@@ -13911,9 +14052,9 @@
"license": "MIT"
},
"node_modules/immer": {
- "version": "11.0.1",
- "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
- "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
+ "version": "11.1.3",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
+ "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -14254,6 +14395,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/is-docker": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
+ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "is-docker": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -14391,6 +14548,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-network-error": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
+ "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -14651,6 +14820,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-wsl": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
+ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -14763,7 +14945,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -14875,22 +15056,6 @@
"node": ">=8"
}
},
- "node_modules/jest-cli/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/jest-cli/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -15556,19 +15721,19 @@
"license": "MIT"
},
"node_modules/jsdom": {
- "version": "27.3.0",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz",
- "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==",
+ "version": "27.4.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz",
+ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
+ "@exodus/bytes": "^1.6.0",
"cssstyle": "^5.3.4",
"data-urls": "^6.0.0",
"decimal.js": "^10.6.0",
- "html-encoding-sniffer": "^4.0.0",
+ "html-encoding-sniffer": "^6.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
@@ -15578,7 +15743,6 @@
"tough-cookie": "^6.0.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.0",
- "whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.1.0",
"ws": "^8.18.3",
@@ -15596,6 +15760,39 @@
}
}
},
+ "node_modules/jsdom/node_modules/tldts": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
+ "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.19"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/jsdom/node_modules/tldts-core": {
+ "version": "7.0.19",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
+ "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom/node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -15643,6 +15840,26 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
+ "node_modules/json-stable-stringify": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
+ "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "isarray": "^2.0.5",
+ "jsonify": "^0.0.1",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -15650,6 +15867,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/json-stable-stringify/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
@@ -15683,6 +15907,16 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jsonify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+ "dev": true,
+ "license": "Public Domain",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/jsprim": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
@@ -15736,6 +15970,15 @@
"katex": "cli.js"
}
},
+ "node_modules/katex/node_modules/commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -15751,15 +15994,25 @@
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
+ "node_modules/klaw-sync": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
+ "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.1.11"
+ }
+ },
"node_modules/langchain": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.1.tgz",
- "integrity": "sha512-4sU3tQ6cFM8omUp2FFQParLbPC36GA7Rruq56vZ82XnHfDL3I+r1PH1eKdufCacSkAeM4NWbw3Bm2Ish6QOG5A==",
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.11.tgz",
+ "integrity": "sha512-vLDlibIbcaVt+H4Jw4lDCrHnSCGhcg/U5oELp9tdfoCkGpuDsrU9qv1wW0WW+ClypLCEbWiD5qJ+IqrOm6UiBQ==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.0.0",
"@langchain/langgraph-checkpoint": "^1.0.0",
- "langsmith": "~0.3.74",
+ "langsmith": ">=0.4.0 <1.0.0",
"uuid": "^10.0.0",
"zod": "^3.25.76 || ^4"
},
@@ -15767,7 +16020,7 @@
"node": ">=20"
},
"peerDependencies": {
- "@langchain/core": "1.1.6"
+ "@langchain/core": "1.1.16"
}
},
"node_modules/langchain/node_modules/uuid": {
@@ -15800,9 +16053,9 @@
}
},
"node_modules/langsmith": {
- "version": "0.3.87",
- "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.3.87.tgz",
- "integrity": "sha512-XXR1+9INH8YX96FKWc5tie0QixWz6tOqAsAKfcJyPkE0xPep+NDz0IQLR32q4bn10QK3LqD2HN6T3n6z1YLW7Q==",
+ "version": "0.4.8",
+ "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.4.8.tgz",
+ "integrity": "sha512-zyhQ4zp/TJqITfoQvtk8ehUmdIkxj24dTA76nEnMw63lb04/JKZgs29r/epH1pmEwbt0nUlQWKlE8n2g6BabUA==",
"license": "MIT",
"dependencies": {
"@types/uuid": "^10.0.0",
@@ -15892,9 +16145,9 @@
}
},
"node_modules/lightningcss": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
- "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
+ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==",
"devOptional": true,
"license": "MPL-2.0",
"dependencies": {
@@ -15908,23 +16161,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
- "lightningcss-android-arm64": "1.30.2",
- "lightningcss-darwin-arm64": "1.30.2",
- "lightningcss-darwin-x64": "1.30.2",
- "lightningcss-freebsd-x64": "1.30.2",
- "lightningcss-linux-arm-gnueabihf": "1.30.2",
- "lightningcss-linux-arm64-gnu": "1.30.2",
- "lightningcss-linux-arm64-musl": "1.30.2",
- "lightningcss-linux-x64-gnu": "1.30.2",
- "lightningcss-linux-x64-musl": "1.30.2",
- "lightningcss-win32-arm64-msvc": "1.30.2",
- "lightningcss-win32-x64-msvc": "1.30.2"
+ "lightningcss-android-arm64": "1.31.1",
+ "lightningcss-darwin-arm64": "1.31.1",
+ "lightningcss-darwin-x64": "1.31.1",
+ "lightningcss-freebsd-x64": "1.31.1",
+ "lightningcss-linux-arm-gnueabihf": "1.31.1",
+ "lightningcss-linux-arm64-gnu": "1.31.1",
+ "lightningcss-linux-arm64-musl": "1.31.1",
+ "lightningcss-linux-x64-gnu": "1.31.1",
+ "lightningcss-linux-x64-musl": "1.31.1",
+ "lightningcss-win32-arm64-msvc": "1.31.1",
+ "lightningcss-win32-x64-msvc": "1.31.1"
}
},
"node_modules/lightningcss-android-arm64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
- "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz",
+ "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==",
"cpu": [
"arm64"
],
@@ -15943,9 +16196,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
- "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz",
+ "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==",
"cpu": [
"arm64"
],
@@ -15964,9 +16217,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
- "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz",
+ "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==",
"cpu": [
"x64"
],
@@ -15985,9 +16238,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
- "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz",
+ "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==",
"cpu": [
"x64"
],
@@ -16006,9 +16259,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
- "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz",
+ "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==",
"cpu": [
"arm"
],
@@ -16027,9 +16280,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
- "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz",
+ "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==",
"cpu": [
"arm64"
],
@@ -16048,9 +16301,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
- "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz",
+ "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==",
"cpu": [
"arm64"
],
@@ -16069,9 +16322,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
- "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz",
+ "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==",
"cpu": [
"x64"
],
@@ -16090,9 +16343,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
- "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz",
+ "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==",
"cpu": [
"x64"
],
@@ -16111,9 +16364,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
- "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz",
+ "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==",
"cpu": [
"arm64"
],
@@ -16132,9 +16385,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
- "version": "1.30.2",
- "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
- "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "version": "1.31.1",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz",
+ "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==",
"cpu": [
"x64"
],
@@ -16194,14 +16447,20 @@
"url": "https://opencollective.com/lint-staged"
}
},
- "node_modules/lint-staged/node_modules/commander": {
- "version": "14.0.2",
- "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
- "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
+ "node_modules/lint-staged/node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
- "license": "MIT",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
"engines": {
- "node": ">=20"
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/lisa-docs": {
@@ -16313,15 +16572,15 @@
}
},
"node_modules/lodash": {
- "version": "4.17.21",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash-es": {
- "version": "4.17.22",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
- "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+ "version": "4.17.23",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
+ "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
},
"node_modules/lodash.get": {
@@ -16536,6 +16795,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -16618,6 +16878,19 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
+ "node_modules/markdown-it/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -17052,7 +17325,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -17088,7 +17360,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"decode-named-character-reference": "^1.0.0",
"devlop": "^1.0.0",
@@ -17762,6 +18033,16 @@
"ufo": "^1.6.1"
}
},
+ "node_modules/mnemonist": {
+ "version": "0.38.3",
+ "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"
+ }
+ },
"node_modules/mnth": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mnth/-/mnth-2.0.0.tgz",
@@ -18055,6 +18336,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obliterator": {
+ "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
+ },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
@@ -18071,7 +18359,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"
},
@@ -18127,10 +18414,27 @@
"regex-recursion": "^6.0.2"
}
},
+ "node_modules/open": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
+ "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-docker": "^2.0.0",
+ "is-wsl": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/openai": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/openai/-/openai-6.14.0.tgz",
- "integrity": "sha512-ZPD9MG5/sPpyGZ0idRoDK0P5MWEMuXe0Max/S55vuvoxqyEVkN94m9jSpE3YgNgz3WoESFvozs57dxWqAco31w==",
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz",
+ "integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
@@ -18271,16 +18575,18 @@
"license": "MIT"
},
"node_modules/p-retry": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
- "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
+ "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
- "@types/retry": "0.12.0",
- "retry": "^0.13.1"
+ "is-network-error": "^1.1.0"
},
"engines": {
- "node": ">=8"
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
@@ -18426,6 +18732,93 @@
"node": ">= 0.8"
}
},
+ "node_modules/patch-package": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
+ "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@yarnpkg/lockfile": "^1.1.0",
+ "chalk": "^4.1.2",
+ "ci-info": "^3.7.0",
+ "cross-spawn": "^7.0.3",
+ "find-yarn-workspace-root": "^2.0.0",
+ "fs-extra": "^10.0.0",
+ "json-stable-stringify": "^1.0.2",
+ "klaw-sync": "^6.0.0",
+ "minimist": "^1.2.6",
+ "open": "^7.4.2",
+ "semver": "^7.5.3",
+ "slash": "^2.0.0",
+ "tmp": "^0.2.4",
+ "yaml": "^2.2.2"
+ },
+ "bin": {
+ "patch-package": "index.js"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">5"
+ }
+ },
+ "node_modules/patch-package/node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/patch-package/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/patch-package/node_modules/slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/patch-package/node_modules/yaml": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/path-data-parser": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz",
@@ -18741,10 +19134,18 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postinstall-postinstall": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz",
+ "integrity": "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT"
+ },
"node_modules/preact": {
- "version": "10.28.0",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz",
- "integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==",
+ "version": "10.28.2",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
+ "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -18762,12 +19163,11 @@
}
},
"node_modules/prettier": {
- "version": "3.7.4",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
- "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -18779,9 +19179,9 @@
}
},
"node_modules/prettier-linter-helpers": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
- "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
+ "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -18819,6 +19219,19 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
@@ -18939,9 +19352,9 @@
"license": "MIT"
},
"node_modules/qs": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
- "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -18987,9 +19400,9 @@
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
- "version": "0.7.1",
- "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
- "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -19007,7 +19420,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"
}
@@ -19034,7 +19446,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"
},
@@ -19060,18 +19471,6 @@
"react": "^18.0.0 || ^19.0.0"
}
},
- "node_modules/react-keyed-flatten-children": {
- "version": "2.2.1",
- "resolved": "https://registry.npmjs.org/react-keyed-flatten-children/-/react-keyed-flatten-children-2.2.1.tgz",
- "integrity": "sha512-6yBLVO6suN8c/OcJk1mzIrUHdeEzf5rtRVBhxEXAHO49D7SlJ70cG4xrSJrBIAG7MMeQ+H/T151mM2dRDNnFaA==",
- "license": "MIT",
- "dependencies": {
- "react-is": "^18.2.0"
- },
- "peerDependencies": {
- "react": ">=15.0.0"
- }
- },
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -19117,7 +19516,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"
@@ -19137,9 +19535,9 @@
}
},
"node_modules/react-router": {
- "version": "7.11.0",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
- "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
+ "version": "7.12.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
+ "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -19159,12 +19557,12 @@
}
},
"node_modules/react-router-dom": {
- "version": "7.11.0",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.11.0.tgz",
- "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==",
+ "version": "7.12.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
+ "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT",
"dependencies": {
- "react-router": "7.11.0"
+ "react-router": "7.12.0"
},
"engines": {
"node": ">=20.0.0"
@@ -19242,6 +19640,12 @@
"util-deprecate": "~1.0.1"
}
},
+ "node_modules/readable-stream/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -19296,8 +19700,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",
@@ -19664,15 +20067,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/retry": {
- "version": "0.13.1",
- "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
- "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
- "license": "MIT",
- "engines": {
- "node": ">= 4"
- }
- },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -19685,50 +20079,10 @@
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
"license": "Unlicense"
},
- "node_modules/rolldown": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.50.tgz",
- "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@oxc-project/types": "=0.97.0",
- "@rolldown/pluginutils": "1.0.0-beta.50"
- },
- "bin": {
- "rolldown": "bin/cli.mjs"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "optionalDependencies": {
- "@rolldown/binding-android-arm64": "1.0.0-beta.50",
- "@rolldown/binding-darwin-arm64": "1.0.0-beta.50",
- "@rolldown/binding-darwin-x64": "1.0.0-beta.50",
- "@rolldown/binding-freebsd-x64": "1.0.0-beta.50",
- "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50",
- "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50",
- "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50",
- "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50",
- "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50",
- "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50",
- "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50",
- "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50",
- "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50",
- "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50"
- }
- },
- "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.50",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
- "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/rollup": {
- "version": "4.53.5",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz",
- "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==",
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
+ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -19741,28 +20095,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.53.5",
- "@rollup/rollup-android-arm64": "4.53.5",
- "@rollup/rollup-darwin-arm64": "4.53.5",
- "@rollup/rollup-darwin-x64": "4.53.5",
- "@rollup/rollup-freebsd-arm64": "4.53.5",
- "@rollup/rollup-freebsd-x64": "4.53.5",
- "@rollup/rollup-linux-arm-gnueabihf": "4.53.5",
- "@rollup/rollup-linux-arm-musleabihf": "4.53.5",
- "@rollup/rollup-linux-arm64-gnu": "4.53.5",
- "@rollup/rollup-linux-arm64-musl": "4.53.5",
- "@rollup/rollup-linux-loong64-gnu": "4.53.5",
- "@rollup/rollup-linux-ppc64-gnu": "4.53.5",
- "@rollup/rollup-linux-riscv64-gnu": "4.53.5",
- "@rollup/rollup-linux-riscv64-musl": "4.53.5",
- "@rollup/rollup-linux-s390x-gnu": "4.53.5",
- "@rollup/rollup-linux-x64-gnu": "4.53.5",
- "@rollup/rollup-linux-x64-musl": "4.53.5",
- "@rollup/rollup-openharmony-arm64": "4.53.5",
- "@rollup/rollup-win32-arm64-msvc": "4.53.5",
- "@rollup/rollup-win32-ia32-msvc": "4.53.5",
- "@rollup/rollup-win32-x64-gnu": "4.53.5",
- "@rollup/rollup-win32-x64-msvc": "4.53.5",
+ "@rollup/rollup-android-arm-eabi": "4.56.0",
+ "@rollup/rollup-android-arm64": "4.56.0",
+ "@rollup/rollup-darwin-arm64": "4.56.0",
+ "@rollup/rollup-darwin-x64": "4.56.0",
+ "@rollup/rollup-freebsd-arm64": "4.56.0",
+ "@rollup/rollup-freebsd-x64": "4.56.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.56.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.56.0",
+ "@rollup/rollup-linux-arm64-musl": "4.56.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.56.0",
+ "@rollup/rollup-linux-loong64-musl": "4.56.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.56.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.56.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.56.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.56.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-musl": "4.56.0",
+ "@rollup/rollup-openbsd-x64": "4.56.0",
+ "@rollup/rollup-openharmony-arm64": "4.56.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.56.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.56.0",
+ "@rollup/rollup-win32-x64-gnu": "4.56.0",
+ "@rollup/rollup-win32-x64-msvc": "4.56.0",
"fsevents": "~2.3.2"
}
},
@@ -19838,9 +20195,24 @@
"license": "MIT"
},
"node_modules/safe-buffer": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
- "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
"license": "MIT"
},
"node_modules/safe-push-apply": {
@@ -20422,6 +20794,12 @@
"safe-buffer": "~5.1.0"
}
},
+ "node_modules/string_decoder/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/string-argv": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
@@ -20706,7 +21084,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -20784,9 +21161,9 @@
"license": "MIT"
},
"node_modules/synckit": {
- "version": "0.11.11",
- "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
- "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
+ "version": "0.11.12",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
+ "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -20800,9 +21177,9 @@
}
},
"node_modules/systeminformation": {
- "version": "5.27.7",
- "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz",
- "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==",
+ "version": "5.30.5",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.5.tgz",
+ "integrity": "sha512-DpWmpCckhwR3hG+6udb6/aQB7PpiqVnvSljrjbKxNSvTRsGsg7NVE3/vouoYf96xgwMxXFKcS4Ux+cnkFwYM7A==",
"dev": true,
"license": "MIT",
"os": [
@@ -20827,9 +21204,9 @@
}
},
"node_modules/tabbable": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
- "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
+ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwindcss": {
@@ -20973,22 +21350,22 @@
}
},
"node_modules/tldts": {
- "version": "7.0.19",
- "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",
- "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==",
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "tldts-core": "^7.0.19"
+ "tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
- "version": "7.0.19",
- "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz",
- "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==",
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
@@ -21042,13 +21419,13 @@
}
},
"node_modules/tough-cookie": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
- "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
- "tldts": "^7.0.5"
+ "tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
@@ -21098,9 +21475,9 @@
}
},
"node_modules/ts-api-utils": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
- "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
+ "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -21191,7 +21568,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",
@@ -21278,7 +21654,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -21486,7 +21861,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"
@@ -21503,9 +21877,9 @@
"license": "MIT"
},
"node_modules/ufo": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
- "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==",
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
+ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
"license": "MIT"
},
"node_modules/uglify-js": {
@@ -21635,9 +22009,9 @@
}
},
"node_modules/unist-util-visit": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz",
- "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
@@ -21981,27 +22355,20 @@
}
},
"node_modules/vite": {
- "name": "rolldown-vite",
- "version": "7.2.5",
- "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.2.5.tgz",
- "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==",
- "dev": true,
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"license": "MIT",
- "peer": true,
"dependencies": {
- "@oxc-project/runtime": "0.97.0",
- "fdir": "^6.5.0",
- "lightningcss": "^1.30.2",
- "picomatch": "^4.0.3",
- "postcss": "^8.5.6",
- "rolldown": "1.0.0-beta.50",
- "tinyglobby": "^0.2.15"
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -22010,29 +22377,23 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^20.19.0 || >=22.12.0",
- "esbuild": "^0.25.0",
- "jiti": ">=1.21.0",
- "less": "^4.0.0",
- "sass": "^1.70.0",
- "sass-embedded": "^1.70.0",
- "stylus": ">=0.54.8",
- "sugarss": "^5.0.0",
- "terser": "^5.16.0",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
},
"peerDependenciesMeta": {
- "@types/node": {
- "optional": true
- },
- "esbuild": {
+ "@types/node": {
"optional": true
},
- "jiti": {
+ "less": {
"optional": true
},
- "less": {
+ "lightningcss": {
"optional": true
},
"sass": {
@@ -22049,12 +22410,6 @@
},
"terser": {
"optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
}
}
},
@@ -22062,7 +22417,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -22125,23 +22479,126 @@
"vue": "^3.5.0"
}
},
- "node_modules/vitepress/node_modules/@vitejs/plugin-vue": {
- "version": "5.2.4",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
- "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
"license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0",
- "vue": "^3.2.25"
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
}
},
- "node_modules/vitepress/node_modules/fsevents": {
+ "node_modules/vitest/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -22152,22 +22609,25 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/vitepress/node_modules/vite": {
- "version": "5.4.21",
- "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
- "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "node_modules/vitest/node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "esbuild": "^0.21.3",
- "postcss": "^8.4.43",
- "rollup": "^4.20.0"
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || >=20.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -22176,19 +22636,25 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || >=20.0.0",
- "less": "*",
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
"lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
- "terser": "^5.4.0"
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
+ "jiti": {
+ "optional": true
+ },
"less": {
"optional": true
},
@@ -22209,84 +22675,11 @@
},
"terser": {
"optional": true
- }
- }
- },
- "node_modules/vitest": {
- "version": "4.0.16",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz",
- "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@vitest/expect": "4.0.16",
- "@vitest/mocker": "4.0.16",
- "@vitest/pretty-format": "4.0.16",
- "@vitest/runner": "4.0.16",
- "@vitest/snapshot": "4.0.16",
- "@vitest/spy": "4.0.16",
- "@vitest/utils": "4.0.16",
- "es-module-lexer": "^1.7.0",
- "expect-type": "^1.2.2",
- "magic-string": "^0.30.21",
- "obug": "^2.1.1",
- "pathe": "^2.0.3",
- "picomatch": "^4.0.3",
- "std-env": "^3.10.0",
- "tinybench": "^2.9.0",
- "tinyexec": "^1.0.2",
- "tinyglobby": "^0.2.15",
- "tinyrainbow": "^3.0.3",
- "vite": "^6.0.0 || ^7.0.0",
- "why-is-node-running": "^2.3.0"
- },
- "bin": {
- "vitest": "vitest.mjs"
- },
- "engines": {
- "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- },
- "peerDependencies": {
- "@edge-runtime/vm": "*",
- "@opentelemetry/api": "^1.9.0",
- "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.0.16",
- "@vitest/browser-preview": "4.0.16",
- "@vitest/browser-webdriverio": "4.0.16",
- "@vitest/ui": "4.0.16",
- "happy-dom": "*",
- "jsdom": "*"
- },
- "peerDependenciesMeta": {
- "@edge-runtime/vm": {
- "optional": true
- },
- "@opentelemetry/api": {
- "optional": true
- },
- "@types/node": {
- "optional": true
- },
- "@vitest/browser-playwright": {
- "optional": true
- },
- "@vitest/browser-preview": {
- "optional": true
- },
- "@vitest/browser-webdriverio": {
- "optional": true
},
- "@vitest/ui": {
- "optional": true
- },
- "happy-dom": {
+ "tsx": {
"optional": true
},
- "jsdom": {
+ "yaml": {
"optional": true
}
}
@@ -22341,17 +22734,16 @@
"license": "MIT"
},
"node_modules/vue": {
- "version": "3.5.25",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
- "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
+ "version": "3.5.27",
+ "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.25",
- "@vue/compiler-sfc": "3.5.25",
- "@vue/runtime-dom": "3.5.25",
- "@vue/server-renderer": "3.5.25",
- "@vue/shared": "3.5.25"
+ "@vue/compiler-dom": "3.5.27",
+ "@vue/compiler-sfc": "3.5.27",
+ "@vue/runtime-dom": "3.5.27",
+ "@vue/server-renderer": "3.5.27",
+ "@vue/shared": "3.5.27"
},
"peerDependencies": {
"typescript": "*"
@@ -22416,9 +22808,9 @@
}
},
"node_modules/webidl-conversions": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
- "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
@@ -22431,19 +22823,6 @@
"integrity": "sha512-HjYc14IQUwDcnGICuc8tVtqAd6EFpoAQMqgrqcNtWWZB+F1b7iTq44GzwM1qvnH4upFgbhJsaNHuK93NOFheSg==",
"license": "MIT"
},
- "node_modules/whatwg-encoding": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
- "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "iconv-lite": "0.6.3"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
@@ -22558,9 +22937,9 @@
}
},
"node_modules/which-typed-array": {
- "version": "1.1.19",
- "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
- "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
@@ -22659,22 +23038,6 @@
"node": ">=8"
}
},
- "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -22754,12 +23117,11 @@
}
},
"node_modules/ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -22833,20 +23195,13 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
"engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
+ "node": ">= 6"
}
},
"node_modules/yargs": {
@@ -22978,21 +23333,19 @@
}
},
"node_modules/zod": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
- "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
+ "version": "4.3.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"
}
},
"node_modules/zod-to-json-schema": {
- "version": "3.25.0",
- "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
- "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
+ "version": "3.25.1",
+ "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"
}
@@ -23011,9 +23364,9 @@
}
},
"node_modules/zod2md": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/zod2md/-/zod2md-0.2.4.tgz",
- "integrity": "sha512-P+12EKfWquooGSlkZ2RatVX9O8rrF7BvM8vlsrohOgvFM2NeGIS0VZQbkGNGLRI8sp82KTxxWG7H7JhRZhzHFg==",
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/zod2md/-/zod2md-0.2.5.tgz",
+ "integrity": "sha512-Ru9GVlkgpy3rRY9FIy1UQbyg6K168qtwojzlCwlWtoq4Swt7T5nUe87iJuhssy9m6M8jTpjSKe1I2b+15FMSfg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -23045,7 +23398,6 @@
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18"
}
diff --git a/package.json b/package.json
index 9f1414313..acd1b377d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@awslabs/lisa",
- "version": "6.1.1",
+ "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": [
"aws",
@@ -59,15 +59,18 @@
"clean": "npm run clean -ws && rm -rf dist node_modules cdk.out build lib/rag/layer/TIKTOKEN_CACHE lib/serve/rest-api/TIKTOKEN_CACHE",
"watch": "tsc -w",
"test": "jest && npm run test -ws",
+ "test:update-baselines": "jest --testPathPatterns=snapshot.test.ts -- --updateBaselines",
"cdk": "cdk",
"prepare": "husky || true",
+ "postinstall": "patch-package",
"dev": "cd lib/user-interface/react/ && npm run dev",
"prepublishOnly": "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"
+ "generateSchemaDocs": "npx zod2md -c ./lib/zod2md.config.ts && npx zod2md -c ./lib/zod2md.rag.ts",
+ "generate-config": "tsx scripts/generate-config.ts"
},
"devDependencies": {
- "@aws-cdk/aws-lambda-python-alpha": "2.232.1-alpha.0",
+ "@aws-cdk/aws-lambda-python-alpha": "^2.236.0-alpha.0",
"@aws-sdk/client-iam": "^3.948.0",
"@aws-sdk/client-ssm": "^3.948.0",
"@cdklabs/cdk-enterprise-iac": "^0.1.0",
@@ -81,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.1033.0",
+ "aws-cdk": "^2.1103.0",
"depcheck": "^1.4.7",
"esbuild": "^0.27.1",
"eslint": "^9.39.1",
@@ -102,7 +105,8 @@
"zod2md": "^0.2.4"
},
"dependencies": {
- "aws-cdk-lib": "^2.232.1",
+ "@aws-sdk/util-dynamodb": "^3.948.0",
+ "aws-cdk-lib": "^2.236.0",
"aws-sdk": "^2.1693.0",
"cdk-ecr-deployment": "^4.0.5",
"cdk-nag": "^2.37.55",
diff --git a/patches/@langchain+openai+1.2.0.patch b/patches/@langchain+openai+1.2.0.patch
new file mode 100644
index 000000000..f9c49bb22
--- /dev/null
+++ b/patches/@langchain+openai+1.2.0.patch
@@ -0,0 +1,25 @@
+diff --git a/node_modules/@langchain/openai/dist/converters/completions.js b/node_modules/@langchain/openai/dist/converters/completions.js
+index 7a7adf5..a2a56e3 100644
+--- a/node_modules/@langchain/openai/dist/converters/completions.js
++++ b/node_modules/@langchain/openai/dist/converters/completions.js
+@@ -175,6 +175,10 @@ const convertCompletionsMessageToBaseMessage = ({ message, rawResponse, includeR
+ tool_calls: rawToolCalls
+ };
+ if (includeRawResponse !== void 0) additional_kwargs.__raw_response = rawResponse;
++ if (message.reasoning_content) additional_kwargs.reasoning_content = message.reasoning_content;
++ // Extract thinking signature from thinking_blocks
++ const thinkingBlock = rawResponse?.choices?.[0]?.message?.thinking_blocks?.[0] || message.thinking_blocks?.[0];
++ if (thinkingBlock?.signature) additional_kwargs.thinking_signature = thinkingBlock.signature;
+ const response_metadata = {
+ model_provider: "openai",
+ model_name: rawResponse.model,
+@@ -264,6 +268,9 @@ const convertCompletionsDeltaToBaseMessageChunk = ({ delta, rawResponse, include
+ else if (delta.tool_calls) additional_kwargs = { tool_calls: delta.tool_calls };
+ else additional_kwargs = {};
+ if (includeRawResponse) additional_kwargs.__raw_response = rawResponse;
++ if (delta.reasoning_content) additional_kwargs.reasoning_content = delta.reasoning_content;
++ const deltaThinkingBlock = delta.thinking_blocks?.[0];
++ if (deltaThinkingBlock?.signature) additional_kwargs.thinking_signature = deltaThinkingBlock.signature;
+ if (delta.audio) additional_kwargs.audio = {
+ ...delta.audio,
+ index: rawResponse.choices[0].index
diff --git a/pyproject.toml b/pyproject.toml
index 5c62a8986..bece6911b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,21 @@ warn_return_any = true
warn_unused_ignores = true
strict_optional = true
show_error_codes = true
+explicit_package_bases = true
+mypy_path = [
+ "lambda",
+ "lisa-sdk",
+ "lib/serve/rest-api/src",
+ "lib/serve/mcp-workbench/src",
+]
+exclude = [
+ "^build/",
+ "^dist/",
+ "^\\.venv/",
+ "^cdk\\.out/",
+ "^node_modules/",
+ ".*/build/.*",
+]
[tool.ruff]
line-length = 120
@@ -53,5 +68,14 @@ markers = [
"cdk_deployed: CDK infrastructure deployed",
]
testpaths = [
- "test/python"
+ "test/python",
+ "test/lambda",
+ "test/mcp-workbench",
+ "test/lisa-sdk",
+]
+pythonpath = [
+ "lambda",
+ "lisa-sdk",
+ "lib/serve/rest-api/src",
+ "lib/serve/mcp-workbench/src",
]
diff --git a/pytest.ini b/pytest.ini
index cdcf8c152..255f7db6d 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,4 +1,11 @@
[pytest]
-pythonpath = lambda
-testpaths = test/lambda
+pythonpath = lambda lisa-sdk lib/serve/rest-api/src lib/serve/mcp-workbench/src
+testpaths = test/lambda test/python test/mcp-workbench test/sdk test/rest-api
python_files = test_*.py
+# Exclude integration tests from default test runs
+norecursedirs = test/integration
+filterwarnings =
+ # Ignore botocore datetime.utcnow() deprecation warnings (AWS SDK issue, not our code)
+ ignore:datetime\.datetime\.utcnow\(\) is deprecated:DeprecationWarning:botocore
+ # Ignore Pydantic serialization warnings in tests (test mocks use dicts instead of models)
+ ignore:Pydantic serializer warnings:UserWarning:pydantic
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 05e64f720..fea52ce19 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,27 +1,26 @@
pyarrow>=20.0.0
datasets>=3.0.0
-fastapi==0.124.2
+fastapi>=0.120.1
fastapi_utils==0.8.0
loguru==0.7.3
mangum==0.19.0
pre-commit==4.5.0
-pydantic==2.12.5
+pydantic>=2.5.0,<3.0.0
pytest-xdist==3.8.0
pytest-asyncio==1.3.0
-# ASGI Server - Version constrained by litellm[proxy]==1.80.9 in rest-api
-# Standardized to 0.31.1 for compatibility across all components
-uvicorn==0.38.0
+# ASGI Server - Version constrained by litellm[proxy]==1.81.3 in rest-api
+uvicorn>=0.31.1,<0.32.0
-# AWS SDK - Version constrained by litellm[proxy]==1.80.9 in rest-api
-# Standardized to boto3==1.36.0 for compatibility across all components
-boto3==1.36.0
+# AWS SDK - Version constrained by litellm[proxy]==1.81.3 in rest-api
+boto3==1.40.76
tiktoken==0.12.0
python-docx==1.2.0
pypdf==6.4.1
+langchain==1.2.7
langchain-community==0.4.1
-langchain-core==1.1.3
-langchain-openai==1.1.1
-langchain-text-splitters==1.0.0
+langchain-core==1.2.7
+langchain-openai==1.1.7
+langchain-text-splitters==1.1.0
cachetools==6.2.2
--only-binary=pyarrow,lxml,psycopg2-binary
@@ -37,6 +36,9 @@ PyJWT==2.10.1
psycopg2-binary==2.9.11
cachetools==6.2.2
+# MCP Workbench testing
+fastmcp>=2.0.0
+
# Development
black==25.12.0
flake8==7.3.0
diff --git a/scripts/audit_dependencies.py b/scripts/audit_dependencies.py
index 0c82d2946..8749809fd 100755
--- a/scripts/audit_dependencies.py
+++ b/scripts/audit_dependencies.py
@@ -29,15 +29,14 @@
import tomllib
from collections import defaultdict
from pathlib import Path
-from typing import Dict, List, Optional, Set, Tuple
class DependencyAuditor:
def __init__(self, root_path: Path):
self.root = root_path
- self.package_versions: Dict[str, Dict[str, Set[str]]] = defaultdict(lambda: defaultdict(set))
+ self.package_versions: dict[str, dict[str, set[str]]] = defaultdict(lambda: defaultdict(set))
- def find_files(self, pattern: str, exclude_dirs: List[str] = None) -> List[Path]:
+ def find_files(self, pattern: str, exclude_dirs: list[str] | None = None) -> list[Path]:
"""Find files matching pattern, excluding specified directories."""
exclude_dirs = exclude_dirs or ["node_modules", ".venv", "dist", "build", ".pytest_cache"]
files = []
@@ -48,7 +47,7 @@ def find_files(self, pattern: str, exclude_dirs: List[str] = None) -> List[Path]
return sorted(files)
- def parse_requirement_line(self, line: str) -> Optional[Tuple[str, str]]:
+ def parse_requirement_line(self, line: str) -> tuple[str, str] | None:
"""
Parse a requirement line into (package, version_spec).
@@ -75,7 +74,7 @@ def parse_requirement_line(self, line: str) -> Optional[Tuple[str, str]]:
return None
- def audit_requirements_files(self):
+ def audit_requirements_files(self) -> None:
"""Audit all requirements.txt files."""
for file_path in self.find_files("requirements*.txt"):
rel_path = str(file_path.relative_to(self.root))
@@ -90,7 +89,7 @@ def audit_requirements_files(self):
except Exception as e:
print(f"Warning: Could not parse {rel_path}: {e}", file=sys.stderr)
- def audit_pyproject_files(self):
+ def audit_pyproject_files(self) -> None:
"""Audit all pyproject.toml files."""
for file_path in self.find_files("pyproject.toml"):
rel_path = str(file_path.relative_to(self.root))
@@ -159,7 +158,7 @@ def audit_pyproject_files(self):
except Exception as e:
print(f"Warning: Could not parse {rel_path}: {e}", file=sys.stderr)
- def audit_poetry_lock_files(self):
+ def audit_poetry_lock_files(self) -> None:
"""Audit all poetry.lock files."""
for file_path in self.find_files("poetry.lock"):
rel_path = str(file_path.relative_to(self.root))
@@ -196,7 +195,7 @@ def normalize_version_spec(self, spec: str) -> str:
return spec
- def are_versions_compatible(self, specs: Set[str]) -> bool:
+ def are_versions_compatible(self, specs: set[str]) -> bool:
"""
Check if version specs are compatible.
@@ -232,7 +231,7 @@ def are_versions_compatible(self, specs: Set[str]) -> bool:
# If we have multiple different exact versions, incompatible
return len(exact_versions) <= 1
- def generate_report(self) -> Tuple[Dict[str, Dict[str, Set[str]]], int]:
+ def generate_report(self) -> tuple[dict[str, dict[str, set[str]]], int]:
"""
Generate inconsistency report.
@@ -298,7 +297,7 @@ def run_audit(self) -> int:
return 1
-def main():
+def main() -> None:
# Find project root
script_path = Path(__file__).resolve()
diff --git a/scripts/docker/harden-ssh.sh b/scripts/docker/harden-ssh.sh
new file mode 100644
index 000000000..a403abd7f
--- /dev/null
+++ b/scripts/docker/harden-ssh.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+# LISA Security Hardening Script
+# Disables weak SSH ciphers (3DES-CBC, etc.) to address security vulnerabilities
+# This script is distribution-agnostic and works with any Linux base image
+
+set -e
+
+echo "Applying SSH security hardening..."
+
+# Ensure /etc/ssh directory exists
+mkdir -p /etc/ssh
+
+# Define strong cipher suites (no 3DES-CBC, no weak algorithms)
+STRONG_CIPHERS="aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com"
+STRONG_MACS="hmac-sha2-256,hmac-sha2-512,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com"
+STRONG_KEX="curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512"
+
+# Configure SSH client
+cat >> /etc/ssh/ssh_config <> /etc/ssh/sshd_config < = {
+ vllm: 'vllm/vllm-openai:latest',
+ tei: 'ghcr.io/huggingface/text-embeddings-inference:latest',
+ tgi: 'ghcr.io/huggingface/text-generation-inference:latest',
+};
+
+
+// ============================================================================
+// Input Validator
+// ============================================================================
+
+class DefaultInputValidator {
+ validateAccountNumber (value: string): ValidationResult {
+ const cleaned = value.trim();
+ if (!/^\d+$/.test(cleaned)) {
+ return { isValid: false, error: 'Account number must contain only digits' };
+ }
+ if (cleaned.length !== 12) {
+ return { isValid: false, error: `Account number must be exactly 12 digits, got ${cleaned.length}` };
+ }
+ return { isValid: true };
+ }
+
+ validateRegion (value: string): ValidationResult {
+ const cleaned = value.trim().toLowerCase();
+ if (VALID_REGIONS.has(cleaned)) {
+ return { isValid: true };
+ }
+ // Allow region-like patterns for future regions
+ if (/^[a-z]{2}(-[a-z]+)?-[a-z]+-\d+$/.test(cleaned)) {
+ return { isValid: true };
+ }
+ return { isValid: false, error: `'${value}' is not a recognized AWS region` };
+ }
+
+ validateNonEmpty (value: string, fieldName: string): ValidationResult {
+ if (!value.trim()) {
+ return { isValid: false, error: `${fieldName} cannot be empty` };
+ }
+ return { isValid: true };
+ }
+
+ validateBooleanInput (value: string): BooleanValidationResult {
+ const cleaned = value.trim().toLowerCase();
+ if (['yes', 'y', 'true', '1'].includes(cleaned)) {
+ return { isValid: true, value: true };
+ }
+ if (['no', 'n', 'false', '0'].includes(cleaned)) {
+ return { isValid: true, value: false };
+ }
+ return { isValid: false };
+ }
+
+ validateInferenceContainer (value: string): ValidationResult {
+ const cleaned = value.trim().toLowerCase() as InferenceContainer;
+ if (VALID_INFERENCE_CONTAINERS.includes(cleaned)) {
+ return { isValid: true };
+ }
+ return {
+ isValid: false,
+ error: `Invalid inference container. Must be one of: ${VALID_INFERENCE_CONTAINERS.join(', ')}`,
+ };
+ }
+
+ validatePartition (value: string): ValidationResult {
+ const cleaned = value.trim().toLowerCase() as AwsPartition;
+ if (VALID_PARTITIONS.includes(cleaned)) {
+ return { isValid: true };
+ }
+ return {
+ isValid: false,
+ error: `Invalid partition. Must be one of: ${VALID_PARTITIONS.join(', ')}`,
+ };
+ }
+}
+
+
+// ============================================================================
+// Config Builder
+// ============================================================================
+
+class ConfigBuilder {
+ private core?: CoreConfig;
+ private auth?: AuthConfig;
+ private apiGateway?: ApiGatewayConfig;
+ private restApi?: RestApiConfig;
+ private prebuiltAssets?: PrebuiltAssetsConfig;
+ private ecsModels: EcsModel[] = [];
+ private featureFlags: FeatureFlags = {
+ deployChat: true,
+ deployMetrics: true,
+ deployMcpWorkbench: true,
+ deployRag: true,
+ deployDocs: true,
+ deployUi: true,
+ deployMcp: true,
+ deployServe: true,
+ };
+
+ setCoreConfig (config: CoreConfig): this {
+ this.core = config;
+ return this;
+ }
+
+ setAuthConfig (config: AuthConfig | undefined): this {
+ this.auth = config;
+ return this;
+ }
+
+ setApiGatewayConfig (config: ApiGatewayConfig | undefined): this {
+ this.apiGateway = config;
+ return this;
+ }
+
+ setRestApiConfig (config: RestApiConfig | undefined): this {
+ this.restApi = config;
+ return this;
+ }
+
+ setPrebuiltAssets (usePrebuilt: boolean, partition?: AwsPartition, region?: string, accountNumber?: string): this {
+ if (usePrebuilt && partition && region && accountNumber) {
+ this.prebuiltAssets = this.createPrebuiltAssetsConfig(partition, region, accountNumber);
+ } else {
+ this.prebuiltAssets = undefined;
+ }
+ return this;
+ }
+
+ setEcsModels (models: EcsModel[]): this {
+ this.ecsModels = models;
+ return this;
+ }
+
+ setFeatureFlags (flags: FeatureFlags): this {
+ this.featureFlags = flags;
+ return this;
+ }
+
+ private createImageConfig (partition: AwsPartition, region: string, accountNumber: string, repositoryName: string): ImageConfig {
+ return {
+ type: 'ecr',
+ repositoryArn: `arn:${partition}:ecr:${region}:${accountNumber}:repository/${repositoryName}`,
+ tag: 'latest',
+ };
+ }
+
+ private createPrebuiltAssetsConfig (partition: AwsPartition, region: string, accountNumber: string): PrebuiltAssetsConfig {
+ 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`,
+ },
+ lambdaPath: `${base}/layers/AimlAdcLisaLambda.zip`,
+ webAppAssetsPath: `${base}/lisa-web`,
+ documentsPath: `${base}/docs`,
+ ecsModelDeployerPath: `${base}/ecs_model_deployer`,
+ vectorStoreDeployerPath: `${base}/vector_store_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'),
+ batchIngestionImageConfig: this.createImageConfig(partition, region, accountNumber, 'lisa-batch-ingestion'),
+ };
+ }
+
+ build (): LisaConfig {
+ if (!this.core) {
+ throw new Error('Core configuration is required');
+ }
+
+ const config: LisaConfig = {
+ accountNumber: this.core.accountNumber,
+ region: this.core.region,
+ partition: this.core.partition,
+ deploymentStage: this.core.deploymentStage,
+ deploymentName: this.core.deploymentName,
+ s3BucketModels: this.core.s3BucketModels,
+ ragRepositories: [],
+ ecsModels: this.ecsModels,
+ ...this.featureFlags,
+ };
+
+ if (this.auth) {
+ config.authConfig = { ...this.auth };
+ }
+
+ if (this.apiGateway) {
+ config.apiGatewayConfig = { ...this.apiGateway };
+ }
+
+ if (this.restApi && (this.restApi.sslCertIamArn || this.restApi.domainName)) {
+ config.restApiConfig = { ...this.restApi };
+ }
+
+ if (this.prebuiltAssets) {
+ config.lambdaLayerAssets = this.prebuiltAssets.lambdaLayerAssets;
+ config.lambdaPath = this.prebuiltAssets.lambdaPath;
+ config.webAppAssetsPath = this.prebuiltAssets.webAppAssetsPath;
+ config.documentsPath = this.prebuiltAssets.documentsPath;
+ config.ecsModelDeployerPath = this.prebuiltAssets.ecsModelDeployerPath;
+ config.vectorStoreDeployerPath = this.prebuiltAssets.vectorStoreDeployerPath;
+ config.certificateAuthorityBundle = this.prebuiltAssets.certificateAuthorityBundle;
+
+ // Add image configs - merge with existing restApiConfig if present
+ config.restApiConfig = {
+ ...config.restApiConfig,
+ imageConfig: this.prebuiltAssets.restApiImageConfig,
+ };
+ config.mcpWorkbenchConfig = {
+ imageConfig: this.prebuiltAssets.mcpWorkbenchImageConfig,
+ };
+ config.batchIngestionConfig = {
+ imageConfig: this.prebuiltAssets.batchIngestionImageConfig,
+ };
+ }
+
+ return config;
+ }
+}
+
+
+// ============================================================================
+// YAML Serializer
+// ============================================================================
+
+class YAMLSerializer {
+ serialize (config: Record): string {
+ return YAML.stringify(config, {
+ indent: 2,
+ });
+ }
+
+ deserialize (yamlContent: string): Record {
+ return YAML.parse(yamlContent) ?? {};
+ }
+}
+
+
+// ============================================================================
+// Config File Handler
+// ============================================================================
+
+class ConfigFileHandler {
+ private static readonly DEFAULT_CONFIG_FILE = 'config-custom.yaml';
+ private static readonly GENERATED_CONFIG_FILE = 'config-generated.yaml';
+ private serializer: YAMLSerializer;
+
+ constructor (private basePath: string = process.cwd()) {
+ this.serializer = new YAMLSerializer();
+ }
+
+ configExists (): boolean {
+ return fs.existsSync(path.join(this.basePath, ConfigFileHandler.DEFAULT_CONFIG_FILE));
+ }
+
+ getOutputPath (createNew: boolean): string {
+ if (createNew && this.configExists()) {
+ return path.join(this.basePath, ConfigFileHandler.GENERATED_CONFIG_FILE);
+ }
+ return path.join(this.basePath, ConfigFileHandler.DEFAULT_CONFIG_FILE);
+ }
+
+ getOutputFileName (createNew: boolean): string {
+ if (createNew && this.configExists()) {
+ return ConfigFileHandler.GENERATED_CONFIG_FILE;
+ }
+ return ConfigFileHandler.DEFAULT_CONFIG_FILE;
+ }
+
+ loadExistingConfig (): Record {
+ const configPath = path.join(this.basePath, ConfigFileHandler.DEFAULT_CONFIG_FILE);
+ if (!fs.existsSync(configPath)) {
+ return {};
+ }
+ const content = fs.readFileSync(configPath, 'utf-8');
+ return this.serializer.deserialize(content);
+ }
+
+ mergeConfigs (
+ existing: Record,
+ newConfig: Record
+ ): Record {
+ return { ...existing, ...newConfig };
+ }
+
+ writeConfig (config: Record, outputPath: string): void {
+ const yamlContent = this.serializer.serialize(config);
+ fs.writeFileSync(outputPath, yamlContent, 'utf-8');
+ }
+}
+
+
+// ============================================================================
+// Config Prompter
+// ============================================================================
+
+class ConfigPrompter {
+ private rl: readline.Interface;
+ private validator: DefaultInputValidator;
+
+ constructor (validator: DefaultInputValidator) {
+ this.validator = validator;
+ this.rl = readline.createInterface({ input, output });
+ }
+
+ async close (): Promise {
+ this.rl.close();
+ }
+
+ async promptWithValidation (
+ prompt: string,
+ validate: (value: string) => ValidationResult,
+ defaultValue?: string
+ ): Promise {
+ while (true) {
+ const displayPrompt = defaultValue ? `${prompt} [${defaultValue}]: ` : `${prompt}: `;
+ const answer = await this.rl.question(displayPrompt);
+ const value = answer.trim() || defaultValue || '';
+
+ const result = validate(value);
+ if (result.isValid) {
+ return value;
+ }
+ console.log(`Error: ${result.error}`);
+ }
+ }
+
+ async promptYesNo (prompt: string, defaultValue = true): Promise {
+ const defaultStr = defaultValue ? 'Y/n' : 'y/N';
+ while (true) {
+ const answer = await this.rl.question(`${prompt} [${defaultStr}]: `);
+ if (!answer.trim()) {
+ return defaultValue;
+ }
+ const result = this.validator.validateBooleanInput(answer);
+ if (result.isValid && result.value !== undefined) {
+ return result.value;
+ }
+ console.log('Please enter yes/no or y/n');
+ }
+ }
+
+ async promptCoreConfig (): Promise {
+ console.log('\n📋 Core Configuration\n');
+
+ const accountNumber = await this.promptWithValidation(
+ 'AWS Account Number (12 digits)',
+ (v) => this.validator.validateAccountNumber(v)
+ );
+ const region = await this.promptWithValidation(
+ 'AWS Region',
+ (v) => this.validator.validateRegion(v)
+ );
+ console.log('\nPartition options: aws, aws-cn, aws-us-gov, aws-iso, aws-iso-b, aws-iso-f');
+ const partition = await this.promptWithValidation(
+ 'AWS Partition',
+ (v) => this.validator.validatePartition(v),
+ 'aws'
+ ) as AwsPartition;
+ const deploymentStage = await this.promptWithValidation(
+ 'Deployment Stage',
+ (v) => this.validator.validateNonEmpty(v, 'Deployment stage'),
+ 'prod'
+ );
+ const deploymentName = await this.promptWithValidation(
+ 'Deployment Name',
+ (v) => this.validator.validateNonEmpty(v, 'Deployment name'),
+ 'prod'
+ );
+ const s3BucketModels = await this.promptWithValidation(
+ 'S3 Bucket for Models',
+ (v) => this.validator.validateNonEmpty(v, 'S3 bucket')
+ );
+
+ return {
+ accountNumber,
+ region,
+ partition,
+ deploymentStage,
+ deploymentName,
+ s3BucketModels,
+ };
+ }
+
+ async promptPrebuiltAssets (): Promise {
+ console.log('\n📦 Prebuilt Assets\n');
+ return await this.promptYesNo('Use prebuilt assets from @awslabs/lisa?', true);
+ }
+
+ async promptAuthConfig (): Promise {
+ console.log('\n🔐 Authentication Configuration\n');
+
+ const configure = await this.promptYesNo('Configure Authentication?', false);
+ if (!configure) {
+ return undefined;
+ }
+
+ const authority = await this.promptWithValidation(
+ 'OIDC Authority URL',
+ (v) => this.validator.validateNonEmpty(v, 'Authority URL')
+ );
+ const clientId = await this.promptWithValidation(
+ 'Client ID',
+ (v) => this.validator.validateNonEmpty(v, 'Client ID')
+ );
+ const adminGroup = (await this.rl.question('Admin Group Name (optional): ')).trim() || undefined;
+ const jwtGroupsProperty = (await this.rl.question('JWT Groups Property (optional): ')).trim() || undefined;
+
+ return { authority, clientId, adminGroup, jwtGroupsProperty };
+ }
+
+ async promptApiGatewayConfig (): Promise {
+ console.log('\n🌐 API Gateway Configuration\n');
+
+ const configure = await this.promptYesNo('Configure API Gateway custom domain?', false);
+ if (!configure) {
+ return undefined;
+ }
+
+ const domainName = await this.promptWithValidation(
+ 'API Gateway Domain Name',
+ (v) => this.validator.validateNonEmpty(v, 'Domain name')
+ );
+
+ return { domainName };
+ }
+
+ async promptRestApiConfig (): Promise {
+ console.log('\n🔧 REST API Configuration\n');
+
+ const configure = await this.promptYesNo('Configure REST API settings?', false);
+ if (!configure) {
+ return undefined;
+ }
+
+ const sslCertIamArn = (await this.rl.question('SSL Certificate IAM ARN (optional): ')).trim() || undefined;
+ const domainName = (await this.rl.question('REST API Domain Name (optional): ')).trim() || undefined;
+
+ if (!sslCertIamArn && !domainName) {
+ return undefined;
+ }
+
+ return { sslCertIamArn, domainName };
+ }
+
+ async promptFeatureFlags (): Promise {
+ console.log('\n🚀 Feature Deployment Flags\n');
+
+ const useDefaults = await this.promptYesNo('Use default feature flags (all enabled)?', true);
+ if (useDefaults) {
+ return {
+ deployChat: true,
+ deployMetrics: true,
+ deployMcpWorkbench: true,
+ deployRag: true,
+ deployDocs: true,
+ deployUi: true,
+ deployMcp: true,
+ deployServe: true,
+ };
+ }
+
+ return {
+ deployChat: await this.promptYesNo('Deploy Chat?'),
+ deployMetrics: await this.promptYesNo('Deploy Metrics?'),
+ deployMcpWorkbench: await this.promptYesNo('Deploy MCP Workbench?'),
+ deployRag: await this.promptYesNo('Deploy RAG?'),
+ deployDocs: await this.promptYesNo('Deploy Docs?'),
+ deployUi: await this.promptYesNo('Deploy UI?'),
+ deployMcp: await this.promptYesNo('Deploy MCP?'),
+ deployServe: await this.promptYesNo('Deploy Serve?'),
+ };
+ }
+
+ async promptEcsModels (s3BucketModels: string): Promise {
+ console.log('\n🤖 ECS Model Configuration\n');
+ console.log(`Models will be deployed from S3 bucket: ${s3BucketModels}`);
+ console.log('The model name corresponds to the path in S3 where the model is stored.');
+ console.log('Example: "openai/gpt-oss-20b" means s3://' + s3BucketModels + '/openai/gpt-oss-20b\n');
+
+ const addModels = await this.promptYesNo('Would you like to add ECS models?', false);
+ if (!addModels) {
+ return [];
+ }
+
+ const models: EcsModel[] = [];
+ let addMore = true;
+
+ while (addMore) {
+ console.log(`\n--- Model ${models.length + 1} ---`);
+
+ const modelName = await this.promptWithValidation(
+ 'Model name (S3 path, e.g., openai/gpt-oss-20b)',
+ (v) => this.validator.validateNonEmpty(v, 'Model name')
+ );
+
+ console.log('\nInference container options: vllm, tei, tgi');
+ const inferenceContainer = await this.promptWithValidation(
+ 'Inference container type',
+ (v) => this.validator.validateInferenceContainer(v),
+ 'vllm'
+ ) as InferenceContainer;
+
+ const defaultImage = DEFAULT_BASE_IMAGES[inferenceContainer];
+ const baseImage = await this.promptWithValidation(
+ 'Base image',
+ (v) => this.validator.validateNonEmpty(v, 'Base image'),
+ defaultImage
+ );
+
+ models.push({
+ modelName,
+ baseImage,
+ inferenceContainer,
+ });
+
+ console.log(`\n✓ Added model: ${modelName} (${inferenceContainer})`);
+ addMore = await this.promptYesNo('\nAdd another model?', false);
+ }
+
+ return models;
+ }
+
+ async promptFileHandling (configExists: boolean): Promise {
+ if (!configExists) {
+ return false; // Will create config-custom.yaml
+ }
+
+ console.log('\n📁 File Handling\n');
+ console.log('An existing config-custom.yaml was found.');
+
+ const createNew = await this.promptYesNo('Create new config-generated.yaml instead of merging?', false);
+ return createNew;
+ }
+}
+
+// ============================================================================
+// Main Entry Point
+// ============================================================================
+
+async function main (): Promise {
+ console.log('╔════════════════════════════════════════════════════════════════╗');
+ console.log('║ LISA Configuration Generator ║');
+ console.log('║ Generate a config-custom.yaml for LISA deployment ║');
+ console.log('╚════════════════════════════════════════════════════════════════╝');
+
+ const validator = new DefaultInputValidator();
+ const builder = new ConfigBuilder();
+ const fileHandler = new ConfigFileHandler();
+ const prompter = new ConfigPrompter(validator);
+
+ try {
+ // Check for existing config and determine file handling
+ const configExists = fileHandler.configExists();
+ const createNew = await prompter.promptFileHandling(configExists);
+
+ // Collect configuration through prompts
+ const coreConfig = await prompter.promptCoreConfig();
+ const usePrebuiltAssets = await prompter.promptPrebuiltAssets();
+ const authConfig = await prompter.promptAuthConfig();
+ const apiGatewayConfig = await prompter.promptApiGatewayConfig();
+ const restApiConfig = await prompter.promptRestApiConfig();
+ const featureFlags = await prompter.promptFeatureFlags();
+ const ecsModels = await prompter.promptEcsModels(coreConfig.s3BucketModels);
+
+ // Build the configuration
+ builder
+ .setCoreConfig(coreConfig)
+ .setPrebuiltAssets(usePrebuiltAssets, coreConfig.partition, coreConfig.region, coreConfig.accountNumber)
+ .setAuthConfig(authConfig)
+ .setApiGatewayConfig(apiGatewayConfig)
+ .setRestApiConfig(restApiConfig)
+ .setFeatureFlags(featureFlags)
+ .setEcsModels(ecsModels);
+
+ const newConfig = builder.build() as unknown as Record;
+
+ // Determine final config (merge or new)
+ let finalConfig: Record;
+ if (configExists && !createNew) {
+ const existingConfig = fileHandler.loadExistingConfig();
+ finalConfig = fileHandler.mergeConfigs(existingConfig, newConfig);
+ } else {
+ finalConfig = newConfig;
+ }
+
+ // Write the configuration
+ const outputPath = fileHandler.getOutputPath(createNew);
+ const outputFileName = fileHandler.getOutputFileName(createNew);
+ fileHandler.writeConfig(finalConfig, outputPath);
+
+ console.log('\n✅ Configuration generated successfully!');
+ console.log(`📄 Output file: ${outputFileName}`);
+
+ if (configExists && !createNew) {
+ console.log('ℹ️ Merged with existing configuration');
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ console.error(`\n❌ Error: ${error.message}`);
+ } else {
+ console.error('\n❌ An unexpected error occurred');
+ }
+ process.exit(1);
+ } finally {
+ await prompter.close();
+ }
+}
+
+// Run the main function
+main().catch((error) => {
+ console.error('Fatal error:', error);
+ process.exit(1);
+});
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 000000000..31cf60e0e
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,73 @@
+# LISA Test Structure
+
+This directory contains all tests for the LISA monorepo, organized by module.
+
+## Directory Structure
+
+```
+test/
+├── cdk/ # CDK infrastructure tests
+├── lambda/ # Lambda function tests
+├── mcp-workbench/ # MCP Workbench module tests
+├── lisa-sdk/ # LISA SDK (lisapy) tests
+├── python/ # Integration tests
+└── utils/ # Test utilities
+```
+
+## Running Tests
+
+### Run all tests
+```bash
+pytest
+```
+
+### Run tests for a specific module
+```bash
+# MCP Workbench tests
+pytest test/mcp-workbench/
+
+# LISA SDK tests
+pytest test/lisa-sdk/
+
+# Lambda tests
+pytest test/lambda/
+```
+
+### Run a specific test file
+```bash
+pytest test/mcp-workbench/test_core.py
+```
+
+## Module Test Organization
+
+### MCP Workbench (`test/mcp-workbench/`)
+Tests for the MCP Workbench module located in `lib/serve/mcp-workbench/src/mcpworkbench/`
+
+### LISA SDK (`test/lisa-sdk/`)
+Tests for the LISA Python SDK located in `lisa-sdk/lisapy/`
+
+### Lambda (`test/lambda/`)
+Tests for Lambda functions located in `lambda/`
+
+## Configuration
+
+Tests are configured in:
+- `pytest.ini` - Main pytest configuration with PYTHONPATH settings
+- `pyproject.toml` - Additional pytest and mypy configuration
+
+The PYTHONPATH is configured to include:
+- `lambda/`
+- `lisa-sdk/`
+- `lib/serve/rest-api/src/`
+- `lib/serve/mcp-workbench/src/`
+
+This allows tests to import modules from their respective source directories.
+
+## Type Checking
+
+Run mypy type checking:
+```bash
+mypy --config-file=pyproject.toml lisa-sdk/ lib/serve/mcp-workbench/src/
+```
+
+The mypy configuration uses `explicit_package_bases` to properly handle the monorepo structure and avoid duplicate module errors.
diff --git a/test/cdk/constructs/vector-store-creator.test.ts b/test/cdk/constructs/vector-store-creator.test.ts
new file mode 100644
index 000000000..1c8bbfe4e
--- /dev/null
+++ b/test/cdk/constructs/vector-store-creator.test.ts
@@ -0,0 +1,434 @@
+/**
+ 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 { App, Stack } from 'aws-cdk-lib';
+import { Match, Template } from 'aws-cdk-lib/assertions';
+import { VectorStoreCreatorStack } from '../../../lib/rag/vector-store/vector-store-creator';
+import ConfigParser from '../mocks/ConfigParser';
+import { Vpc } from '../../../lib/networking/vpc';
+import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
+import * as lambda from 'aws-cdk-lib/aws-lambda';
+import * as ssm from 'aws-cdk-lib/aws-ssm';
+
+// Use existing mock directory for Lambda code asset
+const TEST_MOCK_DIR = './test/cdk/mocks/layers';
+
+describe('VectorStoreCreator IAM Self-Targeting Prevention', () => {
+ let app: App;
+ let stack: Stack;
+ let template: Template;
+
+ beforeAll(() => {
+ const config = ConfigParser.parseConfig();
+ // Override the deployer path to use existing mock directory
+ config.vectorStoreDeployerPath = TEST_MOCK_DIR;
+ app = new App();
+ stack = new Stack(app, 'TestStack', {
+ env: {
+ account: '012345678901',
+ region: config.region,
+ },
+ });
+
+ // Create mock VPC
+ const vpc = new Vpc(stack, 'TestVpc', { config });
+
+ // Create mock DynamoDB table output
+ const mockTable = new dynamodb.Table(stack, 'MockRagVectorStoreTable', {
+ partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
+ });
+
+ // Create mock SSM parameter for RAG Lambda execution role
+ new ssm.StringParameter(stack, 'MockRagLambdaExecutionRole', {
+ parameterName: `${config.deploymentPrefix}/roles/LisaRAGLambdaExecutionRole`,
+ stringValue: 'arn:aws:iam::012345678901:role/mock-rag-lambda-execution-role',
+ });
+
+ // Create mock SSM parameter for CDK layer
+ new ssm.StringParameter(stack, 'MockCdkLayer', {
+ parameterName: `${config.deploymentPrefix}/layerVersion/cdk`,
+ stringValue: `arn:aws:lambda:${config.region}:012345678901:layer:mock-cdk-layer:1`,
+ });
+
+ // Create mock layers
+ const mockLayer = lambda.LayerVersion.fromLayerVersionArn(
+ stack,
+ 'MockLayer',
+ `arn:aws:lambda:${config.region}:012345678901:layer:mock-layer:1`
+ );
+
+ // Create VectorStoreCreatorStack
+ new VectorStoreCreatorStack(stack, 'VectorStoreCreator', {
+ config,
+ ragVectorStoreTable: {
+ value: mockTable.tableArn,
+ } as any,
+ vpc,
+ baseEnvironment: {
+ LISA_RAG_CREATE_STATE_MACHINE_ARN_PARAMETER: '/test/create-state-machine',
+ LISA_RAG_DELETE_STATE_MACHINE_ARN_PARAMETER: '/test/delete-state-machine',
+ },
+ layers: [mockLayer],
+ });
+
+ template = Template.fromStack(stack);
+ });
+
+ describe('IAM Policy Self-Targeting Prevention', () => {
+ it('should prevent the VectorStoreCreator role from modifying itself', () => {
+ // Find the VectorStoreCreator role
+ const roles = template.findResources('AWS::IAM::Role', {
+ Properties: {
+ AssumeRolePolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Principal: {
+ Service: 'lambda.amazonaws.com',
+ },
+ }),
+ ]),
+ },
+ ManagedPolicyArns: Match.arrayWith([
+ Match.objectLike({
+ 'Fn::Join': Match.arrayWith([
+ Match.arrayWith([
+ Match.stringLikeRegexp('.*AWSCloudFormationFullAccess'),
+ ]),
+ ]),
+ }),
+ ]),
+ },
+ });
+
+ // Should find exactly one VectorStoreCreator role
+ const roleKeys = Object.keys(roles);
+ expect(roleKeys.length).toBeGreaterThan(0);
+
+ // Get the role logical ID
+ const roleLogicalId = roleKeys[0];
+
+ // The policies are added via addToPolicy which creates separate AWS::IAM::Policy resources
+ // Find all IAM policies
+ const allPolicies = template.findResources('AWS::IAM::Policy');
+
+ // Find the policy attached to our role
+ let targetPolicy: any = null;
+ for (const [, policy] of Object.entries(allPolicies)) {
+ const policyProps = (policy as any).Properties;
+ if (policyProps.Roles && policyProps.Roles.some((role: any) =>
+ role.Ref === roleLogicalId
+ )) {
+ targetPolicy = policyProps;
+ break;
+ }
+ }
+
+ expect(targetPolicy).toBeDefined();
+ expect(targetPolicy.PolicyDocument).toBeDefined();
+ expect(targetPolicy.PolicyDocument.Statement).toBeDefined();
+
+ // Find the policy statement that contains permission mutation actions
+ const permissionMutationStatement = targetPolicy.PolicyDocument.Statement.find((stmt: any) =>
+ Array.isArray(stmt.Action) && stmt.Action.includes('iam:AttachRolePolicy')
+ );
+
+ expect(permissionMutationStatement).toBeDefined();
+ expect(permissionMutationStatement.Condition).toBeDefined();
+ expect(permissionMutationStatement.Condition.ArnNotEquals).toBeDefined();
+ expect(permissionMutationStatement.Condition.ArnNotEquals['iam:ResourceArn']).toBeDefined();
+
+ // Verify the condition references the role's ARN
+ const arnNotEqualsValue = permissionMutationStatement.Condition.ArnNotEquals['iam:ResourceArn'];
+ expect(arnNotEqualsValue).toEqual(
+ expect.objectContaining({
+ 'Fn::GetAtt': expect.arrayContaining([roleLogicalId, 'Arn']),
+ })
+ );
+ });
+
+ it('should allow role creation for vector stores with naming pattern restriction', () => {
+ // Find the VectorStoreCreator role
+ const roles = template.findResources('AWS::IAM::Role', {
+ Properties: {
+ AssumeRolePolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Principal: {
+ Service: 'lambda.amazonaws.com',
+ },
+ }),
+ ]),
+ },
+ ManagedPolicyArns: Match.arrayWith([
+ Match.objectLike({
+ 'Fn::Join': Match.arrayWith([
+ Match.arrayWith([
+ Match.stringLikeRegexp('.*AWSCloudFormationFullAccess'),
+ ]),
+ ]),
+ }),
+ ]),
+ },
+ });
+
+ const roleKeys = Object.keys(roles);
+ expect(roleKeys.length).toBeGreaterThan(0);
+ const roleLogicalId = roleKeys[0];
+
+ // Find the policy attached to our role
+ const allPolicies = template.findResources('AWS::IAM::Policy');
+ let targetPolicy: any = null;
+ for (const [, policy] of Object.entries(allPolicies)) {
+ const policyProps = (policy as any).Properties;
+ if (policyProps.Roles && policyProps.Roles.some((role: any) =>
+ role.Ref === roleLogicalId
+ )) {
+ targetPolicy = policyProps;
+ break;
+ }
+ }
+
+ expect(targetPolicy).toBeDefined();
+
+ // Find the policy statement that allows role creation
+ const roleManagementStatement = targetPolicy.PolicyDocument.Statement.find((stmt: any) =>
+ Array.isArray(stmt.Action) && stmt.Action.includes('iam:CreateRole')
+ );
+
+ expect(roleManagementStatement).toBeDefined();
+ expect(roleManagementStatement.Resource).toBeDefined();
+ expect(Array.isArray(roleManagementStatement.Resource)).toBe(true);
+
+ // Verify resources follow vector store naming pattern
+ const resources = roleManagementStatement.Resource;
+ expect(resources.some((r: any) =>
+ typeof r === 'string' ? r.includes('vector') : r['Fn::Join']?.[1]?.some((part: string) => part.includes('vector'))
+ )).toBe(true);
+ });
+
+ it('should restrict AssumeRole to CDK bootstrap roles only', () => {
+ // Find the VectorStoreCreator role
+ const roles = template.findResources('AWS::IAM::Role', {
+ Properties: {
+ AssumeRolePolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Principal: {
+ Service: 'lambda.amazonaws.com',
+ },
+ }),
+ ]),
+ },
+ ManagedPolicyArns: Match.arrayWith([
+ Match.objectLike({
+ 'Fn::Join': Match.arrayWith([
+ Match.arrayWith([
+ Match.stringLikeRegexp('.*AWSCloudFormationFullAccess'),
+ ]),
+ ]),
+ }),
+ ]),
+ },
+ });
+
+ const roleKeys = Object.keys(roles);
+ expect(roleKeys.length).toBeGreaterThan(0);
+ const roleLogicalId = roleKeys[0];
+
+ // Find the policy attached to our role
+ const allPolicies = template.findResources('AWS::IAM::Policy');
+ let targetPolicy: any = null;
+ for (const [, policy] of Object.entries(allPolicies)) {
+ const policyProps = (policy as any).Properties;
+ if (policyProps.Roles && policyProps.Roles.some((role: any) =>
+ role.Ref === roleLogicalId
+ )) {
+ targetPolicy = policyProps;
+ break;
+ }
+ }
+
+ expect(targetPolicy).toBeDefined();
+
+ // Find the policy statement that allows AssumeRole
+ const assumeRoleStatement = targetPolicy.PolicyDocument.Statement.find((stmt: any) =>
+ stmt.Action === 'iam:AssumeRole'
+ );
+
+ expect(assumeRoleStatement).toBeDefined();
+ expect(assumeRoleStatement.Resource).toBeDefined();
+ expect(Array.isArray(assumeRoleStatement.Resource)).toBe(true);
+
+ // Verify resources are CDK bootstrap roles
+ const resources = assumeRoleStatement.Resource;
+ expect(resources.every((r: any) => {
+ const arnString = typeof r === 'string' ? r : r['Fn::Join']?.[1]?.join('');
+ return arnString?.includes('cdk-') && arnString?.includes('deploy-role');
+ })).toBe(true);
+ });
+
+ it('should allow PassRole only to specific AWS services', () => {
+ // Find the VectorStoreCreator role
+ const roles = template.findResources('AWS::IAM::Role', {
+ Properties: {
+ AssumeRolePolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Principal: {
+ Service: 'lambda.amazonaws.com',
+ },
+ }),
+ ]),
+ },
+ ManagedPolicyArns: Match.arrayWith([
+ Match.objectLike({
+ 'Fn::Join': Match.arrayWith([
+ Match.arrayWith([
+ Match.stringLikeRegexp('.*AWSCloudFormationFullAccess'),
+ ]),
+ ]),
+ }),
+ ]),
+ },
+ });
+
+ const roleKeys = Object.keys(roles);
+ expect(roleKeys.length).toBeGreaterThan(0);
+ const roleLogicalId = roleKeys[0];
+
+ // Find the policy attached to our role
+ const allPolicies = template.findResources('AWS::IAM::Policy');
+ let targetPolicy: any = null;
+ for (const [, policy] of Object.entries(allPolicies)) {
+ const policyProps = (policy as any).Properties;
+ if (policyProps.Roles && policyProps.Roles.some((role: any) =>
+ role.Ref === roleLogicalId
+ )) {
+ targetPolicy = policyProps;
+ break;
+ }
+ }
+
+ expect(targetPolicy).toBeDefined();
+
+ // Find the policy statement that allows PassRole
+ const passRoleStatement = targetPolicy.PolicyDocument.Statement.find((stmt: any) =>
+ stmt.Action === 'iam:PassRole'
+ );
+
+ expect(passRoleStatement).toBeDefined();
+ expect(passRoleStatement.Condition).toBeDefined();
+ expect(passRoleStatement.Condition.StringEquals).toBeDefined();
+ expect(passRoleStatement.Condition.StringEquals['iam:PassedToService']).toBeDefined();
+
+ // Verify allowed services
+ const allowedServices = passRoleStatement.Condition.StringEquals['iam:PassedToService'];
+ expect(allowedServices).toContain('cloudformation.amazonaws.com');
+ expect(allowedServices).toContain('lambda.amazonaws.com');
+ expect(allowedServices).toContain('events.amazonaws.com');
+ });
+ });
+
+ describe('IAM Policy Structure Validation', () => {
+ it('should create the VectorStoreCreator Lambda function with correct role', () => {
+ // Verify Lambda function exists
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Runtime: Match.stringLikeRegexp('nodejs.*'),
+ Timeout: 900, // 15 minutes
+ MemorySize: 1024,
+ });
+ });
+
+ it('should grant necessary permissions for CloudFormation operations', () => {
+ // Verify the role has CloudFormation managed policy
+ const roles = template.findResources('AWS::IAM::Role', {
+ Properties: {
+ ManagedPolicyArns: Match.arrayWith([
+ Match.objectLike({
+ 'Fn::Join': Match.arrayWith([
+ Match.arrayWith([
+ Match.stringLikeRegexp('.*AWSCloudFormationFullAccess'),
+ ]),
+ ]),
+ }),
+ ]),
+ },
+ });
+
+ expect(Object.keys(roles).length).toBeGreaterThan(0);
+ });
+
+ it('should allow service-linked role creation for required services', () => {
+ // Find the VectorStoreCreator role
+ const roles = template.findResources('AWS::IAM::Role', {
+ Properties: {
+ AssumeRolePolicyDocument: {
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Principal: {
+ Service: 'lambda.amazonaws.com',
+ },
+ }),
+ ]),
+ },
+ ManagedPolicyArns: Match.arrayWith([
+ Match.objectLike({
+ 'Fn::Join': Match.arrayWith([
+ Match.arrayWith([
+ Match.stringLikeRegexp('.*AWSCloudFormationFullAccess'),
+ ]),
+ ]),
+ }),
+ ]),
+ },
+ });
+
+ const roleKeys = Object.keys(roles);
+ expect(roleKeys.length).toBeGreaterThan(0);
+ const roleLogicalId = roleKeys[0];
+
+ // Find the policy attached to our role
+ const allPolicies = template.findResources('AWS::IAM::Policy');
+ let targetPolicy: any = null;
+ for (const [, policy] of Object.entries(allPolicies)) {
+ const policyProps = (policy as any).Properties;
+ if (policyProps.Roles && policyProps.Roles.some((role: any) =>
+ role.Ref === roleLogicalId
+ )) {
+ targetPolicy = policyProps;
+ break;
+ }
+ }
+
+ expect(targetPolicy).toBeDefined();
+
+ // Find the policy statement that allows CreateServiceLinkedRole
+ const serviceLinkedRoleStatement = targetPolicy.PolicyDocument.Statement.find((stmt: any) =>
+ stmt.Action === 'iam:CreateServiceLinkedRole'
+ );
+
+ expect(serviceLinkedRoleStatement).toBeDefined();
+ expect(serviceLinkedRoleStatement.Condition).toBeDefined();
+ expect(serviceLinkedRoleStatement.Condition.StringEquals).toBeDefined();
+ expect(serviceLinkedRoleStatement.Condition.StringEquals['iam:AWSServiceName']).toBeDefined();
+
+ // Verify allowed services
+ const allowedServices = serviceLinkedRoleStatement.Condition.StringEquals['iam:AWSServiceName'];
+ expect(allowedServices).toContain('opensearchservice.amazonaws.com');
+ expect(allowedServices).toContain('rds.amazonaws.com');
+ });
+ });
+});
diff --git a/test/cdk/mocks/config-test.yaml b/test/cdk/mocks/config-test.yaml
index 86e4d7a62..e7dbebf27 100644
--- a/test/cdk/mocks/config-test.yaml
+++ b/test/cdk/mocks/config-test.yaml
@@ -36,6 +36,8 @@ dev:
deployRag: true
deployUI: true
deployDocs: true
+ useCustomBranding: false
+ customDisplayName: LISA
authConfig:
authority: test
clientId: test
@@ -58,3 +60,4 @@ dev:
baseImage: openai/vllm:latest
litellmConfig:
db_key: sk-012345 #pragma: allowlist secret
+ iamRdsAuth: true
diff --git a/test/cdk/mocks/layers/index.js b/test/cdk/mocks/layers/index.js
new file mode 100644
index 000000000..c49346bc9
--- /dev/null
+++ b/test/cdk/mocks/layers/index.js
@@ -0,0 +1,23 @@
+/**
+ 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.
+*/
+
+// Mock Lambda handler for testing
+exports.handler = async () => {
+ return {
+ statusCode: 200,
+ body: JSON.stringify({ message: 'Mock handler' }),
+ };
+};
diff --git a/test/cdk/stacks/__baselines__/LisaApiBase.json b/test/cdk/stacks/__baselines__/LisaApiBase.json
index 63d8b2aa6..863a334f4 100644
--- a/test/cdk/stacks/__baselines__/LisaApiBase.json
+++ b/test/cdk/stacks/__baselines__/LisaApiBase.json
@@ -1,5 +1,269 @@
{
+ "Parameters": {
+ "SsmParameterValuedevtestlisalisabucketbucketaccesslogsC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/dev/test-lisa/lisa/bucket/bucket-access-logs"
+ },
+ "SsmParameterValuedevtestlisalisalayerVersioncommonC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/dev/test-lisa/lisa/layerVersion/common"
+ },
+ "SsmParameterValuedevtestlisalisalayerVersionauthorizerC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/dev/test-lisa/lisa/layerVersion/authorizer"
+ },
+ "BootstrapVersion": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/cdk-bootstrap/hnb659fds/version",
+ "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
+ }
+ },
"Resources": {
+ "GeneratedImagesBucketC3465633": {
+ "Type": "AWS::S3::Bucket",
+ "Properties": {
+ "BucketEncryption": {
+ "ServerSideEncryptionConfiguration": [
+ {
+ "ServerSideEncryptionByDefault": {
+ "SSEAlgorithm": "AES256"
+ }
+ }
+ ]
+ },
+ "CorsConfiguration": {
+ "CorsRules": [
+ {
+ "AllowedHeaders": [
+ "*"
+ ],
+ "AllowedMethods": [
+ "GET",
+ "POST"
+ ],
+ "AllowedOrigins": [
+ "*"
+ ],
+ "ExposedHeaders": [
+ "Access-Control-Allow-Origin"
+ ]
+ }
+ ]
+ },
+ "LoggingConfiguration": {
+ "DestinationBucketName": {
+ "Fn::Select": [
+ 0,
+ {
+ "Fn::Split": [
+ "/",
+ {
+ "Fn::Select": [
+ 5,
+ {
+ "Fn::Split": [
+ ":",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisabucketbucketaccesslogsC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "LogFilePrefix": "logs/generated-images-bucket/"
+ },
+ "PublicAccessBlockConfiguration": {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true
+ },
+ "Tags": [
+ {
+ "Key": "aws-cdk:auto-delete-objects",
+ "Value": "true"
+ }
+ ]
+ },
+ "UpdateReplacePolicy": "Delete",
+ "DeletionPolicy": "Delete"
+ },
+ "GeneratedImagesBucketPolicyA72A6FC9": {
+ "Type": "AWS::S3::BucketPolicy",
+ "Properties": {
+ "Bucket": {
+ "Ref": "GeneratedImagesBucketC3465633"
+ },
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "s3:*",
+ "Condition": {
+ "Bool": {
+ "aws:SecureTransport": "false"
+ }
+ },
+ "Effect": "Deny",
+ "Principal": {
+ "AWS": "*"
+ },
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "GeneratedImagesBucketC3465633",
+ "Arn"
+ ]
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "GeneratedImagesBucketC3465633",
+ "Arn"
+ ]
+ },
+ "/*"
+ ]
+ ]
+ }
+ ]
+ },
+ {
+ "Action": [
+ "s3:PutBucketPolicy",
+ "s3:GetBucket*",
+ "s3:List*",
+ "s3:DeleteObject*"
+ ],
+ "Effect": "Allow",
+ "Principal": {
+ "AWS": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
+ "Arn"
+ ]
+ }
+ },
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "GeneratedImagesBucketC3465633",
+ "Arn"
+ ]
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "GeneratedImagesBucketC3465633",
+ "Arn"
+ ]
+ },
+ "/*"
+ ]
+ ]
+ }
+ ]
+ }
+ ],
+ "Version": "2012-10-17"
+ }
+ }
+ },
+ "GeneratedImagesBucketAutoDeleteObjectsCustomResource907225F0": {
+ "Type": "Custom::S3AutoDeleteObjects",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F",
+ "Arn"
+ ]
+ },
+ "BucketName": {
+ "Ref": "GeneratedImagesBucketC3465633"
+ }
+ },
+ "DependsOn": [
+ "GeneratedImagesBucketPolicyA72A6FC9"
+ ],
+ "UpdateReplacePolicy": "Delete",
+ "DeletionPolicy": "Delete"
+ },
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ]
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ }
+ ]
+ }
+ },
+ "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip"
+ },
+ "Timeout": 900,
+ "MemorySize": 128,
+ "Handler": "index.handler",
+ "Role": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
+ "Arn"
+ ]
+ },
+ "Runtime": "nodejs22.x",
+ "Description": {
+ "Fn::Join": [
+ "",
+ [
+ "Lambda function for auto-deleting objects in ",
+ {
+ "Ref": "GeneratedImagesBucketC3465633"
+ },
+ " S3 bucket."
+ ]
+ ]
+ }
+ },
+ "DependsOn": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
+ ]
+ },
+ "GeneratedImagesBucketNameParameter40BFED5D": {
+ "Type": "AWS::SSM::Parameter",
+ "Properties": {
+ "Description": "S3 bucket name for generated images and videos",
+ "Name": "/dev/test-lisa/lisa/generatedImagesBucketName",
+ "Type": "String",
+ "Value": {
+ "Ref": "GeneratedImagesBucketC3465633"
+ }
+ }
+ },
"TokenTable3625D248": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
@@ -14,6 +278,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "username-index",
@@ -39,8 +304,8 @@
},
"TableName": "test-lisa-LISAApiBaseTokenTable"
},
- "UpdateReplacePolicy": "Delete",
- "DeletionPolicy": "Delete"
+ "UpdateReplacePolicy": "Retain",
+ "DeletionPolicy": "RetainExceptOnCreate"
},
"TokenTableNameParameter02DD6D39": {
"Type": "AWS::SSM::Parameter",
@@ -59,6 +324,101 @@
"Name": "test-lisa-management-events"
}
},
+ "LisaApiBasemanagementKeyRotationRole5AABDC6F": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LisaApiBasemanagementKeyRotationRoleDefaultPolicyC8457441": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "events:PutEvents",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::GetAtt": [
+ "LisaApiBasemanagementEventBus92EB8785",
+ "Arn"
+ ]
+ }
+ },
+ {
+ "Action": [
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:DescribeSecret"
+ ],
+ "Effect": "Allow",
+ "Resource": {
+ "Ref": "LisaApiBasemanagementKeySecret88DE7B9D"
+ }
+ },
+ {
+ "Action": [
+ "secretsmanager:PutSecretValue",
+ "secretsmanager:UpdateSecret",
+ "secretsmanager:UpdateSecretVersionStage"
+ ],
+ "Effect": "Allow",
+ "Resource": {
+ "Ref": "LisaApiBasemanagementKeySecret88DE7B9D"
+ }
+ },
+ {
+ "Action": [
+ "secretsmanager:DescribeSecret",
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:PutSecretValue",
+ "secretsmanager:UpdateSecretVersionStage"
+ ],
+ "Effect": "Allow",
+ "Resource": {
+ "Ref": "LisaApiBasemanagementKeySecret88DE7B9D"
+ }
+ },
+ {
+ "Action": "secretsmanager:GetRandomPassword",
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LisaApiBasemanagementKeyRotationRoleDefaultPolicyC8457441",
+ "Roles": [
+ {
+ "Ref": "LisaApiBasemanagementKeyRotationRole5AABDC6F"
+ }
+ ]
+ }
+ },
"LisaApiBasemanagementKeySecret88DE7B9D": {
"Type": "AWS::SecretsManager::Secret",
"Properties": {
@@ -124,108 +484,12 @@
}
}
},
- "LisaApiBasemanagementKeyRotationRole5AABDC6F": {
- "Type": "AWS::IAM::Role",
- "Properties": {
- "AssumeRolePolicyDocument": {
- "Statement": [
- {
- "Action": "sts:AssumeRole",
- "Effect": "Allow",
- "Principal": {
- "Service": "lambda.amazonaws.com"
- }
- }
- ],
- "Version": "2012-10-17"
- },
- "ManagedPolicyArns": [
- {
- "Fn::Join": [
- "",
- [
- "arn:",
- {
- "Ref": "AWS::Partition"
- },
- ":iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
- ]
- ]
- }
- ],
- "Policies": [
- {
- "PolicyDocument": {
- "Statement": [
- {
- "Action": [
- "secretsmanager:DescribeSecret",
- "secretsmanager:GetSecretValue",
- "secretsmanager:PutSecretValue",
- "secretsmanager:UpdateSecretVersionStage"
- ],
- "Effect": "Allow",
- "Resource": {
- "Ref": "LisaApiBasemanagementKeySecret88DE7B9D"
- }
- },
- {
- "Action": "events:PutEvents",
- "Effect": "Allow",
- "Resource": {
- "Fn::GetAtt": [
- "LisaApiBasemanagementEventBus92EB8785",
- "Arn"
- ]
- }
- }
- ],
- "Version": "2012-10-17"
- },
- "PolicyName": "SecretsManagerRotation"
- }
- ]
- }
- },
- "LisaApiBasemanagementKeyRotationRoleDefaultPolicyC8457441": {
- "Type": "AWS::IAM::Policy",
- "Properties": {
- "PolicyDocument": {
- "Statement": [
- {
- "Action": [
- "secretsmanager:DescribeSecret",
- "secretsmanager:GetSecretValue",
- "secretsmanager:PutSecretValue",
- "secretsmanager:UpdateSecretVersionStage"
- ],
- "Effect": "Allow",
- "Resource": {
- "Ref": "LisaApiBasemanagementKeySecret88DE7B9D"
- }
- },
- {
- "Action": "secretsmanager:GetRandomPassword",
- "Effect": "Allow",
- "Resource": "*"
- }
- ],
- "Version": "2012-10-17"
- },
- "PolicyName": "LisaApiBasemanagementKeyRotationRoleDefaultPolicyC8457441",
- "Roles": [
- {
- "Ref": "LisaApiBasemanagementKeyRotationRole5AABDC6F"
- }
- ]
- }
- },
"LisaApiBasemanagementKeyRotationLambda599C8C2D": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -383,6 +647,178 @@
}
}
},
+ "IamAuthSetupRole24F7520D": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "IamAuthSetupRoleDefaultPolicy08F92101": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:DeleteSecret"
+ ],
+ "Effect": "Allow",
+ "Resource": "arn:aws:secretsmanager:us-iso-east-1:012345678901:secret:*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "IamAuthSetupRoleDefaultPolicy08F92101",
+ "Roles": [
+ {
+ "Ref": "IamAuthSetupRole24F7520D"
+ }
+ ]
+ }
+ },
+ "IamAuthSetupFnC47BD2C8": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
+ },
+ "FunctionName": "test-lisa-dev-iam_auth_setup",
+ "Handler": "utilities.db_setup_iam_auth.handler",
+ "Layers": [
+ {
+ "Ref": "SsmParameterValuedevtestlisalisalayerVersioncommonC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ],
+ "MemorySize": 256,
+ "Role": {
+ "Fn::GetAtt": [
+ "IamAuthSetupRole24F7520D",
+ "Arn"
+ ]
+ },
+ "Runtime": "python3.13",
+ "Timeout": 120,
+ "VpcConfig": {
+ "SecurityGroupIds": [
+ {
+ "Fn::ImportValue": "LisaNetworking:ExportsOutputFnGetAttVpcLambdaSecurityGroup184B54BDGroupIdB1374FFB"
+ }
+ ],
+ "SubnetIds": [
+ {
+ "Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPCprivateSubnet1Subnet29B9FADC0739E75F"
+ },
+ {
+ "Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPCprivateSubnet2Subnet63498DC142E639BD"
+ }
+ ]
+ }
+ },
+ "DependsOn": [
+ "IamAuthSetupRoleDefaultPolicy08F92101",
+ "IamAuthSetupRole24F7520D"
+ ]
+ },
+ "IamAuthSetupFnArnParamE1FB71BE": {
+ "Type": "AWS::SSM::Parameter",
+ "Properties": {
+ "Description": "ARN of the shared IAM auth setup Lambda for PGVector databases",
+ "Name": "/dev/test-lisa/lisa/iamAuthSetupFnArn",
+ "Type": "String",
+ "Value": {
+ "Fn::GetAtt": [
+ "IamAuthSetupFnC47BD2C8",
+ "Arn"
+ ]
+ }
+ }
+ },
+ "IamAuthSetupRoleArnParamA4AB77FF": {
+ "Type": "AWS::SSM::Parameter",
+ "Properties": {
+ "Description": "ARN of the IAM auth setup Lambda role for granting secret permissions",
+ "Name": "/dev/test-lisa/lisa/iamAuthSetupRoleArn",
+ "Type": "String",
+ "Value": {
+ "Fn::GetAtt": [
+ "IamAuthSetupRole24F7520D",
+ "Arn"
+ ]
+ }
+ }
+ },
+ "ApiGatewayCloudWatchRole86F22A4D": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "apigateway.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "ApiGatewayAccount": {
+ "Type": "AWS::ApiGateway::Account",
+ "Properties": {
+ "CloudWatchRoleArn": {
+ "Fn::GetAtt": [
+ "ApiGatewayCloudWatchRole86F22A4D",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "ApiGatewayCloudWatchRole86F22A4D"
+ ]
+ },
"LisaApiAuthorizerAuthorizerLambdaServiceRoleFC9BFB65": {
"Type": "AWS::IAM::Role",
"Properties": {
@@ -528,7 +964,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "REST API and UI Authorization Lambda",
"Environment": {
@@ -691,7 +1127,10 @@
]
},
"Name": "LisaApiBase-RestApi"
- }
+ },
+ "DependsOn": [
+ "ApiGatewayAccount"
+ ]
},
"LisaApiBaseRestApiCloudWatchRoleC64221F2": {
"Type": "AWS::IAM::Role",
@@ -723,6 +1162,9 @@
}
]
},
+ "DependsOn": [
+ "ApiGatewayAccount"
+ ],
"UpdateReplacePolicy": "Retain",
"DeletionPolicy": "Retain"
},
@@ -737,12 +1179,13 @@
}
},
"DependsOn": [
+ "ApiGatewayAccount",
"LisaApiBaseRestApiBD7EFE41"
],
"UpdateReplacePolicy": "Retain",
"DeletionPolicy": "Retain"
},
- "LisaApiBaseRestApiDeployment0EFEF450a469aadc1179465edb3265abd2173fed": {
+ "LisaApiBaseRestApiDeployment0EFEF450b6f4e90161d0dfc05320ee29d387215a": {
"Type": "AWS::ApiGateway::Deployment",
"Properties": {
"Description": "Base API Gateway for LISA.",
@@ -751,6 +1194,7 @@
}
},
"DependsOn": [
+ "ApiGatewayAccount",
"LisaApiBaseRestApiOPTIONSB08FDC9B"
],
"Metadata": {
@@ -761,7 +1205,7 @@
"Type": "AWS::ApiGateway::Stage",
"Properties": {
"DeploymentId": {
- "Ref": "LisaApiBaseRestApiDeployment0EFEF450a469aadc1179465edb3265abd2173fed"
+ "Ref": "LisaApiBaseRestApiDeployment0EFEF450b6f4e90161d0dfc05320ee29d387215a"
},
"MethodSettings": [
{
@@ -778,6 +1222,7 @@
"StageName": "dev"
},
"DependsOn": [
+ "ApiGatewayAccount",
"LisaApiBaseRestApiAccount63CB3D5A"
]
},
@@ -822,22 +1267,10 @@
"RestApiId": {
"Ref": "LisaApiBaseRestApiBD7EFE41"
}
- }
- }
- },
- "Parameters": {
- "SsmParameterValuedevtestlisalisalayerVersioncommonC96584B6F00A464EAD1953AFF4B05118Parameter": {
- "Type": "AWS::SSM::Parameter::Value",
- "Default": "/dev/test-lisa/lisa/layerVersion/common"
- },
- "SsmParameterValuedevtestlisalisalayerVersionauthorizerC96584B6F00A464EAD1953AFF4B05118Parameter": {
- "Type": "AWS::SSM::Parameter::Value",
- "Default": "/dev/test-lisa/lisa/layerVersion/authorizer"
- },
- "BootstrapVersion": {
- "Type": "AWS::SSM::Parameter::Value",
- "Default": "/cdk-bootstrap/hnb659fds/version",
- "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
+ },
+ "DependsOn": [
+ "ApiGatewayAccount"
+ ]
}
},
"Outputs": {
diff --git a/test/cdk/stacks/__baselines__/LisaApiDeployment.json b/test/cdk/stacks/__baselines__/LisaApiDeployment.json
index adca5779a..80d8cc343 100644
--- a/test/cdk/stacks/__baselines__/LisaApiDeployment.json
+++ b/test/cdk/stacks/__baselines__/LisaApiDeployment.json
@@ -1,6 +1,6 @@
{
"Resources": {
- "Deployment176823892944967617009": {
+ "Deployment177033172649606CD2074": {
"Type": "AWS::ApiGateway::Deployment",
"Properties": {
"RestApiId": {
diff --git a/test/cdk/stacks/__baselines__/LisaChat.json b/test/cdk/stacks/__baselines__/LisaChat.json
index 382c8a3db..688ee4434 100644
--- a/test/cdk/stacks/__baselines__/LisaChat.json
+++ b/test/cdk/stacks/__baselines__/LisaChat.json
@@ -18,6 +18,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "byOwner",
@@ -134,7 +135,7 @@
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Fn::GetAtt": [
- "McpApiLisaChatmcpserverlist6AA9C7D0",
+ "McpApiLisaChatmcpserverlistmcpservers010AAA79",
"Arn"
]
},
@@ -163,7 +164,7 @@
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Fn::GetAtt": [
- "McpApiLisaChatmcpserverlist6AA9C7D0",
+ "McpApiLisaChatmcpserverlistmcpservers010AAA79",
"Arn"
]
},
@@ -208,7 +209,7 @@
":apigateway:us-iso-east-1:lambda:path/2015-03-31/functions/",
{
"Fn::GetAtt": [
- "McpApiLisaChatmcpserverlist6AA9C7D0",
+ "McpApiLisaChatmcpserverlistmcpservers010AAA79",
"Arn"
]
},
@@ -894,12 +895,12 @@
]
}
},
- "McpApiLisaChatmcpserverlist6AA9C7D0": {
+ "McpApiLisaChatmcpserverlistmcpservers010AAA79": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Lists available mcp servers for user",
"Environment": {
@@ -911,8 +912,8 @@
"MCP_SERVERS_BY_OWNER_INDEX_NAME": "byOwner"
}
},
- "FunctionName": "LisaChat-mcp_server-list",
- "Handler": "mcp_server.lambda_functions.list",
+ "FunctionName": "LisaChat-mcp_server-list_mcp_servers",
+ "Handler": "mcp_server.lambda_functions.list_mcp_servers",
"Layers": [
{
"Ref": "SsmParameterValuedevtestlisalisalayerVersioncommonC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -951,12 +952,35 @@
"McpApiLisaMcpServerApiLambdaExecutionRoleAB722D7F"
]
},
+ "McpApiLisaChatmcpserverlistmcpserversLogRetentionBA24AF6A": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpApiLisaChatmcpserverlistmcpservers010AAA79"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpApiLisaChatmcpserverget2921E1D3": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Returns the selected mcp server",
"Environment": {
@@ -1008,12 +1032,35 @@
"McpApiLisaMcpServerApiLambdaExecutionRoleAB722D7F"
]
},
+ "McpApiLisaChatmcpservergetLogRetentionD627715D": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpApiLisaChatmcpserverget2921E1D3"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpApiLisaChatmcpservercreate8CAC0C43": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Creates the mcp server",
"Environment": {
@@ -1065,12 +1112,35 @@
"McpApiLisaMcpServerApiLambdaExecutionRoleAB722D7F"
]
},
+ "McpApiLisaChatmcpservercreateLogRetention22E7E4B5": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpApiLisaChatmcpservercreate8CAC0C43"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpApiLisaChatmcpserverdeleteB1B6EC65": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Deletes selected mcp server",
"Environment": {
@@ -1122,12 +1192,35 @@
"McpApiLisaMcpServerApiLambdaExecutionRoleAB722D7F"
]
},
+ "McpApiLisaChatmcpserverdeleteLogRetention06F88A7B": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpApiLisaChatmcpserverdeleteB1B6EC65"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpApiLisaChatmcpserverupdate2F094A10": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Creates or updates selected mcp server",
"Environment": {
@@ -1179,6 +1272,106 @@
"McpApiLisaMcpServerApiLambdaExecutionRoleAB722D7F"
]
},
+ "McpApiLisaChatmcpserverupdateLogRetention18E65B84": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpApiLisaChatmcpserverupdate2F094A10"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy"
+ ],
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Handler": "index.handler",
+ "Runtime": "nodejs22.x",
+ "Timeout": 900,
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"
+ },
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ ]
+ },
"ConfigurationApiConfigurationTable4B2B7EE1": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
@@ -1193,6 +1386,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "configScope",
@@ -1429,7 +1623,7 @@
{
"Ref": "ConfigurationApiConfigurationTable4B2B7EE1"
},
- "\",\"Item\":{\"versionId\":{\"N\":\"0\"},\"changedBy\":{\"S\":\"System\"},\"configScope\":{\"S\":\"global\"},\"changeReason\":{\"S\":\"Initial deployment default config\"},\"createdAt\":{\"S\":\"1768238930\"},\"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\":\"False\"},\"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\":\"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\":\"\"}}}}}}}}"
]
]
},
@@ -1741,7 +1935,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Get configuration",
"Environment": {
@@ -1752,6 +1946,7 @@
"FASTAPI_ENDPOINT": {
"Ref": "SsmParameterValuedevtestlisalisaserveendpointC96584B6F00A464EAD1953AFF4B05118Parameter"
},
+ "ADMIN_GROUP": "",
"MCP_SERVERS_TABLE_NAME": {
"Fn::GetAtt": [
"McpApiMcpServersTableNameParameter40CBF850",
@@ -1800,12 +1995,35 @@
"ConfigurationApiLisaConfigurationApiLambdaExecutionRole52653FA6"
]
},
+ "ConfigurationApiLisaChatconfigurationgetconfigurationLogRetention12552FB7": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "ConfigurationApiLisaChatconfigurationgetconfiguration19178EAF"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"ConfigurationApiLisaChatconfigurationupdateconfigurationB328F68F": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Updates config data",
"Environment": {
@@ -1816,6 +2034,7 @@
"FASTAPI_ENDPOINT": {
"Ref": "SsmParameterValuedevtestlisalisaserveendpointC96584B6F00A464EAD1953AFF4B05118Parameter"
},
+ "ADMIN_GROUP": "",
"MCP_SERVERS_TABLE_NAME": {
"Fn::GetAtt": [
"McpApiMcpServersTableNameParameter40CBF850",
@@ -1864,12 +2083,35 @@
"ConfigurationApiLisaConfigurationApiLambdaExecutionRole52653FA6"
]
},
+ "ConfigurationApiLisaChatconfigurationupdateconfigurationLogRetention6629BEF8": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "ConfigurationApiLisaChatconfigurationupdateconfigurationB328F68F"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"AWS679f53fac002430cb0da5b7982bd22872D164C4C": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "bc8ef58951f4c88e641dd23090e9d2e2e61c7530a07960e767522941e959b625.zip"
+ "S3Key": "a38f8a5114ae969de815d5c66b3ddbd646445697a789f7b16424b4ea541d4ff9.zip"
},
"Handler": "index.handler",
"Role": {
@@ -1904,6 +2146,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "byUserId",
@@ -3091,34 +3334,20 @@
]
},
{
- "Action": [
- "s3:GetObject*",
- "s3:GetBucket*",
- "s3:List*"
- ],
+ "Action": "s3:GetObject",
"Effect": "Allow",
- "Resource": [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- {
- "Fn::Join": [
- "",
- [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- "/*"
- ]
+ "Resource": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:aws:s3:::",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ "/*"
]
- }
- ]
+ ]
+ }
},
{
"Action": [
@@ -3158,17 +3387,30 @@
]
},
{
- "Action": "s3:DeleteObject*",
+ "Action": "s3:ListBucket",
"Effect": "Allow",
"Resource": {
"Fn::Join": [
"",
[
+ "arn:aws:s3:::",
{
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ ]
+ }
+ },
+ {
+ "Action": "s3:DeleteObject",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:aws:s3:::",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"/*"
]
@@ -3209,40 +3451,22 @@
},
{
"Action": [
- "s3:GetObject*",
- "s3:GetBucket*",
- "s3:List*",
- "s3:DeleteObject*",
"s3:PutObject",
- "s3:PutObjectLegalHold",
- "s3:PutObjectRetention",
- "s3:PutObjectTagging",
- "s3:PutObjectVersionTagging",
- "s3:Abort*"
+ "s3:GetObject"
],
"Effect": "Allow",
- "Resource": [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- {
- "Fn::Join": [
- "",
- [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- "/*"
- ]
+ "Resource": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:aws:s3:::",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ "/*"
]
- }
- ]
+ ]
+ }
}
],
"Version": "2012-10-17"
@@ -3260,7 +3484,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Lists available sessions for user",
"Environment": {
@@ -3270,7 +3494,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3329,14 +3553,37 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
- "SessionApiLisaChatsessiongetsessionBC2FF1E4": {
- "Type": "AWS::Lambda::Function",
+ "SessionApiLisaChatsessionlistsessionsLogRetentionDBF988E6": {
+ "Type": "Custom::LogRetention",
"Properties": {
- "Code": {
- "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
- },
- "Description": "Returns the selected session",
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "SessionApiLisaChatsessionlistsessionsB88A4A44"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
+ "SessionApiLisaChatsessiongetsessionBC2FF1E4": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
+ },
+ "Description": "Returns the selected session",
"Environment": {
"Variables": {
"SESSIONS_TABLE_NAME": {
@@ -3344,7 +3591,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3403,12 +3650,35 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
+ "SessionApiLisaChatsessiongetsessionLogRetentionE89F8BB1": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "SessionApiLisaChatsessiongetsessionBC2FF1E4"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"SessionApiLisaChatsessiondeletesession88A67D12": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Deletes selected session",
"Environment": {
@@ -3418,7 +3688,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3477,12 +3747,35 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
+ "SessionApiLisaChatsessiondeletesessionLogRetention97E002CE": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "SessionApiLisaChatsessiondeletesession88A67D12"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"SessionApiLisaChatsessiondeleteusersessions7AADE5ED": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Deletes all sessions for selected user",
"Environment": {
@@ -3492,7 +3785,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3551,12 +3844,35 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
+ "SessionApiLisaChatsessiondeleteusersessionsLogRetention6E8F1810": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "SessionApiLisaChatsessiondeleteusersessions7AADE5ED"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"SessionApiLisaChatsessionputsession28059E77": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Creates or updates selected session",
"Environment": {
@@ -3566,7 +3882,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3625,12 +3941,35 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
+ "SessionApiLisaChatsessionputsessionLogRetentionD6D13902": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "SessionApiLisaChatsessionputsession28059E77"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"SessionApiLisaChatsessionrenamesession93520CEA": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Updates session name",
"Environment": {
@@ -3640,7 +3979,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3699,12 +4038,35 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
+ "SessionApiLisaChatsessionrenamesessionLogRetentionFEB22690": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "SessionApiLisaChatsessionrenamesession93520CEA"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"SessionApiLisaChatsessionattachimagetosessionA482E30C": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Attaches image to session",
"Environment": {
@@ -3714,7 +4076,7 @@
},
"SESSIONS_BY_USER_ID_INDEX_NAME": "byUserId",
"GENERATED_IMAGES_S3_BUCKET_NAME": {
- "Ref": "GeneratedImagesBucketC3465633"
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"MODEL_TABLE_NAME": {
"Ref": "SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -3773,224 +4135,28 @@
"SessionApiLisaSessionApiLambdaExecutionRoleC453EA6B"
]
},
- "GeneratedImagesBucketC3465633": {
- "Type": "AWS::S3::Bucket",
- "Properties": {
- "CorsConfiguration": {
- "CorsRules": [
- {
- "AllowedHeaders": [
- "*"
- ],
- "AllowedMethods": [
- "GET",
- "POST"
- ],
- "AllowedOrigins": [
- "*"
- ],
- "ExposedHeaders": [
- "Access-Control-Allow-Origin"
- ]
- }
- ]
- },
- "LoggingConfiguration": {
- "DestinationBucketName": {
- "Fn::Select": [
- 0,
- {
- "Fn::Split": [
- "/",
- {
- "Fn::Select": [
- 5,
- {
- "Fn::Split": [
- ":",
- {
- "Ref": "SsmParameterValuedevtestlisalisabucketbucketaccesslogsC96584B6F00A464EAD1953AFF4B05118Parameter"
- }
- ]
- }
- ]
- }
- ]
- }
- ]
- },
- "LogFilePrefix": "logs/generated-images-bucket/"
- },
- "Tags": [
- {
- "Key": "aws-cdk:auto-delete-objects",
- "Value": "true"
- }
- ]
- },
- "UpdateReplacePolicy": "Delete",
- "DeletionPolicy": "Delete"
- },
- "GeneratedImagesBucketPolicyA72A6FC9": {
- "Type": "AWS::S3::BucketPolicy",
- "Properties": {
- "Bucket": {
- "Ref": "GeneratedImagesBucketC3465633"
- },
- "PolicyDocument": {
- "Statement": [
- {
- "Action": "s3:*",
- "Condition": {
- "Bool": {
- "aws:SecureTransport": "false"
- }
- },
- "Effect": "Deny",
- "Principal": {
- "AWS": "*"
- },
- "Resource": [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- {
- "Fn::Join": [
- "",
- [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- "/*"
- ]
- ]
- }
- ]
- },
- {
- "Action": [
- "s3:PutBucketPolicy",
- "s3:GetBucket*",
- "s3:List*",
- "s3:DeleteObject*"
- ],
- "Effect": "Allow",
- "Principal": {
- "AWS": {
- "Fn::GetAtt": [
- "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
- "Arn"
- ]
- }
- },
- "Resource": [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- {
- "Fn::Join": [
- "",
- [
- {
- "Fn::GetAtt": [
- "GeneratedImagesBucketC3465633",
- "Arn"
- ]
- },
- "/*"
- ]
- ]
- }
- ]
- }
- ],
- "Version": "2012-10-17"
- }
- }
- },
- "GeneratedImagesBucketAutoDeleteObjectsCustomResource907225F0": {
- "Type": "Custom::S3AutoDeleteObjects",
+ "SessionApiLisaChatsessionattachimagetosessionLogRetentionF531C706": {
+ "Type": "Custom::LogRetention",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
- "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
"Arn"
]
},
- "BucketName": {
- "Ref": "GeneratedImagesBucketC3465633"
- }
- },
- "DependsOn": [
- "GeneratedImagesBucketPolicyA72A6FC9"
- ],
- "UpdateReplacePolicy": "Delete",
- "DeletionPolicy": "Delete"
- },
- "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": {
- "Type": "AWS::IAM::Role",
- "Properties": {
- "AssumeRolePolicyDocument": {
- "Version": "2012-10-17",
- "Statement": [
- {
- "Action": "sts:AssumeRole",
- "Effect": "Allow",
- "Principal": {
- "Service": "lambda.amazonaws.com"
- }
- }
- ]
- },
- "ManagedPolicyArns": [
- {
- "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
- }
- ]
- }
- },
- "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": {
- "Type": "AWS::Lambda::Function",
- "Properties": {
- "Code": {
- "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip"
- },
- "Timeout": 900,
- "MemorySize": 128,
- "Handler": "index.handler",
- "Role": {
- "Fn::GetAtt": [
- "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
- "Arn"
- ]
- },
- "Runtime": "nodejs22.x",
- "Description": {
+ "LogGroupName": {
"Fn::Join": [
"",
[
- "Lambda function for auto-deleting objects in ",
+ "/aws/lambda/",
{
- "Ref": "GeneratedImagesBucketC3465633"
- },
- " S3 bucket."
+ "Ref": "SessionApiLisaChatsessionattachimagetosessionA482E30C"
+ }
]
]
- }
- },
- "DependsOn": [
- "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
- ]
+ },
+ "RetentionInDays": 30
+ }
},
"PromptTemplateApiPromptTemplatesTable2B59FA4A": {
"Type": "AWS::DynamoDB::Table",
@@ -4010,6 +4176,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "byOwner",
@@ -4053,10 +4220,7 @@
"AttributeName": "created",
"KeyType": "RANGE"
}
- ],
- "PointInTimeRecoverySpecification": {
- "PointInTimeRecoveryEnabled": true
- }
+ ]
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete"
@@ -4788,7 +4952,7 @@
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Fn::GetAtt": [
- "PromptTemplateApiLisaChatprompttemplateslist70C16F55",
+ "PromptTemplateApiLisaChatprompttemplateslistprompt2B644A78",
"Arn"
]
},
@@ -4817,7 +4981,7 @@
"Action": "lambda:InvokeFunction",
"FunctionName": {
"Fn::GetAtt": [
- "PromptTemplateApiLisaChatprompttemplateslist70C16F55",
+ "PromptTemplateApiLisaChatprompttemplateslistprompt2B644A78",
"Arn"
]
},
@@ -4862,7 +5026,7 @@
":apigateway:us-iso-east-1:lambda:path/2015-03-31/functions/",
{
"Fn::GetAtt": [
- "PromptTemplateApiLisaChatprompttemplateslist70C16F55",
+ "PromptTemplateApiLisaChatprompttemplateslistprompt2B644A78",
"Arn"
]
},
@@ -4884,7 +5048,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Creates prompt template",
"Environment": {
@@ -4936,12 +5100,35 @@
"PromptTemplateApiLisaPromptTemplatesApiLambdaExecutionRole941C178E"
]
},
+ "PromptTemplateApiLisaChatprompttemplatescreateLogRetention12287950": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "PromptTemplateApiLisaChatprompttemplatescreateC453FE00"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"PromptTemplateApiLisaChatprompttemplatesgetA1792182": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Retrieves specific prompt template by ID",
"Environment": {
@@ -4993,12 +5180,35 @@
"PromptTemplateApiLisaPromptTemplatesApiLambdaExecutionRole941C178E"
]
},
- "PromptTemplateApiLisaChatprompttemplateslist70C16F55": {
+ "PromptTemplateApiLisaChatprompttemplatesgetLogRetentionF4939A0A": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "PromptTemplateApiLisaChatprompttemplatesgetA1792182"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
+ "PromptTemplateApiLisaChatprompttemplateslistprompt2B644A78": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Lists all available prompt templates",
"Environment": {
@@ -5010,8 +5220,8 @@
"PROMPT_TEMPLATES_BY_LATEST_INDEX_NAME": "byOwner"
}
},
- "FunctionName": "LisaChat-prompt_templates-list",
- "Handler": "prompt_templates.lambda_functions.list",
+ "FunctionName": "LisaChat-prompt_templates-list_prompt",
+ "Handler": "prompt_templates.lambda_functions.list_prompt",
"Layers": [
{
"Ref": "SsmParameterValuedevtestlisalisalayerVersionfastapiC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -5050,12 +5260,35 @@
"PromptTemplateApiLisaPromptTemplatesApiLambdaExecutionRole941C178E"
]
},
+ "PromptTemplateApiLisaChatprompttemplateslistpromptLogRetentionAD7E61CA": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "PromptTemplateApiLisaChatprompttemplateslistprompt2B644A78"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"PromptTemplateApiLisaChatprompttemplatesupdateDA9BD670": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Updates an existing prompt template",
"Environment": {
@@ -5107,12 +5340,35 @@
"PromptTemplateApiLisaPromptTemplatesApiLambdaExecutionRole941C178E"
]
},
+ "PromptTemplateApiLisaChatprompttemplatesupdateLogRetention70C9D7A2": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "PromptTemplateApiLisaChatprompttemplatesupdateDA9BD670"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"PromptTemplateApiLisaChatprompttemplatesdelete41FB2B92": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Deletes a specific prompt template by ID",
"Environment": {
@@ -5164,6 +5420,29 @@
"PromptTemplateApiLisaPromptTemplatesApiLambdaExecutionRole941C178E"
]
},
+ "PromptTemplateApiLisaChatprompttemplatesdeleteLogRetention13CC9314": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "PromptTemplateApiLisaChatprompttemplatesdelete41FB2B92"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"UserPreferencesApiUserPreferencesTableD7C804C6": {
"Type": "AWS::DynamoDB::Table",
"Properties": {
@@ -5174,6 +5453,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "user",
@@ -5582,7 +5862,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Returns the preferences for the calling user",
"Environment": {
@@ -5632,12 +5912,35 @@
"UserPreferencesApiLisaUserPreferencesApiLambdaExecutionRole44353F29"
]
},
+ "UserPreferencesApiLisaChatuserpreferencesgetLogRetention16B781BD": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "UserPreferencesApiLisaChatuserpreferencesgetCAACC747"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"UserPreferencesApiLisaChatuserpreferencesupdateC679459F": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Creates or updates user preferences for user",
"Environment": {
@@ -5686,6 +5989,29 @@
"UserPreferencesApiLisaUserPreferencesApiLambdaExecutionRoleDefaultPolicyA4B6F391",
"UserPreferencesApiLisaUserPreferencesApiLambdaExecutionRole44353F29"
]
+ },
+ "UserPreferencesApiLisaChatuserpreferencesupdateLogRetention7574F0B4": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "UserPreferencesApiLisaChatuserpreferencesupdateC679459F"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
}
},
"Parameters": {
@@ -5701,9 +6027,9 @@
"Type": "AWS::SSM::Parameter::Value",
"Default": "/dev/test-lisa/lisa/serve/endpoint"
},
- "SsmParameterValuedevtestlisalisabucketbucketaccesslogsC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter": {
"Type": "AWS::SSM::Parameter::Value",
- "Default": "/dev/test-lisa/lisa/bucket/bucket-access-logs"
+ "Default": "/dev/test-lisa/lisa/generatedImagesBucketName"
},
"SsmParameterValuedevtestlisalisamodelTableNameC96584B6F00A464EAD1953AFF4B05118Parameter": {
"Type": "AWS::SSM::Parameter::Value",
diff --git a/test/cdk/stacks/__baselines__/LisaCore.json b/test/cdk/stacks/__baselines__/LisaCore.json
index d254f9f64..4412d0853 100644
--- a/test/cdk/stacks/__baselines__/LisaCore.json
+++ b/test/cdk/stacks/__baselines__/LisaCore.json
@@ -3,7 +3,29 @@
"BucketAccessLogsBucket91990836": {
"Type": "AWS::S3::Bucket",
"Properties": {
+ "BucketEncryption": {
+ "ServerSideEncryptionConfiguration": [
+ {
+ "ServerSideEncryptionByDefault": {
+ "SSEAlgorithm": "AES256"
+ }
+ }
+ ]
+ },
"BucketName": "test-lisa-012345678901-dev-bucket-access-logs",
+ "OwnershipControls": {
+ "Rules": [
+ {
+ "ObjectOwnership": "BucketOwnerPreferred"
+ }
+ ]
+ },
+ "PublicAccessBlockConfiguration": {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true
+ },
"Tags": [
{
"Key": "aws-cdk:auto-delete-objects",
@@ -212,7 +234,7 @@
],
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "5a2a36324526dc2bb78667953cbbd05698b1ebb0c64ca5ff6b6cd5a2d3d0b00c.zip"
+ "S3Key": "faa87eaaea7a6adfcffa5bcf40857522d11b66521e7098a91230cd679235eb4c.zip"
},
"Description": "FastAPI requirements for REST API Lambdas"
},
@@ -242,7 +264,7 @@
],
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "1ebc9d3ac2033816c4abb63e4afd69d350b4aba8704cc9236b82ea520b74f4b0.zip"
+ "S3Key": "9f5a93dbbc19571081320d62c9dda6a08a222173f89401f9298cb0a184a790e5.zip"
},
"Description": "AWS CDK dependencies for deployer Lambdas"
},
diff --git a/test/cdk/stacks/__baselines__/LisaDocs.json b/test/cdk/stacks/__baselines__/LisaDocs.json
index 3c7c90c8d..e37db5380 100644
--- a/test/cdk/stacks/__baselines__/LisaDocs.json
+++ b/test/cdk/stacks/__baselines__/LisaDocs.json
@@ -239,7 +239,7 @@
"Properties": {
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "c49d356cac773d491c5f7ac148995a1181498a8e289429f8612a7f7e3814f535.zip"
+ "S3Key": "0cfdecad2260a3a84ad0c2d08a77e03c9d25e26c7b52f26b1e1faf97aef92f18.zip"
},
"Description": "/opt/awscli/aws"
}
diff --git a/test/cdk/stacks/__baselines__/LisaMcpApi.json b/test/cdk/stacks/__baselines__/LisaMcpApi.json
index 91d435c31..84998d335 100644
--- a/test/cdk/stacks/__baselines__/LisaMcpApi.json
+++ b/test/cdk/stacks/__baselines__/LisaMcpApi.json
@@ -10,6 +10,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "id",
@@ -911,7 +912,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -974,7 +975,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1037,7 +1038,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1100,7 +1101,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1163,7 +1164,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1469,7 +1470,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1522,7 +1523,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1575,7 +1576,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1628,7 +1629,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1887,7 +1888,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1940,7 +1941,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1993,7 +1994,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2046,7 +2047,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2099,7 +2100,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2477,7 +2478,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Create LISA MCP hosted server",
"Environment": {
@@ -2536,12 +2537,35 @@
"McpServerApiLisaMcpServerDynamicApiLambdaExecutionRole0253C949"
]
},
+ "McpServerApiLisaMcpApimcpservercreatehostedmcpserverLogRetention4C1412D6": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpServerApiLisaMcpApimcpservercreatehostedmcpserverA53DC046"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpServerApiLisaMcpApimcpserverlisthostedmcpservers7803D63C": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List LISA MCP hosted servers",
"Environment": {
@@ -2600,12 +2624,35 @@
"McpServerApiLisaMcpServerDynamicApiLambdaExecutionRole0253C949"
]
},
+ "McpServerApiLisaMcpApimcpserverlisthostedmcpserversLogRetention3DBFEEE4": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpServerApiLisaMcpApimcpserverlisthostedmcpservers7803D63C"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpServerApiLisaMcpApimcpservergethostedmcpserver9994682E": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Get LISA MCP hosted server by ID",
"Environment": {
@@ -2664,12 +2711,35 @@
"McpServerApiLisaMcpServerDynamicApiLambdaExecutionRole0253C949"
]
},
+ "McpServerApiLisaMcpApimcpservergethostedmcpserverLogRetention18703C14": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpServerApiLisaMcpApimcpservergethostedmcpserver9994682E"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpServerApiLisaMcpApimcpserverdeletehostedmcpserver5962CC6C": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Delete LISA MCP hosted server by ID",
"Environment": {
@@ -2728,12 +2798,35 @@
"McpServerApiLisaMcpServerDynamicApiLambdaExecutionRole0253C949"
]
},
+ "McpServerApiLisaMcpApimcpserverdeletehostedmcpserverLogRetentionD73F3CC3": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpServerApiLisaMcpApimcpserverdeletehostedmcpserver5962CC6C"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpServerApiLisaMcpApimcpserverupdatehostedmcpserver71734A76": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Update LISA MCP hosted server by ID",
"Environment": {
@@ -2792,6 +2885,29 @@
"McpServerApiLisaMcpServerDynamicApiLambdaExecutionRole0253C949"
]
},
+ "McpServerApiLisaMcpApimcpserverupdatehostedmcpserverLogRetention084817F6": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpServerApiLisaMcpApimcpserverupdatehostedmcpserver71734A76"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpServerApiMcpServerApiStateMachinePerms74804F4E": {
"Type": "AWS::IAM::Policy",
"Properties": {
@@ -2858,6 +2974,15 @@
"LISAMCPHostingtestlisadev2C95EA15": {
"Type": "AWS::S3::Bucket",
"Properties": {
+ "BucketEncryption": {
+ "ServerSideEncryptionConfiguration": [
+ {
+ "ServerSideEncryptionByDefault": {
+ "SSEAlgorithm": "AES256"
+ }
+ }
+ ]
+ },
"CorsConfiguration": {
"CorsRules": [
{
@@ -2903,6 +3028,12 @@
},
"LogFilePrefix": "logs/mcp-hosting-bucket/"
},
+ "PublicAccessBlockConfiguration": {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true
+ },
"Tags": [
{
"Key": "aws-cdk:auto-delete-objects",
@@ -3073,6 +3204,83 @@
"DependsOn": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
]
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy"
+ ],
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Handler": "index.handler",
+ "Runtime": "nodejs22.x",
+ "Timeout": 900,
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"
+ },
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ ]
}
},
"Parameters": {
diff --git a/test/cdk/stacks/__baselines__/LisaMcpWorkbench.json b/test/cdk/stacks/__baselines__/LisaMcpWorkbench.json
index 3f1b1eaae..81132cd75 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": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Lists available MCP Workbench tools",
"Environment": {
@@ -943,12 +943,35 @@
"McpWorkbenchLambdaExecutionRole43E4060B"
]
},
+ "McpWorkbenchLisaMcpWorkbenchmcpworkbenchlistLogRetention423CC79F": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpWorkbenchLisaMcpWorkbenchmcpworkbenchlist046D8BF5"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpWorkbenchLisaMcpWorkbenchmcpworkbenchcreateE9F26E3A": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Create MCP Workbench tools",
"Environment": {
@@ -999,12 +1022,35 @@
"McpWorkbenchLambdaExecutionRole43E4060B"
]
},
+ "McpWorkbenchLisaMcpWorkbenchmcpworkbenchcreateLogRetention14AEA18C": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpWorkbenchLisaMcpWorkbenchmcpworkbenchcreateE9F26E3A"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpWorkbenchLisaMcpWorkbenchmcpworkbenchread7EECA39E": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Get MCP Workbench tool",
"Environment": {
@@ -1055,12 +1101,35 @@
"McpWorkbenchLambdaExecutionRole43E4060B"
]
},
+ "McpWorkbenchLisaMcpWorkbenchmcpworkbenchreadLogRetention3F008761": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpWorkbenchLisaMcpWorkbenchmcpworkbenchread7EECA39E"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpWorkbenchLisaMcpWorkbenchmcpworkbenchupdate830E4B0B": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Update MCP Workbench tool",
"Environment": {
@@ -1111,12 +1180,35 @@
"McpWorkbenchLambdaExecutionRole43E4060B"
]
},
+ "McpWorkbenchLisaMcpWorkbenchmcpworkbenchupdateLogRetentionE2837050": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpWorkbenchLisaMcpWorkbenchmcpworkbenchupdate830E4B0B"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpWorkbenchLisaMcpWorkbenchmcpworkbenchdelete020452F9": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Delete MCP Workbench tool",
"Environment": {
@@ -1167,12 +1259,35 @@
"McpWorkbenchLambdaExecutionRole43E4060B"
]
},
+ "McpWorkbenchLisaMcpWorkbenchmcpworkbenchdeleteLogRetention358CCB28": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpWorkbenchLisaMcpWorkbenchmcpworkbenchdelete020452F9"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpWorkbenchLisaMcpWorkbenchmcpworkbenchvalidatesyntax4307220C": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Validate Python code syntax",
"Environment": {
@@ -1223,6 +1338,29 @@
"McpWorkbenchLambdaExecutionRole43E4060B"
]
},
+ "McpWorkbenchLisaMcpWorkbenchmcpworkbenchvalidatesyntaxLogRetentionF1654049": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "McpWorkbenchLisaMcpWorkbenchmcpworkbenchvalidatesyntax4307220C"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"McpWorkbenchS3EventHandlerRole545174C5": {
"Type": "AWS::IAM::Role",
"Properties": {
@@ -1337,7 +1475,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1443,6 +1581,15 @@
"LISAMCPWorkbenchtestlisadevC221720C": {
"Type": "AWS::S3::Bucket",
"Properties": {
+ "BucketEncryption": {
+ "ServerSideEncryptionConfiguration": [
+ {
+ "ServerSideEncryptionByDefault": {
+ "SSEAlgorithm": "AES256"
+ }
+ }
+ ]
+ },
"BucketName": "test-lisa-dev-mcpworkbench-012345678901",
"LoggingConfiguration": {
"DestinationBucketName": {
@@ -1470,6 +1617,12 @@
},
"LogFilePrefix": "logs/mcpworkbench-bucket/"
},
+ "PublicAccessBlockConfiguration": {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true
+ },
"Tags": [
{
"Key": "aws-cdk:auto-delete-objects",
@@ -1741,6 +1894,83 @@
"BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleDefaultPolicy2CF63D36",
"BucketNotificationsHandler050a0587b7544547bf325f094a3db834RoleB6FB88EC"
]
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy"
+ ],
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Handler": "index.handler",
+ "Runtime": "nodejs22.x",
+ "Timeout": 900,
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"
+ },
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ ]
}
},
"Parameters": {
diff --git a/test/cdk/stacks/__baselines__/LisaMetrics.json b/test/cdk/stacks/__baselines__/LisaMetrics.json
index f9b01c086..124060ad9 100644
--- a/test/cdk/stacks/__baselines__/LisaMetrics.json
+++ b/test/cdk/stacks/__baselines__/LisaMetrics.json
@@ -11,6 +11,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "userId",
@@ -654,7 +655,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Gets metrics for a specific user",
"Environment": {
@@ -701,12 +702,35 @@
"LisaMetricsLisaLisaMetricsApiLambdaExecutionRoleLambdaExecutionRoleB2BF8012"
]
},
+ "LisaMetricsLisaMetricsmetricsgetusermetricsLogRetention0806E30E": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "LisaMetricsLisaMetricsmetricsgetusermetricsA7DCD5D4"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"LisaMetricsLisaMetricsmetricsgetusermetricsall5D030B37": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Gets aggregated metrics across all users",
"Environment": {
@@ -753,12 +777,35 @@
"LisaMetricsLisaLisaMetricsApiLambdaExecutionRoleLambdaExecutionRoleB2BF8012"
]
},
+ "LisaMetricsLisaMetricsmetricsgetusermetricsallLogRetention11A727F3": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "LisaMetricsLisaMetricsmetricsgetusermetricsall5D030B37"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"LisaMetricsDailyMetricsLambda45184347": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -844,7 +891,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -902,6 +949,83 @@
"Ref": "LisaMetricsUsageMetricsProcessorCC2E38B3"
}
}
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy"
+ ],
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Handler": "index.handler",
+ "Runtime": "nodejs22.x",
+ "Timeout": 900,
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"
+ },
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ ]
}
},
"Parameters": {
diff --git a/test/cdk/stacks/__baselines__/LisaModels.json b/test/cdk/stacks/__baselines__/LisaModels.json
index 23dc9a2f8..d6b9dc491 100644
--- a/test/cdk/stacks/__baselines__/LisaModels.json
+++ b/test/cdk/stacks/__baselines__/LisaModels.json
@@ -236,6 +236,64 @@
}
}
},
+ "ModelsApiRestApimodelsGETApiPermissionLisaModelsModelsApiRestApi2F36137AGETmodels347EE819": {
+ "Type": "AWS::Lambda::Permission",
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ModelsApiLisaModelsmodelshandler36D33339",
+ "Arn"
+ ]
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":execute-api:us-iso-east-1:012345678901:",
+ {
+ "Fn::ImportValue": "LisaApiBase:ExportsOutputRefLisaApiBaseRestApiBD7EFE4112A65F2C"
+ },
+ "/*/GET/models"
+ ]
+ ]
+ }
+ }
+ },
+ "ModelsApiRestApimodelsGETApiPermissionTestLisaModelsModelsApiRestApi2F36137AGETmodels9AC2E7D5": {
+ "Type": "AWS::Lambda::Permission",
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ModelsApiLisaModelsmodelshandler36D33339",
+ "Arn"
+ ]
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":execute-api:us-iso-east-1:012345678901:",
+ {
+ "Fn::ImportValue": "LisaApiBase:ExportsOutputRefLisaApiBaseRestApiBD7EFE4112A65F2C"
+ },
+ "/test-invoke-stage/GET/models"
+ ]
+ ]
+ }
+ }
+ },
"ModelsApiRestApimodelsGETB3D039CA": {
"Type": "AWS::ApiGateway::Method",
"Properties": {
@@ -275,6 +333,64 @@
}
}
},
+ "ModelsApiRestApimodelsPOSTApiPermissionLisaModelsModelsApiRestApi2F36137APOSTmodelsBEE99249": {
+ "Type": "AWS::Lambda::Permission",
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ModelsApiLisaModelsmodelshandler36D33339",
+ "Arn"
+ ]
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":execute-api:us-iso-east-1:012345678901:",
+ {
+ "Fn::ImportValue": "LisaApiBase:ExportsOutputRefLisaApiBaseRestApiBD7EFE4112A65F2C"
+ },
+ "/*/POST/models"
+ ]
+ ]
+ }
+ }
+ },
+ "ModelsApiRestApimodelsPOSTApiPermissionTestLisaModelsModelsApiRestApi2F36137APOSTmodelsD2CCD576": {
+ "Type": "AWS::Lambda::Permission",
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ModelsApiLisaModelsmodelshandler36D33339",
+ "Arn"
+ ]
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":execute-api:us-iso-east-1:012345678901:",
+ {
+ "Fn::ImportValue": "LisaApiBase:ExportsOutputRefLisaApiBaseRestApiBD7EFE4112A65F2C"
+ },
+ "/test-invoke-stage/POST/models"
+ ]
+ ]
+ }
+ }
+ },
"ModelsApiRestApimodelsPOST3CF25A3C": {
"Type": "AWS::ApiGateway::Method",
"Properties": {
@@ -512,6 +628,64 @@
}
}
},
+ "ModelsApiRestApiopenapijsonGETApiPermissionLisaModelsModelsApiRestApi2F36137AGETopenapijson19AA6B4B": {
+ "Type": "AWS::Lambda::Permission",
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ModelsApiLisaModelsmodelshandler36D33339",
+ "Arn"
+ ]
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":execute-api:us-iso-east-1:012345678901:",
+ {
+ "Fn::ImportValue": "LisaApiBase:ExportsOutputRefLisaApiBaseRestApiBD7EFE4112A65F2C"
+ },
+ "/*/GET/openapi.json"
+ ]
+ ]
+ }
+ }
+ },
+ "ModelsApiRestApiopenapijsonGETApiPermissionTestLisaModelsModelsApiRestApi2F36137AGETopenapijson5460F088": {
+ "Type": "AWS::Lambda::Permission",
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ModelsApiLisaModelsmodelshandler36D33339",
+ "Arn"
+ ]
+ },
+ "Principal": "apigateway.amazonaws.com",
+ "SourceArn": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":execute-api:us-iso-east-1:012345678901:",
+ {
+ "Fn::ImportValue": "LisaApiBase:ExportsOutputRefLisaApiBaseRestApiBD7EFE4112A65F2C"
+ },
+ "/test-invoke-stage/GET/openapi.json"
+ ]
+ ]
+ }
+ }
+ },
"ModelsApiRestApiopenapijsonGETFEBC2C73": {
"Type": "AWS::ApiGateway::Method",
"Properties": {
@@ -558,6 +732,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "model_id",
@@ -704,6 +879,15 @@
"ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95": {
"Type": "AWS::S3::Bucket",
"Properties": {
+ "BucketEncryption": {
+ "ServerSideEncryptionConfiguration": [
+ {
+ "ServerSideEncryptionByDefault": {
+ "SSEAlgorithm": "AES256"
+ }
+ }
+ ]
+ },
"LoggingConfiguration": {
"DestinationBucketName": {
"Fn::Select": [
@@ -727,17 +911,28 @@
]
}
]
- }
+ },
+ "LogFilePrefix": "logs/docker-image-builder-bucket/"
+ },
+ "PublicAccessBlockConfiguration": {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true
},
"Tags": [
+ {
+ "Key": "aws-cdk:auto-delete-objects",
+ "Value": "true"
+ },
{
"Key": "aws-cdk:cr-owned:97c50996",
"Value": "true"
}
]
},
- "UpdateReplacePolicy": "Retain",
- "DeletionPolicy": "Retain"
+ "UpdateReplacePolicy": "Delete",
+ "DeletionPolicy": "Delete"
},
"ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketPolicy0C3E0058": {
"Type": "AWS::S3::BucketPolicy",
@@ -780,18 +975,76 @@
]
}
]
+ },
+ {
+ "Action": [
+ "s3:PutBucketPolicy",
+ "s3:GetBucket*",
+ "s3:List*",
+ "s3:DeleteObject*"
+ ],
+ "Effect": "Allow",
+ "Principal": {
+ "AWS": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
+ "Arn"
+ ]
+ }
+ },
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95",
+ "Arn"
+ ]
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95",
+ "Arn"
+ ]
+ },
+ "/*"
+ ]
+ ]
+ }
+ ]
}
],
"Version": "2012-10-17"
}
}
},
+ "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketAutoDeleteObjectsCustomResource4B9BB97F": {
+ "Type": "Custom::S3AutoDeleteObjects",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F",
+ "Arn"
+ ]
+ },
+ "BucketName": {
+ "Ref": "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95"
+ }
+ },
+ "DependsOn": [
+ "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketPolicy0C3E0058"
+ ],
+ "UpdateReplacePolicy": "Delete",
+ "DeletionPolicy": "Delete"
+ },
"ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2dplmntAwsCliLayerB03BDD23": {
"Type": "AWS::Lambda::LayerVersion",
"Properties": {
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "c49d356cac773d491c5f7ac148995a1181498a8e289429f8612a7f7e3814f535.zip"
+ "S3Key": "0cfdecad2260a3a84ad0c2d08a77e03c9d25e26c7b52f26b1e1faf97aef92f18.zip"
},
"Description": "/opt/awscli/aws"
}
@@ -809,7 +1062,7 @@
"cdk-hnb659fds-assets-012345678901-us-iso-east-1"
],
"SourceObjectKeys": [
- "158cef6fe13582103654cb48259892ae3f9975552a59f2bfad165bd6b8a6b998.zip"
+ "9f3ea1eafc68ae8e89106b5a5558bdeb3a38a08821681b1f69441cbbbec07237.zip"
],
"DestinationBucketName": {
"Ref": "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95"
@@ -1008,7 +1261,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1360,7 +1613,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Manages Auto Scaling scheduled actions for LISA model scheduling",
"Environment": {
@@ -1412,7 +1665,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Processes Auto Scaling Group CloudWatch events to update model status",
"Environment": {
@@ -1542,7 +1795,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1627,7 +1880,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1712,7 +1965,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1797,7 +2050,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1882,7 +2135,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1967,7 +2220,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2047,12 +2300,97 @@
"ModelsApiModelsSfnLambdaRoleF400F0BC"
]
},
+ "ModelsApiCreateModelWorkflowPollModelReadyFunc1EF62F32": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
+ },
+ "Environment": {
+ "Variables": {
+ "DOCKER_IMAGE_BUILDER_FN_ARN": {
+ "Fn::GetAtt": [
+ "ModelsApidockerimagebuilderLisaModelsdockerimagebuilder35917FEB",
+ "Arn"
+ ]
+ },
+ "ECR_REPOSITORY_ARN": {
+ "Fn::GetAtt": [
+ "ModelsApiecsmodelbuildrepo74B1906D",
+ "Arn"
+ ]
+ },
+ "ECR_REPOSITORY_NAME": {
+ "Ref": "ModelsApiecsmodelbuildrepo74B1906D"
+ },
+ "ECS_MODEL_DEPLOYER_FN_ARN": {
+ "Fn::GetAtt": [
+ "ModelsApiecsmodeldeployerLisaModelsecsmodeldeployerFunc775C7BAD",
+ "Arn"
+ ]
+ },
+ "LISA_API_URL_PS_NAME": "/dev/test-lisa/lisa/lisaServeRestApiUri",
+ "MODEL_TABLE_NAME": {
+ "Ref": "ModelsApiModelTable72B9582E"
+ },
+ "GUARDRAILS_TABLE_NAME": {
+ "Ref": "ModelsApiGuardrailsTableNameParameterParameter9338827B"
+ },
+ "REST_API_VERSION": "v2",
+ "MANAGEMENT_KEY_NAME": {
+ "Ref": "SsmParameterValuedevtestlisalisaappManagementKeySecretNameC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ "RESTAPI_SSL_CERT_ARN": "arn:aws:iam::012345678901:server-certificate/lisa-self-signed-dev",
+ "LITELLM_CONFIG_OBJ": "{\"db_key\":\"sk-012345\"}",
+ "AWS_ACCOUNT_ID": "012345678901",
+ "AWS_PARTITION": "aws"
+ }
+ },
+ "Handler": "models.state_machine.create_model.handle_poll_model_ready",
+ "Layers": [
+ {
+ "Ref": "SsmParameterValuedevtestlisalisalayerVersioncommonC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ {
+ "Ref": "SsmParameterValuedevtestlisalisalayerVersionfastapiC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ],
+ "MemorySize": 128,
+ "Role": {
+ "Fn::GetAtt": [
+ "ModelsApiModelsSfnLambdaRoleF400F0BC",
+ "Arn"
+ ]
+ },
+ "Runtime": "python3.13",
+ "Timeout": 60,
+ "VpcConfig": {
+ "SecurityGroupIds": [
+ {
+ "Fn::ImportValue": "LisaNetworking:ExportsOutputFnGetAttVpcEcsModelAlbSg5FC4C18EGroupId3AE6D77A"
+ }
+ ],
+ "SubnetIds": [
+ {
+ "Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPCprivateSubnet1Subnet29B9FADC0739E75F"
+ },
+ {
+ "Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPCprivateSubnet2Subnet63498DC142E639BD"
+ }
+ ]
+ }
+ },
+ "DependsOn": [
+ "ModelsApiModelsSfnLambdaRoleF400F0BC"
+ ]
+ },
"ModelsApiCreateModelWorkflowCreateScheduleFuncBB684C18": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2140,7 +2478,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2225,7 +2563,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2560,6 +2898,32 @@
]
}
]
+ },
+ {
+ "Action": "lambda:InvokeFunction",
+ "Effect": "Allow",
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "ModelsApiCreateModelWorkflowPollModelReadyFunc1EF62F32",
+ "Arn"
+ ]
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "ModelsApiCreateModelWorkflowPollModelReadyFunc1EF62F32",
+ "Arn"
+ ]
+ },
+ ":*"
+ ]
+ ]
+ }
+ ]
}
],
"Version": "2012-10-17"
@@ -2678,7 +3042,18 @@
"Arn"
]
},
- "\",\"Payload.$\":\"$\"}},\"WaitBeforePollingCreateStack\":{\"Type\":\"Wait\",\"Seconds\":60,\"Next\":\"PollCreateStack\"},\"PollCreateStackChoice\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.continue_polling_stack\",\"BooleanEquals\":true,\"Next\":\"WaitBeforePollingCreateStack\"}],\"Default\":\"CreateSchedule\"},\"CreateFailed\":{\"Type\":\"Fail\"}}}"
+ "\",\"Payload.$\":\"$\"}},\"WaitBeforePollingCreateStack\":{\"Type\":\"Wait\",\"Seconds\":60,\"Next\":\"PollCreateStack\"},\"PollCreateStackChoice\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.continue_polling_stack\",\"BooleanEquals\":true,\"Next\":\"WaitBeforePollingCreateStack\"}],\"Default\":\"PollModelReady\"},\"PollModelReady\":{\"Next\":\"PollModelReadyChoice\",\"Retry\":[{\"ErrorEquals\":[\"Lambda.ClientExecutionTimeoutException\",\"Lambda.ServiceException\",\"Lambda.AWSLambdaException\",\"Lambda.SdkClientException\"],\"IntervalSeconds\":2,\"MaxAttempts\":6,\"BackoffRate\":2}],\"Type\":\"Task\",\"OutputPath\":\"$.Payload\",\"Resource\":\"arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":states:::lambda:invoke\",\"Parameters\":{\"FunctionName\":\"",
+ {
+ "Fn::GetAtt": [
+ "ModelsApiCreateModelWorkflowPollModelReadyFunc1EF62F32",
+ "Arn"
+ ]
+ },
+ "\",\"Payload.$\":\"$\"}},\"WaitBeforePollingModelReady\":{\"Type\":\"Wait\",\"Seconds\":60,\"Next\":\"PollModelReady\"},\"PollModelReadyChoice\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.continue_polling_capacity\",\"BooleanEquals\":true,\"Next\":\"WaitBeforePollingModelReady\"}],\"Default\":\"CreateSchedule\"},\"CreateFailed\":{\"Type\":\"Fail\"}}}"
]
]
},
@@ -2701,7 +3076,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2762,7 +3137,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2823,7 +3198,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2884,7 +3259,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2945,7 +3320,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3006,7 +3381,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3347,7 +3722,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3409,7 +3784,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3471,7 +3846,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3533,7 +3908,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3595,7 +3970,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -3657,7 +4032,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -4118,7 +4493,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Manage model",
"Environment": {
@@ -4189,6 +4564,29 @@
"ModelsApiLisaModelApiLambdaExecutionRole3E663E08"
]
},
+ "ModelsApiLisaModelsmodelshandlerLogRetentionF4A336A5": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "ModelsApiLisaModelsmodelshandler36D33339"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"ModelsApiModelsApiCertPerms4642C7FC": {
"Type": "AWS::IAM::Policy",
"Properties": {
@@ -4213,7 +4611,7 @@
]
}
},
- "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler80a4D64CE701": {
+ "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler292673D9D37A": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
@@ -4252,7 +4650,7 @@
}
}
},
- "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler5e0476086EF9": {
+ "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler843cDF6CA924": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
@@ -4296,7 +4694,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Manage model",
"Environment": {
@@ -4367,7 +4765,30 @@
"ModelsApiLisaModelApiLambdaExecutionRole3E663E08"
]
},
- "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler5f08340CB6F3": {
+ "ModelsApiLisaModelsmodelsdocsLogRetention5811EB7D": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "ModelsApiLisaModelsmodelsdocs57EF06DB"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
+ "ModelsApiLambdaInvokeAccessRemoteLisaModelsmodelshandler851b7E6C1542": {
"Type": "AWS::Lambda::Permission",
"Properties": {
"Action": "lambda:InvokeFunction",
@@ -4558,7 +4979,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Remove api_key from existing Bedrock models to fix Invalid API Key format errors",
"Environment": {
@@ -4696,7 +5117,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "bdc104ed9cab1b5b6421713c8155f0b753380595356f710400609664d3635eca.zip"
+ "S3Key": "07a90cc3efdfc34da22208dcd9d211f06f5b0e01b21e778edc7c3966b1f61d57.zip"
},
"Description": "AWS CDK resource provider framework - onEvent (LisaModels/ModelsApi/ModelApiKeyCleanupProvider)",
"Environment": {
@@ -4742,6 +5163,62 @@
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete"
},
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ]
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ }
+ ]
+ }
+ },
+ "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip"
+ },
+ "Timeout": 900,
+ "MemorySize": 128,
+ "Handler": "index.handler",
+ "Role": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
+ "Arn"
+ ]
+ },
+ "Runtime": "nodejs22.x",
+ "Description": {
+ "Fn::Join": [
+ "",
+ [
+ "Lambda function for auto-deleting objects in ",
+ {
+ "Ref": "ModelsApidockerimagebuilderLisaModelsdockerimagebuilderec2bucketA3074A95"
+ },
+ " S3 bucket."
+ ]
+ ]
+ }
+ },
+ "DependsOn": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
+ ]
+ },
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265": {
"Type": "AWS::IAM::Role",
"Properties": {
@@ -4891,6 +5368,83 @@
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRoleDefaultPolicy88902FDF",
"CustomCDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756CServiceRole89A01265"
]
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy"
+ ],
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Handler": "index.handler",
+ "Runtime": "nodejs22.x",
+ "Timeout": 900,
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"
+ },
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ ]
}
},
"Rules": {
diff --git a/test/cdk/stacks/__baselines__/LisaRAG.json b/test/cdk/stacks/__baselines__/LisaRAG.json
index 0c2f7995a..984d51cbb 100644
--- a/test/cdk/stacks/__baselines__/LisaRAG.json
+++ b/test/cdk/stacks/__baselines__/LisaRAG.json
@@ -475,6 +475,15 @@
"LISARAGtestlisadevFF387D45": {
"Type": "AWS::S3::Bucket",
"Properties": {
+ "BucketEncryption": {
+ "ServerSideEncryptionConfiguration": [
+ {
+ "ServerSideEncryptionByDefault": {
+ "SSEAlgorithm": "AES256"
+ }
+ }
+ ]
+ },
"CorsConfiguration": {
"CorsRules": [
{
@@ -520,6 +529,12 @@
},
"LogFilePrefix": "logs/rag-bucket/"
},
+ "PublicAccessBlockConfiguration": {
+ "BlockPublicAcls": true,
+ "BlockPublicPolicy": true,
+ "IgnorePublicAcls": true,
+ "RestrictPublicBuckets": true
+ },
"Tags": [
{
"Key": "aws-cdk:auto-delete-objects",
@@ -805,6 +820,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "document_index",
@@ -906,6 +922,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "document_id",
@@ -973,6 +990,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "RepositoryIndex",
@@ -1064,7 +1082,7 @@
],
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "9f5b8019afa466f0e8a9ede616333a9e6e025da899cd078094fed39ea8d31436.zip"
+ "S3Key": "7364261a3d25bc5b62e6bd642029de96fa3d7edc30fd902e4c124cdf87111bfb.zip"
},
"Description": "Lambda dependencies for RAG API"
},
@@ -1241,15 +1259,13 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"KeySchema": [
{
"AttributeName": "repositoryId",
"KeyType": "HASH"
}
],
- "PointInTimeRecoverySpecification": {
- "PointInTimeRecoveryEnabled": true
- },
"ResourcePolicy": {
"PolicyDocument": {
"Statement": [
@@ -1337,6 +1353,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "documentId",
@@ -1433,7 +1450,7 @@
"IngestionStackConstructIngestionJobFargateEnvD92342F8": {
"Type": "AWS::Batch::ComputeEnvironment",
"Properties": {
- "ComputeEnvironmentName": "test-lisa-dev-ingestion-job-3cc34a6fd065",
+ "ComputeEnvironmentName": "test-lisa-dev-ingestion-job-94117c65ef0f",
"ComputeResources": {
"MaxvCpus": 128,
"SecurityGroupIds": [
@@ -1474,7 +1491,7 @@
"Order": 1
}
],
- "JobQueueName": "test-lisa-dev-ingestion-job-3cc34a6fd065",
+ "JobQueueName": "test-lisa-dev-ingestion-job-94117c65ef0f",
"Priority": 1,
"State": "ENABLED"
}
@@ -1697,7 +1714,7 @@
},
"FargatePlatformConfiguration": {},
"Image": {
- "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:e58297a4e5d43b74d615c1959f92e6e5ce0b22e5ffab01599357ea6e288329f3"
+ "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:abfbff42e168671e0107e86fd31bef1124447938fdf1878b3a32c39a175466de"
},
"JobRoleArn": {
"Ref": "SsmParameterValuedevtestlisalisarolestestlisaLisaRagLambdaExecutionRoleC96584B6F00A464EAD1953AFF4B05118Parameter"
@@ -1728,7 +1745,7 @@
],
"RuntimePlatform": {}
},
- "JobDefinitionName": "test-lisa-dev-ingestion-job-3cc34a6fd065",
+ "JobDefinitionName": "test-lisa-dev-ingestion-job-94117c65ef0f",
"PlatformCapabilities": [
"FARGATE"
],
@@ -1762,7 +1779,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -1852,7 +1869,7 @@
"TIKTOKEN_CACHE_DIR": "/opt/python/TIKTOKEN_CACHE"
}
},
- "FunctionName": "test-lisa-dev-ingestion-ingest-schedule-3cc34a6fd065",
+ "FunctionName": "test-lisa-dev-ingestion-ingest-schedule-94117c65ef0f",
"Handler": "repository.pipeline_ingest_documents.handle_pipline_ingest_schedule",
"Layers": [
{
@@ -1891,7 +1908,7 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
- "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E5c42b50017b167866e923d052aafed408": {
+ "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E54d7ec1f630ae7ff839416c4f723eaa43": {
"Type": "AWS::Lambda::Version",
"Properties": {
"FunctionName": {
@@ -1910,7 +1927,7 @@
},
"FunctionVersion": {
"Fn::GetAtt": [
- "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E5c42b50017b167866e923d052aafed408",
+ "IngestionStackConstructhandlePipelineIngestScheduleCurrentVersion094270E54d7ec1f630ae7ff839416c4f723eaa43",
"Version"
]
},
@@ -1958,7 +1975,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2048,7 +2065,7 @@
"TIKTOKEN_CACHE_DIR": "/opt/python/TIKTOKEN_CACHE"
}
},
- "FunctionName": "test-lisa-dev-ingestion-ingest-event-3cc34a6fd065",
+ "FunctionName": "test-lisa-dev-ingestion-ingest-event-94117c65ef0f",
"Handler": "repository.pipeline_ingest_documents.handle_pipeline_ingest_event",
"Layers": [
{
@@ -2087,7 +2104,7 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
- "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBCb6b0f073afad92369d9628168229d909": {
+ "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBC2dee4c902e71215dce0dc6f1b013820d": {
"Type": "AWS::Lambda::Version",
"Properties": {
"FunctionName": {
@@ -2106,7 +2123,7 @@
},
"FunctionVersion": {
"Fn::GetAtt": [
- "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBCb6b0f073afad92369d9628168229d909",
+ "IngestionStackConstructhandlePipelineIngestEventCurrentVersion5A33ADBC2dee4c902e71215dce0dc6f1b013820d",
"Version"
]
},
@@ -2154,7 +2171,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -2244,7 +2261,7 @@
"TIKTOKEN_CACHE_DIR": "/opt/python/TIKTOKEN_CACHE"
}
},
- "FunctionName": "test-lisa-dev-ingestion-delete-event-3cc34a6fd065",
+ "FunctionName": "test-lisa-dev-ingestion-delete-event-94117c65ef0f",
"Handler": "repository.pipeline_ingest_documents.handle_pipeline_delete_event",
"Layers": [
{
@@ -2283,7 +2300,7 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
- "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E95104b05cd6c34cc959b96300fb93a3a17": {
+ "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E9561837d7a9baa5a3011c1dda4e72825c4": {
"Type": "AWS::Lambda::Version",
"Properties": {
"FunctionName": {
@@ -2302,7 +2319,7 @@
},
"FunctionVersion": {
"Fn::GetAtt": [
- "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E95104b05cd6c34cc959b96300fb93a3a17",
+ "IngestionStackConstructhandlePipelineDeleteEventCurrentVersion74AC0E9561837d7a9baa5a3011c1dda4e72825c4",
"Version"
]
},
@@ -5300,7 +5317,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List all repositories",
"Environment": {
@@ -5427,12 +5444,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistallLogRetention1CE5334B": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistall7B1FB82C"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositoryliststatus7E4D9E23": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List status for all repositories",
"Environment": {
@@ -5559,12 +5599,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositoryliststatusLogRetention61796A73": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositoryliststatus7E4D9E23"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorypresignedurlBBDFFDED": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Generates a presigned url for uploading files to RAG",
"Environment": {
@@ -5691,12 +5754,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorypresignedurlLogRetentionE8BB59A3": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorypresignedurlBBDFFDED"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorycreate82D293D2": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Create a new repository",
"Environment": {
@@ -5823,12 +5909,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorycreateLogRetentionE3AA1EAC": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorycreate82D293D2"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorygetrepositorybyid02421B2E": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Get a repository by ID",
"Environment": {
@@ -5955,12 +6064,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorygetrepositorybyidLogRetentionF1424910": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorygetrepositorybyid02421B2E"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositoryupdaterepository82B1F9CD": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Update a repository",
"Environment": {
@@ -6087,12 +6219,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositoryupdaterepositoryLogRetention3BFD999D": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositoryupdaterepository82B1F9CD"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorydelete061FD77D": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Delete a repository",
"Environment": {
@@ -6219,12 +6374,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorydeleteLogRetentionE5403F1F": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorydelete061FD77D"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorysimilaritysearch4A105094": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Run a similarity search against the specified repository using the specified query",
"Environment": {
@@ -6351,12 +6529,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorysimilaritysearchLogRetention4AA42A55": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorysimilaritysearch4A105094"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositoryingestdocumentsD194F7BE": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Ingest a set of documents based on specified S3 path",
"Environment": {
@@ -6483,12 +6684,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositoryingestdocumentsLogRetention4F1A6C52": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositoryingestdocumentsD194F7BE"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorylistdocs0127CF73": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List all docs for a repository",
"Environment": {
@@ -6615,12 +6839,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistdocsLogRetentionD230F85A": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistdocs0127CF73"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorygetdocument7F61DEFF": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Get a document by ID",
"Environment": {
@@ -6747,12 +6994,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorygetdocumentLogRetentionA1A1CC49": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorygetdocument7F61DEFF"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorydownloaddocument5BF87366": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Creates presigned url to download document within repository",
"Environment": {
@@ -6879,12 +7149,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorydownloaddocumentLogRetentionF756AEC9": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorydownloaddocument5BF87366"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorydeletedocuments4D39BB65": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Deletes all records associated with documents from the repository",
"Environment": {
@@ -7011,12 +7304,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorydeletedocumentsLogRetentionFC2B290A": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorydeletedocuments4D39BB65"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorylistjobs49FA647F": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List all ingestion jobs for a repository",
"Environment": {
@@ -7143,12 +7459,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistjobsLogRetention7655158E": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistjobs49FA647F"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorylistcollections2D9E8AB8": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List all collections within a repository",
"Environment": {
@@ -7275,12 +7614,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistcollectionsLogRetentionD0DC7C00": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistcollections2D9E8AB8"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorylistusercollections77D929CE": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List all collections user has access to across all repositories",
"Environment": {
@@ -7407,12 +7769,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistusercollectionsLogRetention20E7DB29": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistusercollections77D929CE"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorycreatecollectionAC48C51B": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Create a new collection within a repository",
"Environment": {
@@ -7539,12 +7924,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorycreatecollectionLogRetention42E40FCE": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorycreatecollectionAC48C51B"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorygetcollection8E840085": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Get a collection by ID within a repository",
"Environment": {
@@ -7671,12 +8079,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorygetcollectionLogRetention913E512B": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorygetcollection8E840085"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositoryupdatecollection075A4C93": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Update a collection within a repository",
"Environment": {
@@ -7803,12 +8234,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositoryupdatecollectionLogRetention1AF5BFC7": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositoryupdatecollection075A4C93"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorydeletecollection389837F0": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "Delete a collection within a repository",
"Environment": {
@@ -7935,12 +8389,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorydeletecollectionLogRetention7F155F31": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorydeletecollection389837F0"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorylistbedrockknowledgebases956CBADC": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List all ACTIVE Bedrock Knowledge Bases",
"Environment": {
@@ -8067,12 +8544,35 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistbedrockknowledgebasesLogRetention05BAFF36": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistbedrockknowledgebases956CBADC"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
"RepositoryApiLisaRAGrepositorylistbedrockdatasources5AF92659": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Description": "List data sources for a Bedrock Knowledge Base",
"Environment": {
@@ -8199,6 +8699,106 @@
"LisaRAGResourcesLisaRagLambdaExecutionRolePolicy1F0EBC60"
]
},
+ "RepositoryApiLisaRAGrepositorylistbedrockdatasourcesLogRetention23BD6ABD": {
+ "Type": "Custom::LogRetention",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn"
+ ]
+ },
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "RepositoryApiLisaRAGrepositorylistbedrockdatasources5AF92659"
+ }
+ ]
+ ]
+ },
+ "RetentionInDays": 30
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy"
+ ],
+ "Effect": "Allow",
+ "Resource": "*"
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ }
+ ]
+ }
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Handler": "index.handler",
+ "Runtime": "nodejs22.x",
+ "Timeout": 900,
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"
+ },
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn"
+ ]
+ }
+ },
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB"
+ ]
+ },
"VectorStoreCreatorStackdevtestlisalisarolestestlisaVectorStoreCreatorRole34491B98": {
"Type": "AWS::IAM::Role",
"Properties": {
@@ -8273,23 +8873,46 @@
},
{
"Action": [
- "iam:CreateRole",
- "iam:DeleteRole",
"iam:AttachRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
- "iam:DeleteRolePolicy",
- "iam:TagRole",
- "iam:UntagRole",
+ "iam:DeleteRolePolicy"
+ ],
+ "Condition": {
+ "ArnNotEquals": {
+ "iam:ResourceArn": {
+ "Fn::GetAtt": [
+ "VectorStoreCreatorStackdevtestlisalisarolestestlisaVectorStoreCreatorRole34491B98",
+ "Arn"
+ ]
+ }
+ }
+ },
+ "Effect": "Allow",
+ "Resource": "*"
+ },
+ {
+ "Action": [
+ "iam:CreateRole",
+ "iam:DeleteRole",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListRolePolicies",
"iam:ListAttachedRolePolicies",
- "iam:ListRoleTags",
+ "iam:TagRole",
+ "iam:UntagRole",
"iam:UpdateAssumeRolePolicy",
- "iam:ListRoles"
+ "iam:ListRoleTags"
],
"Effect": "Allow",
+ "Resource": [
+ "arn:aws:iam::012345678901:role/lisa-test-lisa-dev-vector*",
+ "arn:aws:iam::012345678901:role/test-lisa-lisa-dev-vector*"
+ ]
+ },
+ {
+ "Action": "iam:ListRoles",
+ "Effect": "Allow",
"Resource": "*"
},
{
@@ -8798,7 +9421,7 @@
"Fn::Join": [
"",
[
- "{\"accountNumber\":\"012345678901\",\"appName\":\"lisa\",\"deploymentName\":\"test-lisa\",\"deploymentStage\":\"dev\",\"deploymentPrefix\":\"/dev/test-lisa/lisa\",\"partition\":\"aws\",\"region\":\"us-iso-east-1\",\"removalPolicy\":\"destroy\",\"profile\":\"\",\"vpcId\":\"",
+ "{\"accountNumber\":\"012345678901\",\"appName\":\"lisa\",\"deploymentName\":\"test-lisa\",\"deploymentStage\":\"dev\",\"deploymentPrefix\":\"/dev/test-lisa/lisa\",\"iamRdsAuth\":true,\"partition\":\"aws\",\"region\":\"us-iso-east-1\",\"removalPolicy\":\"destroy\",\"profile\":\"\",\"vpcId\":\"",
{
"Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPC8B8C4E4BB8544CDA"
},
@@ -8879,7 +9502,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -9058,7 +9681,7 @@
{
"Ref": "AWS::Partition"
},
- ":states:::aws-sdk:cloudformation:describeStacks\",\"Parameters\":{\"StackName.$\":\"$.deployResult.stackName\"}},\"Wait\":{\"Type\":\"Wait\",\"Seconds\":30,\"Next\":\"CheckDeploymentStatus\"},\"DeploymentComplete?\":{\"Type\":\"Choice\",\"Choices\":[{\"And\":[{\"Variable\":\"$.deployResult.status\",\"IsPresent\":true},{\"Or\":[{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"CREATE_IN_PROGRESS\"},{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"UPDATE_IN_PROGRESS\"},{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"UPDATE_COMPLETE_CLEANUP_IN_PROGRESS\"}]}],\"Next\":\"Wait\"},{\"And\":[{\"Variable\":\"$.deployResult.status\",\"IsPresent\":true},{\"Or\":[{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"CREATE_COMPLETE\"},{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"UPDATE_COMPLETE\"}]}],\"Next\":\"IsBedrockKB?\"}],\"Default\":\"UpdateFailureStatus\"},\"UpdateFailureStatus\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:",
+ ":states:::aws-sdk:cloudformation:describeStacks\",\"Parameters\":{\"StackName.$\":\"$.deployResult.stackName\"}},\"Wait\":{\"Type\":\"Wait\",\"Seconds\":30,\"Next\":\"CheckDeploymentStatus\"},\"DeploymentComplete?\":{\"Type\":\"Choice\",\"Choices\":[{\"And\":[{\"Variable\":\"$.deployResult.status\",\"IsPresent\":true},{\"Or\":[{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"CREATE_IN_PROGRESS\"},{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"UPDATE_IN_PROGRESS\"},{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"UPDATE_COMPLETE_CLEANUP_IN_PROGRESS\"}]}],\"Next\":\"Wait\"},{\"And\":[{\"Variable\":\"$.deployResult.status\",\"IsPresent\":true},{\"Or\":[{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"CREATE_COMPLETE\"},{\"Variable\":\"$.deployResult.status\",\"StringEquals\":\"UPDATE_COMPLETE\"}]}],\"Next\":\"IsBedrockKB?\"}],\"Default\":\"UpdateFailureStatus\"},\"UpdateFailureStatus\":{\"Next\":\"FailExecution\",\"Type\":\"Task\",\"Resource\":\"arn:",
{
"Ref": "AWS::Partition"
},
@@ -9089,7 +9712,7 @@
}
]
},
- "\",\"ExpressionAttributeNames\":{\"#status\":\"status\"},\"ExpressionAttributeValues\":{\":status\":{\"S\":\"CREATE_FAILED\"}},\"UpdateExpression\":\"SET #status = :status\"}},\"IsBedrockKB?\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.body.ragConfig.type\",\"StringEquals\":\"bedrock_knowledge_base\",\"Next\":\"CreateDefaultCollection\"}],\"Default\":\"SkipCollectionCreation\"},\"SkipCollectionCreation\":{\"Type\":\"Pass\",\"Next\":\"UpdateSuccessStatus\"},\"UpdateSuccessStatus\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:",
+ "\",\"ExpressionAttributeNames\":{\"#status\":\"status\"},\"ExpressionAttributeValues\":{\":status\":{\"S\":\"CREATE_FAILED\"}},\"UpdateExpression\":\"SET #status = :status\"}},\"FailExecution\":{\"Type\":\"Fail\",\"Error\":\"DeploymentFailed\",\"Cause\":\"Vector store deployment failed\"},\"IsBedrockKB?\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.body.ragConfig.type\",\"StringEquals\":\"bedrock_knowledge_base\",\"Next\":\"CreateDefaultCollection\"}],\"Default\":\"SkipCollectionCreation\"},\"SkipCollectionCreation\":{\"Type\":\"Pass\",\"Next\":\"UpdateSuccessStatus\"},\"UpdateSuccessStatus\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:",
{
"Ref": "AWS::Partition"
},
@@ -9224,7 +9847,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -9373,7 +9996,7 @@
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "60ef36de618f3c0976d6852620edaaebacfcdbdbcf79082154f7b89228a25147.zip"
+ "S3Key": "d48342ef6b586ea1b32bfc4a7362678941da35c794d23108c8162dbba8b0d5a0.zip"
},
"Environment": {
"Variables": {
@@ -9631,7 +10254,7 @@
{
"Ref": "AWS::Partition"
},
- ":states:::aws-sdk:cloudformation:deleteStack\",\"Parameters\":{\"StackName.$\":\"$.stackName\"}},\"Wait\":{\"Type\":\"Wait\",\"Seconds\":30,\"Next\":\"Check Stack Status\"},\"DeletionSuccessful?\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.checkResult.status\",\"StringEquals\":\"DELETE_FAILED\",\"Next\":\"UpdateFailureStatus\"}],\"Default\":\"Wait\"},\"UpdateFailureStatus\":{\"End\":true,\"Type\":\"Task\",\"Resource\":\"arn:",
+ ":states:::aws-sdk:cloudformation:deleteStack\",\"Parameters\":{\"StackName.$\":\"$.stackName\"}},\"Wait\":{\"Type\":\"Wait\",\"Seconds\":30,\"Next\":\"Check Stack Status\"},\"DeletionSuccessful?\":{\"Type\":\"Choice\",\"Choices\":[{\"Variable\":\"$.checkResult.status\",\"StringEquals\":\"DELETE_FAILED\",\"Next\":\"UpdateFailureStatus\"}],\"Default\":\"Wait\"},\"UpdateFailureStatus\":{\"Next\":\"FailExecution\",\"Type\":\"Task\",\"Resource\":\"arn:",
{
"Ref": "AWS::Partition"
},
@@ -9662,7 +10285,7 @@
}
]
},
- "\",\"ExpressionAttributeNames\":{\"#status\":\"status\",\"#error\":\"error\"},\"ExpressionAttributeValues\":{\":status\":{\"S\":\"$.checkResult.status\"}},\"UpdateExpression\":\"SET #status = :status, #error = :error\"}},\"CleanupRepositoryDocsRetry\":{\"End\":true,\"Retry\":[{\"ErrorEquals\":[\"Lambda.ClientExecutionTimeoutException\",\"Lambda.ServiceException\",\"Lambda.AWSLambdaException\",\"Lambda.SdkClientException\"],\"IntervalSeconds\":2,\"MaxAttempts\":6,\"BackoffRate\":2}],\"Type\":\"Task\",\"OutputPath\":\"$.Payload\",\"Resource\":\"arn:",
+ "\",\"ExpressionAttributeNames\":{\"#status\":\"status\",\"#error\":\"error\"},\"ExpressionAttributeValues\":{\":status\":{\"S\":\"$.checkResult.status\"}},\"UpdateExpression\":\"SET #status = :status, #error = :error\"}},\"FailExecution\":{\"Type\":\"Fail\",\"Error\":\"DeletionFailed\",\"Cause\":\"Vector store deletion failed\"},\"CleanupRepositoryDocsRetry\":{\"End\":true,\"Retry\":[{\"ErrorEquals\":[\"Lambda.ClientExecutionTimeoutException\",\"Lambda.ServiceException\",\"Lambda.AWSLambdaException\",\"Lambda.SdkClientException\"],\"IntervalSeconds\":2,\"MaxAttempts\":6,\"BackoffRate\":2}],\"Type\":\"Task\",\"OutputPath\":\"$.Payload\",\"Resource\":\"arn:",
{
"Ref": "AWS::Partition"
},
diff --git a/test/cdk/stacks/__baselines__/LisaServe.json b/test/cdk/stacks/__baselines__/LisaServe.json
index b24eb2683..e9312c297 100644
--- a/test/cdk/stacks/__baselines__/LisaServe.json
+++ b/test/cdk/stacks/__baselines__/LisaServe.json
@@ -1,5 +1,4 @@
{
- "Transform": "AWS::SecretsManager-2024-09-16",
"Parameters": {
"LisaServeResourcesLisaServeResourcesmanagementKeyStringParameterParameterAD609E8A": {
"Type": "AWS::SSM::Parameter::Value",
@@ -9,9 +8,9 @@
"Type": "AWS::SSM::Parameter::Value",
"Default": "/dev/test-lisa/lisa/tokenTableName"
},
- "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2023recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter": {
"Type": "AWS::SSM::Parameter::Value",
- "Default": "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id"
+ "Default": "/aws/service/ecs/optimized-ami/amazon-linux-2023/recommended/image_id"
},
"SsmParameterValuedevtestlisalisarolesRESTC96584B6F00A464EAD1953AFF4B05118Parameter": {
"Type": "AWS::SSM::Parameter::Value",
@@ -21,6 +20,18 @@
"Type": "AWS::SSM::Parameter::Value",
"Default": "/dev/test-lisa/lisa/roles/RESTEX"
},
+ "SsmParameterValuedevtestlisalisaiamAuthSetupFnArnC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/dev/test-lisa/lisa/iamAuthSetupFnArn"
+ },
+ "SsmParameterValuedevtestlisalisaiamAuthSetupRoleArnC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/dev/test-lisa/lisa/iamAuthSetupRoleArn"
+ },
+ "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter": {
+ "Type": "AWS::SSM::Parameter::Value",
+ "Default": "/dev/test-lisa/lisa/generatedImagesBucketName"
+ },
"SsmParameterValuedevtestlisalisarolesMCPWORKBENCHC96584B6F00A464EAD1953AFF4B05118Parameter": {
"Type": "AWS::SSM::Parameter::Value",
"Default": "/dev/test-lisa/lisa/roles/MCPWORKBENCH"
@@ -50,6 +61,7 @@
}
],
"BillingMode": "PAY_PER_REQUEST",
+ "DeletionProtectionEnabled": false,
"GlobalSecondaryIndexes": [
{
"IndexName": "ModelIdIndex",
@@ -300,10 +312,10 @@
"Ref": "RestApiECSClustertestlisadevASGInstanceProfile5F0036EC"
},
"ImageId": {
- "Ref": "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter"
+ "Ref": "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2023recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter"
},
"InstanceMonitoring": true,
- "InstanceType": "m5.large",
+ "InstanceType": "m5.xlarge",
"SecurityGroups": [
{
"Fn::GetAtt": [
@@ -321,7 +333,7 @@
{
"Ref": "RestApiECSClustertestlisadevClC04148B6"
},
- " >> /etc/ecs/ecs.config\nsudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP\nsudo service iptables save\necho ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config"
+ " >> /etc/ecs/ecs.config"
]
]
}
@@ -336,7 +348,6 @@
"Type": "AWS::AutoScaling::AutoScalingGroup",
"Properties": {
"AutoScalingGroupName": "test-lisa-dev-REST",
- "Cooldown": "60",
"DefaultInstanceWarmup": 60,
"LaunchConfigurationName": {
"Ref": "RestApiECSClustertestlisadevASGLaunchConfigD5B6F73C"
@@ -1039,7 +1050,7 @@
},
"Port": 443,
"Protocol": "HTTPS",
- "SslPolicy": "ELBSecurityPolicy-TLS13-1-2-2021-06"
+ "SslPolicy": "ELBSecurityPolicy-TLS13-1-2-Res-2021-06"
}
},
"RestApiECSClustertestlisadevRESTALBRESTApplicationListenerRESTRESTTgtGrpGroup18249DCC": {
@@ -1049,7 +1060,6 @@
"HealthCheckPath": "/health",
"HealthCheckTimeoutSeconds": 30,
"HealthyThresholdCount": 2,
- "Name": "test-lisa-rest-rest",
"Port": 80,
"Protocol": "HTTP",
"TargetGroupAttributes": [
@@ -1059,7 +1069,7 @@
}
],
"TargetType": "instance",
- "UnhealthyThresholdCount": 10,
+ "UnhealthyThresholdCount": 3,
"VpcId": {
"Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPC8B8C4E4BB8544CDA"
}
@@ -1072,7 +1082,6 @@
"HealthCheckPath": "/health",
"HealthCheckTimeoutSeconds": 30,
"HealthyThresholdCount": 2,
- "Name": "test-lisa-rest-mcpworkbench",
"Port": 80,
"Protocol": "HTTP",
"TargetGroupAttributes": [
@@ -1082,7 +1091,7 @@
}
],
"TargetType": "instance",
- "UnhealthyThresholdCount": 10,
+ "UnhealthyThresholdCount": 3,
"VpcId": {
"Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPC8B8C4E4BB8544CDA"
}
@@ -1212,36 +1221,34 @@
"Fn::Join": [
"",
[
- "arn:",
- {
- "Ref": "AWS::Partition"
- },
- ":rds-db:us-iso-east-1:012345678901:dbuser:",
+ "arn:aws:rds-db:us-iso-east-1:012345678901:dbuser:*/",
{
- "Fn::GetAtt": [
- "LiteLLMScalingDB7C8AE765",
- "DbiResourceId"
+ "Fn::Select": [
+ 1,
+ {
+ "Fn::Split": [
+ "/",
+ {
+ "Fn::Select": [
+ 5,
+ {
+ "Fn::Split": [
+ ":",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisarolesRESTC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
]
- },
- "/{{resolve:secretsmanager:",
- {
- "Ref": "LiteLLMScalingDBSecretAttachmentF62968F9"
- },
- ":SecretString:username::}}"
+ }
]
]
}
},
- {
- "Action": [
- "secretsmanager:GetSecretValue",
- "secretsmanager:DescribeSecret"
- ],
- "Effect": "Allow",
- "Resource": {
- "Ref": "LiteLLMScalingDBSecretAttachmentF62968F9"
- }
- },
{
"Action": [
"ssm:DescribeParameters",
@@ -1265,6 +1272,26 @@
]
]
}
+ },
+ {
+ "Action": [
+ "s3:PutObject",
+ "s3:GetObject",
+ "s3:DeleteObject"
+ ],
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::Join": [
+ "",
+ [
+ "arn:aws:s3:::",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ "/*"
+ ]
+ ]
+ }
}
],
"Version": "2012-10-17"
@@ -1391,7 +1418,7 @@
},
{
"Name": "THREADS",
- "Value": "2"
+ "Value": "4"
},
{
"Name": "USE_AUTH",
@@ -1496,6 +1523,59 @@
"Value": {
"Ref": "GuardrailsTableCEF5B750"
}
+ },
+ {
+ "Name": "GENERATED_IMAGES_S3_BUCKET_NAME",
+ "Value": {
+ "Ref": "SsmParameterValuedevtestlisalisageneratedImagesBucketNameC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ },
+ {
+ "Name": "IAM_TOKEN_DB_AUTH",
+ "Value": "true"
+ },
+ {
+ "Name": "DATABASE_HOST",
+ "Value": {
+ "Fn::GetAtt": [
+ "LiteLLMScalingDB7C8AE765",
+ "Endpoint.Address"
+ ]
+ }
+ },
+ {
+ "Name": "DATABASE_NAME",
+ "Value": "postgres"
+ },
+ {
+ "Name": "DATABASE_PORT",
+ "Value": "5432"
+ },
+ {
+ "Name": "DATABASE_USER",
+ "Value": {
+ "Fn::Select": [
+ 1,
+ {
+ "Fn::Split": [
+ "/",
+ {
+ "Fn::Select": [
+ 5,
+ {
+ "Fn::Split": [
+ ":",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisarolesRESTC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
}
],
"Essential": true,
@@ -1510,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:c16d29d4a2dfd4061c6e3401601c642d92b42e8f68072a2d44c97f9ff528a327"
+ "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:7010d3fc60c43bea5c2f4b926df34e4ef454fe95b966cc5c642b247a7554b8e6"
},
"LogConfiguration": {
"LogDriver": "awslogs",
@@ -1522,7 +1602,7 @@
"awslogs-region": "us-iso-east-1"
}
},
- "Memory": 3904,
+ "Memory": 11904,
"MemoryReservation": 2048,
"MountPoints": [
{
@@ -1767,7 +1847,7 @@
},
{
"Name": "THREADS",
- "Value": "2"
+ "Value": "4"
},
{
"Name": "USE_AUTH",
@@ -1864,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:9a0c236b30347c7353536dbc78fe25548def6ced031c13d920ffc592acdaf0d8"
+ "Fn::Sub": "012345678901.dkr.ecr.us-iso-east-1.${AWS::URLSuffix}/cdk-hnb659fds-container-assets-012345678901-us-iso-east-1:fcba8fe82ada4af0a2de9d06ec3d45e26bd0320633c64e3a5d2a1a32953789cd"
},
"LogConfiguration": {
"LogDriver": "awslogs",
@@ -1876,7 +1956,7 @@
"awslogs-region": "us-iso-east-1"
}
},
- "Memory": 3904,
+ "Memory": 11904,
"MemoryReservation": 1024,
"MountPoints": [
{
@@ -2042,24 +2122,6 @@
}
}
},
- "LISALiteLLMScalingSgfromLisaNetworkingVpcLambdaSecurityGroupE929D4AD54321CB0EB7E": {
- "Type": "AWS::EC2::SecurityGroupIngress",
- "Properties": {
- "Description": "Allow rotation Lambda to connect to database",
- "FromPort": 5432,
- "GroupId": {
- "Fn::GetAtt": [
- "LISALiteLLMScalingSg3CC6544C",
- "GroupId"
- ]
- },
- "IpProtocol": "tcp",
- "SourceSecurityGroupId": {
- "Fn::ImportValue": "LisaNetworking:ExportsOutputFnGetAttVpcLambdaSecurityGroup184B54BDGroupIdB1374FFB"
- },
- "ToPort": 5432
- }
- },
"LiteLLMScalingDBSubnetGroup03777761": {
"Type": "AWS::RDS::DBSubnetGroup",
"Properties": {
@@ -2110,63 +2172,11 @@
"TargetType": "AWS::RDS::DBInstance"
}
},
- "LiteLLMScalingDBSecretAttachmentDatabasePasswordRotationScheduleC8556257": {
- "Type": "AWS::SecretsManager::RotationSchedule",
- "Properties": {
- "HostedRotationLambda": {
- "ExcludeCharacters": " %+~`#$&*()|[]{}:;<>?!'/@\"\\",
- "RotationLambdaName": "test-lisa-Litellm-Rotation-Function",
- "RotationType": "PostgreSQLSingleUser",
- "VpcSecurityGroupIds": {
- "Fn::ImportValue": "LisaNetworking:ExportsOutputFnGetAttVpcLambdaSecurityGroup184B54BDGroupIdB1374FFB"
- },
- "VpcSubnetIds": {
- "Fn::Join": [
- "",
- [
- {
- "Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPCprivateSubnet1Subnet29B9FADC0739E75F"
- },
- ",",
- {
- "Fn::ImportValue": "LisaNetworking:ExportsOutputRefVpcVPCprivateSubnet2Subnet63498DC142E639BD"
- }
- ]
- ]
- }
- },
- "RotationRules": {
- "ScheduleExpression": "rate(30 days)"
- },
- "SecretId": {
- "Ref": "LiteLLMScalingDBSecretAttachmentF62968F9"
- }
- }
- },
"LiteLLMScalingDBSecretAttachmentPolicyDE12773F": {
"Type": "AWS::SecretsManager::ResourcePolicy",
"Properties": {
"ResourcePolicy": {
"Statement": [
- {
- "Action": "secretsmanager:DeleteSecret",
- "Effect": "Deny",
- "Principal": {
- "AWS": {
- "Fn::Join": [
- "",
- [
- "arn:",
- {
- "Ref": "AWS::Partition"
- },
- ":iam::012345678901:root"
- ]
- ]
- }
- },
- "Resource": "*"
- },
{
"Action": [
"secretsmanager:GetSecretValue",
@@ -2175,7 +2185,7 @@
"Effect": "Allow",
"Principal": {
"AWS": {
- "Ref": "SsmParameterValuedevtestlisalisarolesRESTC96584B6F00A464EAD1953AFF4B05118Parameter"
+ "Ref": "SsmParameterValuedevtestlisalisaiamAuthSetupRoleArnC96584B6F00A464EAD1953AFF4B05118Parameter"
}
},
"Resource": {
@@ -2199,6 +2209,7 @@
"DBSubnetGroupName": {
"Ref": "LiteLLMScalingDBSubnetGroup03777761"
},
+ "DeletionProtection": false,
"EnableIAMDatabaseAuthentication": true,
"Engine": "postgres",
"MasterUserPassword": {
@@ -2243,61 +2254,7 @@
"Endpoint.Address"
]
},
- "\",\"dbName\":\"postgres\",\"dbPort\":5432,\"passwordSecretId\":\"",
- {
- "Fn::Join": [
- "-",
- [
- {
- "Fn::Select": [
- 0,
- {
- "Fn::Split": [
- "-",
- {
- "Fn::Select": [
- 6,
- {
- "Fn::Split": [
- ":",
- {
- "Ref": "LisaServeLiteLLMScalingDBSecret7BF5A8EA3fdaad7efa858a3daf9490cf0a702aeb"
- }
- ]
- }
- ]
- }
- ]
- }
- ]
- },
- {
- "Fn::Select": [
- 1,
- {
- "Fn::Split": [
- "-",
- {
- "Fn::Select": [
- 6,
- {
- "Fn::Split": [
- ":",
- {
- "Ref": "LisaServeLiteLLMScalingDBSecret7BF5A8EA3fdaad7efa858a3daf9490cf0a702aeb"
- }
- ]
- }
- ]
- }
- ]
- }
- ]
- }
- ]
- ]
- },
- "\"}"
+ "\",\"dbName\":\"postgres\",\"dbPort\":5432}"
]
]
}
@@ -2334,6 +2291,250 @@
"Value": "[]"
}
},
+ "IamAuthSetupRoleRefPolicy0635E4EA": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:DescribeSecret"
+ ],
+ "Effect": "Allow",
+ "Resource": {
+ "Ref": "LiteLLMScalingDBSecretAttachmentF62968F9"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "IamAuthSetupRoleRefPolicy0635E4EA",
+ "Roles": [
+ {
+ "Fn::Select": [
+ 1,
+ {
+ "Fn::Split": [
+ "/",
+ {
+ "Fn::Select": [
+ 5,
+ {
+ "Fn::Split": [
+ ":",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisaiamAuthSetupRoleArnC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "LISAServeCreateDbUserCustomResourceB2AC2105": {
+ "Type": "Custom::AWS",
+ "Properties": {
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "AWS679f53fac002430cb0da5b7982bd22872D164C4C",
+ "Arn"
+ ]
+ },
+ "Create": {
+ "Fn::Join": [
+ "",
+ [
+ "{\"service\":\"Lambda\",\"action\":\"invoke\",\"physicalResourceId\":{\"id\":\"LISAServeCreateDbUserCustomResource\"},\"parameters\":{\"FunctionName\":\"",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisaiamAuthSetupFnArnC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ "\",\"Payload\":\"{\\\"secretArn\\\":\\\"",
+ {
+ "Ref": "LiteLLMScalingDBSecretAttachmentF62968F9"
+ },
+ "\\\",\\\"dbHost\\\":\\\"",
+ {
+ "Fn::GetAtt": [
+ "LiteLLMScalingDB7C8AE765",
+ "Endpoint.Address"
+ ]
+ },
+ "\\\",\\\"dbPort\\\":5432,\\\"dbName\\\":\\\"postgres\\\",\\\"dbUser\\\":\\\"postgres\\\",\\\"iamName\\\":\\\"",
+ {
+ "Fn::Select": [
+ 1,
+ {
+ "Fn::Split": [
+ "/",
+ {
+ "Fn::Select": [
+ 5,
+ {
+ "Fn::Split": [
+ ":",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisarolesRESTC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "\\\"}\"}}"
+ ]
+ ]
+ },
+ "Update": {
+ "Fn::Join": [
+ "",
+ [
+ "{\"service\":\"Lambda\",\"action\":\"invoke\",\"physicalResourceId\":{\"id\":\"LISAServeCreateDbUserCustomResource\"},\"parameters\":{\"FunctionName\":\"",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisaiamAuthSetupFnArnC96584B6F00A464EAD1953AFF4B05118Parameter"
+ },
+ "\",\"Payload\":\"{\\\"secretArn\\\":\\\"",
+ {
+ "Ref": "LiteLLMScalingDBSecretAttachmentF62968F9"
+ },
+ "\\\",\\\"dbHost\\\":\\\"",
+ {
+ "Fn::GetAtt": [
+ "LiteLLMScalingDB7C8AE765",
+ "Endpoint.Address"
+ ]
+ },
+ "\\\",\\\"dbPort\\\":5432,\\\"dbName\\\":\\\"postgres\\\",\\\"dbUser\\\":\\\"postgres\\\",\\\"iamName\\\":\\\"",
+ {
+ "Fn::Select": [
+ 1,
+ {
+ "Fn::Split": [
+ "/",
+ {
+ "Fn::Select": [
+ 5,
+ {
+ "Fn::Split": [
+ ":",
+ {
+ "Ref": "SsmParameterValuedevtestlisalisarolesRESTC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "\\\"}\"}}"
+ ]
+ ]
+ },
+ "InstallLatestAwsSdk": true
+ },
+ "DependsOn": [
+ "LISAServeCreateDbUserCustomResourceCustomResourcePolicy9D7CC803",
+ "LiteLLMScalingDB7C8AE765",
+ "LiteLLMScalingDBSecretAttachmentPolicyDE12773F",
+ "LiteLLMScalingDBSecretAttachmentF62968F9",
+ "LisaServeLiteLLMScalingDBSecret7BF5A8EA3fdaad7efa858a3daf9490cf0a702aeb",
+ "LiteLLMScalingDBSubnetGroup03777761"
+ ],
+ "UpdateReplacePolicy": "Delete",
+ "DeletionPolicy": "Delete"
+ },
+ "LISAServeCreateDbUserCustomResourceCustomResourcePolicy9D7CC803": {
+ "Type": "AWS::IAM::Policy",
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "lambda:InvokeFunction",
+ "Effect": "Allow",
+ "Resource": {
+ "Ref": "SsmParameterValuedevtestlisalisaiamAuthSetupFnArnC96584B6F00A464EAD1953AFF4B05118Parameter"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "PolicyName": "LISAServeCreateDbUserCustomResourceCustomResourcePolicy9D7CC803",
+ "Roles": [
+ {
+ "Ref": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2"
+ }
+ ]
+ },
+ "DependsOn": [
+ "LiteLLMScalingDB7C8AE765",
+ "LiteLLMScalingDBSecretAttachmentPolicyDE12773F",
+ "LiteLLMScalingDBSecretAttachmentF62968F9",
+ "LisaServeLiteLLMScalingDBSecret7BF5A8EA3fdaad7efa858a3daf9490cf0a702aeb",
+ "LiteLLMScalingDBSubnetGroup03777761"
+ ]
+ },
+ "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2": {
+ "Type": "AWS::IAM::Role",
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com"
+ }
+ }
+ ],
+ "Version": "2012-10-17"
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition"
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+ ]
+ ]
+ }
+ ]
+ }
+ },
+ "AWS679f53fac002430cb0da5b7982bd22872D164C4C": {
+ "Type": "AWS::Lambda::Function",
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
+ "S3Key": "a38f8a5114ae969de815d5c66b3ddbd646445697a789f7b16424b4ea541d4ff9.zip"
+ },
+ "Handler": "index.handler",
+ "Role": {
+ "Fn::GetAtt": [
+ "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2",
+ "Arn"
+ ]
+ },
+ "Runtime": "nodejs22.x",
+ "Timeout": 120
+ },
+ "DependsOn": [
+ "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2"
+ ]
+ },
"ModelInvokePermsACDC92EC": {
"Type": "AWS::IAM::Policy",
"Properties": {
diff --git a/test/cdk/stacks/__baselines__/LisaUI.json b/test/cdk/stacks/__baselines__/LisaUI.json
index 73c84773a..bcc2b8647 100644
--- a/test/cdk/stacks/__baselines__/LisaUI.json
+++ b/test/cdk/stacks/__baselines__/LisaUI.json
@@ -521,7 +521,7 @@
"Properties": {
"Content": {
"S3Bucket": "cdk-hnb659fds-assets-012345678901-us-iso-east-1",
- "S3Key": "c49d356cac773d491c5f7ac148995a1181498a8e289429f8612a7f7e3814f535.zip"
+ "S3Key": "0cfdecad2260a3a84ad0c2d08a77e03c9d25e26c7b52f26b1e1faf97aef92f18.zip"
},
"Description": "/opt/awscli/aws"
}
@@ -541,7 +541,7 @@
],
"SourceObjectKeys": [
"e7d1950ef401262faf75c202f139ccf71a4f9a3c62fedee85354530a22a96213.zip",
- "443fbcadeaa8f915b4a16856151eeeb63c06b984d591a259a1ab6c50e82609ca.zip"
+ "c946322f9aa10420b57114a8bfc511888f0b50606eed92dd38b00c95ddcac529.zip"
],
"SourceMarkers": [
{},
diff --git a/test/cdk/stacks/roleOverrides.test.ts b/test/cdk/stacks/roleOverrides.test.ts
index 97e0944f1..d40072123 100644
--- a/test/cdk/stacks/roleOverrides.test.ts
+++ b/test/cdk/stacks/roleOverrides.test.ts
@@ -21,21 +21,22 @@ import { Roles } from '../../../lib/core/iam/roles';
import { Stack } from 'aws-cdk-lib';
const stackRolesOverrides: Record = {
- 'LisaApiBase': 2,
- 'LisaServe': 3,
+ 'LisaApiBase': 5,
+ 'LisaServe': 4,
'LisaUI': 1,
'LisaDocs': 2,
- 'LisaRAG': 4,
+ 'LisaRAG': 5,
'LisaChat': 1,
'LisaCore': 1,
- 'LisaModels': 1,
- 'LisaMcpWorkbench': 4,
- 'LisaMcpApi': 5,
+ 'LisaModels': 3,
+ 'LisaMcpWorkbench': 5,
+ 'LisaMcpApi': 6,
+ 'LisaMetrics': 1
};
const stackRoles: Record = {
- 'LisaApiBase': 3,
- 'LisaServe': 3,
+ 'LisaApiBase': 6,
+ 'LisaServe': 4,
'LisaUI': 3,
'LisaNetworking': 0,
'LisaChat': 6,
@@ -43,11 +44,11 @@ const stackRoles: Record = {
'LisaApiDeployment': 0,
'LisaIAM': 5,
'LisaDocs': 4,
- 'LisaModels': 10,
- 'LisaRAG': 4,
- 'LisaMetrics': 1,
- 'LisaMcpWorkbench': 4,
- 'LisaMcpApi': 7,
+ 'LisaModels': 12,
+ 'LisaRAG': 5,
+ 'LisaMetrics': 2,
+ 'LisaMcpWorkbench': 5,
+ 'LisaMcpApi': 8,
};
describe('Verify role overrides', () => {
diff --git a/test/cdk/stacks/snapshot.test.ts b/test/cdk/stacks/snapshot.test.ts
index 5f08abb38..a50207256 100644
--- a/test/cdk/stacks/snapshot.test.ts
+++ b/test/cdk/stacks/snapshot.test.ts
@@ -21,12 +21,82 @@ import fs from 'fs';
import path from 'path';
const BASELINE_DIR = path.join(__dirname, '__baselines__');
+const UPDATE_BASELINES = process.argv.includes('--updateBaselines');
+
+/**
+ * Map of CloudFormation resource types to their immutable properties that cause replacement when changed.
+ * This is not exhaustive but covers the most common resources and their replacement-triggering properties.
+ */
+const REPLACEMENT_PROPERTIES: Record = {
+ 'AWS::S3::Bucket': ['BucketName'],
+ 'AWS::DynamoDB::Table': ['TableName', 'KeySchema', 'LocalSecondaryIndexes'],
+ 'AWS::Lambda::Function': ['FunctionName', 'Runtime'],
+ 'AWS::Lambda::LayerVersion': ['LayerName', 'CompatibleRuntimes'],
+ 'AWS::IAM::Role': ['RoleName', 'Path'],
+ 'AWS::IAM::Policy': ['PolicyName'],
+ 'AWS::IAM::ManagedPolicy': ['ManagedPolicyName', 'Path'],
+ 'AWS::SSM::Parameter': ['Name', 'Type'],
+ 'AWS::SQS::Queue': ['QueueName', 'FifoQueue'],
+ 'AWS::SNS::Topic': ['TopicName', 'FifoTopic'],
+ 'AWS::EC2::SecurityGroup': ['GroupName', 'VpcId'],
+ 'AWS::EC2::VPC': ['CidrBlock'],
+ 'AWS::EC2::Subnet': ['VpcId', 'AvailabilityZone', 'CidrBlock'],
+ 'AWS::ECS::Cluster': ['ClusterName'],
+ 'AWS::ECS::Service': ['ServiceName', 'LaunchType'],
+ 'AWS::ECS::TaskDefinition': ['Family'],
+ 'AWS::ElasticLoadBalancingV2::LoadBalancer': ['Name', 'Scheme', 'Type'],
+ 'AWS::ElasticLoadBalancingV2::TargetGroup': ['TargetType', 'Protocol', 'Port', 'VpcId'],
+ 'AWS::ApiGateway::RestApi': ['Name'],
+ 'AWS::ApiGateway::Resource': ['ParentId', 'PathPart', 'RestApiId'],
+ 'AWS::ApiGateway::Method': ['HttpMethod', 'ResourceId', 'RestApiId'],
+ 'AWS::CloudWatch::Alarm': ['AlarmName'],
+ 'AWS::Logs::LogGroup': ['LogGroupName'],
+ 'AWS::KMS::Key': ['KeySpec', 'KeyUsage'],
+ 'AWS::KMS::Alias': ['AliasName'],
+ 'AWS::SecretsManager::Secret': ['Name'],
+ 'AWS::RDS::DBInstance': ['DBInstanceIdentifier', 'Engine', 'DBName'],
+ 'AWS::RDS::DBCluster': ['DBClusterIdentifier', 'Engine', 'DatabaseName'],
+ 'AWS::Cognito::UserPool': ['UserPoolName'],
+ 'AWS::Cognito::UserPoolClient': ['ClientName'],
+ 'AWS::StepFunctions::StateMachine': ['StateMachineName', 'StateMachineType'],
+ 'AWS::Events::Rule': ['Name', 'EventBusName'],
+ 'AWS::CloudFront::Distribution': [],
+ 'AWS::Route53::HostedZone': ['Name'],
+ 'AWS::ECR::Repository': ['RepositoryName'],
+ 'AWS::OpenSearchService::Domain': ['DomainName'],
+ 'AWS::Elasticsearch::Domain': ['DomainName'],
+};
+
+type BreakingChange = {
+ type: 'REMOVED' | 'TYPE_CHANGED' | 'REPLACEMENT_PROPERTY' | 'STATEFUL_REMOVED';
+ logicalId: string;
+ resourceType: string;
+ details: string;
+ severity: 'HIGH' | 'MEDIUM' | 'LOW';
+};
+
+/**
+ * Resource types that are stateful and their removal would cause data loss.
+ */
+const STATEFUL_RESOURCES = new Set([
+ 'AWS::S3::Bucket',
+ 'AWS::DynamoDB::Table',
+ 'AWS::RDS::DBInstance',
+ 'AWS::RDS::DBCluster',
+ 'AWS::EFS::FileSystem',
+ 'AWS::OpenSearchService::Domain',
+ 'AWS::Elasticsearch::Domain',
+ 'AWS::Cognito::UserPool',
+ 'AWS::SecretsManager::Secret',
+ 'AWS::SSM::Parameter',
+ 'AWS::KMS::Key',
+]);
describe('Stack Migration Tests', () => {
const stacks = MockApp.getStacks();
stacks?.forEach((stack: Stack) => {
- it(`${stack.stackName} has no resource replacements`, () => {
+ it(`${stack.stackName} has no breaking changes`, () => {
const template = Template.fromStack(stack);
const current = template.toJSON();
const baselinePath = path.join(BASELINE_DIR, `${stack.stackName}.json`);
@@ -39,30 +109,129 @@ describe('Stack Migration Tests', () => {
}
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
- const replacements = detectResourceReplacements(baseline, current);
+ const breakingChanges = detectBreakingChanges(baseline, current);
- if (replacements.length > 0) {
- console.warn(`\nResource changes detected in ${stack.stackName}:`);
- replacements.forEach((r) => console.warn(` - ${r}`));
+ if (breakingChanges.length > 0) {
+ if (UPDATE_BASELINES) {
+ console.warn(`\n⚠️ Updating baseline for ${stack.stackName} due to breaking changes:`);
+ breakingChanges.forEach((change) => {
+ const severityIcon = change.severity === 'HIGH' ? '🔴' : change.severity === 'MEDIUM' ? '🟡' : '🟢';
+ console.warn(` ${severityIcon} [${change.type}] ${change.logicalId} (${change.resourceType})`);
+ console.warn(` ${change.details}`);
+ });
+ fs.writeFileSync(baselinePath, JSON.stringify(current, null, 2));
+ return;
+ }
+
+ // Log all changes regardless of severity
+ console.warn(`\n⚠️ Changes detected in ${stack.stackName}:`);
+ breakingChanges.forEach((change) => {
+ const severityIcon = change.severity === 'HIGH' ? '🔴' : change.severity === 'MEDIUM' ? '🟡' : '🟢';
+ console.warn(` ${severityIcon} [${change.type}] ${change.logicalId} (${change.resourceType})`);
+ console.warn(` ${change.details}`);
+ });
+ }
+
+ // Only fail on HIGH severity changes
+ const highSeverityChanges = breakingChanges.filter((change) => change.severity === 'HIGH');
+ if (highSeverityChanges.length > 0) {
+ console.error('\n❌ HIGH severity breaking changes require attention!');
+ console.error('To update baselines after intentional changes, run:');
+ console.error(' npm run test:update-baselines\n');
}
- expect(replacements).toEqual([]);
+ expect(highSeverityChanges).toEqual([]);
});
});
});
-function detectResourceReplacements (baseline: any, current: any): string[] {
- const replacements: string[] = [];
+/**
+ * Detects breaking changes between baseline and current CloudFormation templates.
+ * Breaking changes include:
+ * - Removed resources (logical ID exists in baseline but not in current)
+ * - Type changes (same logical ID but different resource type)
+ * - Immutable property changes that trigger resource replacement
+ */
+function detectBreakingChanges (baseline: any, current: any): BreakingChange[] {
+ const breakingChanges: BreakingChange[] = [];
const baselineResources = baseline.Resources || {};
const currentResources = current.Resources || {};
+ // Check for removed resources
for (const [logicalId, baselineResource] of Object.entries(baselineResources)) {
- const currentResource = currentResources[logicalId];
+ const resource = baselineResource as { Type: string; Properties?: Record };
+ const currentResource = currentResources[logicalId] as { Type: string; Properties?: Record } | undefined;
+
+ if (!currentResource) {
+ const isStateful = STATEFUL_RESOURCES.has(resource.Type);
+ breakingChanges.push({
+ type: isStateful ? 'STATEFUL_REMOVED' : 'REMOVED',
+ logicalId,
+ resourceType: resource.Type,
+ details: isStateful
+ ? 'Stateful resource removed - this will cause DATA LOSS'
+ : 'Resource removed - CloudFormation will delete this resource',
+ severity: isStateful ? 'HIGH' : 'MEDIUM',
+ });
+ continue;
+ }
- if (currentResource && (baselineResource as any).Type !== (currentResource as any).Type) {
- replacements.push(`Resource ${logicalId} type changed from ${(baselineResource as any).Type} to ${(currentResource as any).Type}`);
+ // Check for type changes
+ if (resource.Type !== currentResource.Type) {
+ breakingChanges.push({
+ type: 'TYPE_CHANGED',
+ logicalId,
+ resourceType: resource.Type,
+ details: `Type changed from ${resource.Type} to ${currentResource.Type}`,
+ severity: 'HIGH',
+ });
+ continue;
}
+
+ // Check for replacement-triggering property changes
+ const replacementProps = REPLACEMENT_PROPERTIES[resource.Type] || [];
+ for (const prop of replacementProps) {
+ const baselineValue = resource.Properties?.[prop];
+ const currentValue = currentResource.Properties?.[prop];
+
+ if (baselineValue !== undefined && !deepEqual(baselineValue, currentValue)) {
+ breakingChanges.push({
+ type: 'REPLACEMENT_PROPERTY',
+ logicalId,
+ resourceType: resource.Type,
+ details: `Property '${prop}' changed from ${JSON.stringify(baselineValue)} to ${JSON.stringify(currentValue)} - this triggers REPLACEMENT`,
+ severity: STATEFUL_RESOURCES.has(resource.Type) ? 'HIGH' : 'MEDIUM',
+ });
+ }
+ }
+ }
+
+ return breakingChanges;
+}
+
+/**
+ * Deep equality check for CloudFormation property values.
+ * Handles objects, arrays, and primitive values.
+ */
+function deepEqual (a: unknown, b: unknown): boolean {
+ if (a === b) return true;
+ if (a === null || b === null) return false;
+ if (typeof a !== typeof b) return false;
+
+ if (Array.isArray(a) && Array.isArray(b)) {
+ if (a.length !== b.length) return false;
+ return a.every((val, idx) => deepEqual(val, b[idx]));
+ }
+
+ if (typeof a === 'object' && typeof b === 'object') {
+ const aObj = a as Record;
+ const bObj = b as Record;
+ const aKeys = Object.keys(aObj);
+ const bKeys = Object.keys(bObj);
+
+ if (aKeys.length !== bKeys.length) return false;
+ return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
}
- return replacements;
+ return false;
}
diff --git a/test/integration/README.md b/test/integration/README.md
new file mode 100644
index 000000000..3f217a652
--- /dev/null
+++ b/test/integration/README.md
@@ -0,0 +1,339 @@
+# Integration Tests
+
+This directory contains integration tests that require a deployed LISA environment to run. These tests are separated from unit tests to avoid slowing down the regular test suite and to prevent test failures when environment variables are not configured.
+
+## Test Categories
+
+### RAG Integration Tests (`rag/`)
+
+End-to-end tests for RAG (Retrieval-Augmented Generation) collections functionality:
+- Collection creation and management
+- Document ingestion to collections
+- Similarity search within collections
+- Document deletion and cleanup
+- Collection deletion and full cleanup
+
+**Location:** `test/integration/rag/test_rag_collections_integration.py`
+
+### Repository Metadata Preservation Tests
+
+Tests for preserving pipeline metadata during repository updates. Some tests are skipped pending refactoring work.
+
+**Location:** `test/integration/test_repository_update_metadata_preservation.py`
+
+### SDK Integration Tests (`sdk/`)
+
+Integration tests for the LISA SDK that test end-to-end functionality against a deployed LISA environment:
+- API operations (models, repositories, configs, sessions)
+- LLM proxy operations
+- RAG operations
+
+**Location:** `test/integration/sdk/`
+**See:** `test/integration/sdk/README.md` for detailed documentation
+
+## Prerequisites
+
+All integration tests require:
+
+1. **Deployed LISA Environment** - A running LISA deployment in AWS
+2. **AWS Credentials** - Configured AWS credentials with appropriate permissions
+3. **Environment Variables or CLI Arguments** - Specific to each test suite (see below)
+
+## Running Integration Tests
+
+### RAG Integration Tests
+
+**Prerequisites:**
+- `LISA_API_URL` - URL of the deployed LISA API
+- `LISA_DEPLOYMENT_NAME` - Name of the LISA deployment
+- `AWS_DEFAULT_REGION` - AWS region where LISA is deployed
+- `LISA_VERIFY_SSL` - (Optional) Set to "false" to disable SSL verification
+- `LISA_DEPLOYMENT_STAGE` - (Optional) Deployment stage
+- `TEST_REPOSITORY_ID` - (Optional) Repository ID to use (default: "test-pgvector-rag")
+- `TEST_EMBEDDING_MODEL` - (Optional) Embedding model to use (default: "titan-embed")
+
+**Run with Make:**
+```bash
+make test-rag-integ
+```
+
+**Run with pytest:**
+```bash
+# Set environment variables
+export LISA_API_URL="https://your-api-url.com"
+export LISA_DEPLOYMENT_NAME="your-deployment"
+export AWS_DEFAULT_REGION="us-east-1"
+
+# Run tests
+pytest test/integration/rag/test_rag_collections_integration.py -v
+```
+
+**Run with the provided script:**
+```bash
+cd test/integration/rag
+./run-integration-tests.sh --api-url https://your-api-url.com
+```
+
+**What gets tested:**
+- ✅ Collection creation and retrieval
+- ✅ Document ingestion and listing
+- ✅ Similarity search on collections
+- ✅ Document deletion and cleanup
+- ✅ User collections across repositories
+- ✅ Collection deletion with documents
+
+### SDK Integration Tests
+
+**Prerequisites:**
+- `--api` or `--url` - API Gateway URL or REST URL
+- `--region` - AWS region (default: us-west-2)
+- `--deployment` - Deployment name (default: app)
+- `--profile` - AWS profile (default: default)
+- `--verify` - SSL verification (default: false)
+- `--stage` - Deployment stage (default: dev)
+
+**Run with Make:**
+```bash
+make test-sdk-integ
+```
+
+**Run with pytest:**
+```bash
+pytest test/integration/sdk/ \
+ --api https://your-api-gateway-url.execute-api.us-west-2.amazonaws.com/prod \
+ --region us-west-2 \
+ --deployment my-deployment \
+ --profile my-aws-profile \
+ -v
+```
+
+**What gets tested:**
+- ✅ List models and embedding models
+- ✅ List repositories
+- ✅ Get configurations
+- ✅ List sessions
+- ✅ Get API documentation
+- ⏭️ LLM proxy operations (skipped - require specific models)
+- ⏭️ RAG operations (skipped - require deployed environment)
+
+### Repository Metadata Preservation Tests
+
+**Prerequisites:**
+- Standard pytest environment (no special configuration needed)
+- Tests use mocked AWS services
+
+**Run with Make:**
+```bash
+make test-metadata-integ
+```
+
+**Run with pytest:**
+```bash
+pytest test/integration/test_repository_update_metadata_preservation.py -v
+```
+
+**What gets tested:**
+- ✅ Bedrock KB updates preserve existing metadata
+- ✅ Complete metadata replacement when tags provided
+- ⏭️ Direct pipeline updates (skipped - pending refactoring)
+- ⏭️ Partial metadata updates (skipped - pending refactoring)
+- ⏭️ New collection metadata handling (skipped - pending refactoring)
+
+## Running All Integration Tests
+
+**Note:** This will attempt to run all integration tests. Tests that don't have required environment variables will be skipped.
+
+```bash
+pytest test/integration -v
+```
+
+## Test Behavior
+
+- **Automatic Skipping:** Tests automatically skip if required environment variables or CLI arguments are not provided
+- **Cleanup:** Tests include cleanup fixtures to remove created resources after execution
+- **Isolation:** Each test suite manages its own test data and cleans up after itself
+- **Timeouts:** Some tests have extended timeouts to account for infrastructure spin-up time
+
+## Excluding from Regular Test Runs
+
+Integration tests are excluded from regular test runs via `pytest.ini`:
+
+```ini
+norecursedirs = test/integration
+```
+
+This ensures that:
+- `make test` runs only unit tests (fast, no external dependencies)
+- `make test-rag-integ` runs RAG integration tests (requires deployed environment)
+- `make test-sdk-integ` runs SDK integration tests (requires deployed environment)
+- CI/CD pipelines can run unit tests quickly without requiring a deployed environment
+
+## Troubleshooting
+
+### Authentication Errors
+
+**RAG Tests:**
+- Verify environment variables are set correctly
+- Check that the API URL is accessible
+- Ensure AWS credentials have access to the LISA deployment
+
+**SDK Tests:**
+- Verify AWS credentials are configured correctly
+- Check that the deployment name matches your LISA deployment
+- Ensure the management key exists in Secrets Manager
+
+### Connection Errors
+
+- Verify the API URL is correct and accessible
+- Check SSL verification settings (use `--verify false` for self-signed certs)
+- Ensure network connectivity to the LISA deployment
+- Check security groups and network ACLs
+
+### Skipped Tests
+
+Many tests are skipped by default because they require:
+- Specific models to be deployed (TGI, instructor embeddings, etc.)
+- Specific configurations (API Gateway vs REST URL)
+- Management tokens (not all deployments support this)
+- Deployed LISA environment with specific features enabled
+
+This is expected behavior and not an error.
+
+### Timeout Errors
+
+If tests timeout:
+- Increase the timeout values in the test code
+- Check that the LISA deployment is healthy and responsive
+- Verify that batch jobs are processing correctly
+- Check CloudWatch logs for errors in Lambda functions or ECS tasks
+
+## Adding New Integration Tests
+
+When adding new integration tests:
+
+1. **Choose the appropriate directory:**
+ - `test/integration/rag/` for RAG-specific tests
+ - `test/integration/sdk/` for SDK tests
+ - `test/integration/` root for other integration tests
+
+2. **Use appropriate fixtures:**
+ - RAG tests: Use environment variables for configuration
+ - SDK tests: Use CLI arguments via `conftest.py` fixtures
+
+3. **Add skip decorators:**
+ ```python
+ @pytest.mark.skip(reason="Requires specific model deployment")
+ def test_something():
+ pass
+ ```
+
+4. **Include cleanup:**
+ - Use fixtures with `yield` for setup/teardown
+ - Track created resources and clean them up
+ - Handle cleanup failures gracefully
+
+5. **Document requirements:**
+ - Update this README with new prerequisites
+ - Add examples of how to run the new tests
+ - Document what gets tested
+
+6. **Update Make targets:**
+ - Add new make targets if needed
+ - Update existing targets to include new tests
+
+## CI/CD Considerations
+
+For CI/CD pipelines:
+
+1. **Unit tests** should run on every commit (fast, no dependencies)
+2. **Integration tests** should run:
+ - On a schedule (nightly, weekly)
+ - Before releases
+ - In a dedicated environment with deployed LISA
+
+3. **Environment setup:**
+ - Use secrets management for credentials
+ - Deploy a test LISA environment
+ - Set environment variables in CI/CD configuration
+ - Clean up resources after tests complete
+
+4. **Test isolation:**
+ - Use unique identifiers for test resources
+ - Avoid conflicts between parallel test runs
+ - Clean up resources even if tests fail
+
+## Prerequisites
+
+Integration tests require:
+
+1. **Deployed LISA Environment** - A running LISA deployment in AWS
+2. **Environment Variables:**
+ - `LISA_API_URL` - URL of the deployed LISA API
+ - `LISA_DEPLOYMENT_NAME` - Name of the LISA deployment
+ - `AWS_DEFAULT_REGION` - AWS region where LISA is deployed
+ - `LISA_VERIFY_SSL` - (Optional) Set to "false" to disable SSL verification
+ - `LISA_DEPLOYMENT_STAGE` - (Optional) Deployment stage
+ - `TEST_REPOSITORY_ID` - (Optional) Repository ID to use for tests (default: "test-pgvector-rag")
+ - `TEST_EMBEDDING_MODEL` - (Optional) Embedding model to use (default: "titan-embed")
+
+3. **AWS Credentials** - Configured AWS credentials with access to the LISA deployment
+
+## Running Integration Tests
+
+### Run All Integration Tests
+
+```bash
+make test-rag-integ
+```
+
+### Run Specific Test Suites
+
+**RAG Collections Integration Tests:**
+```bash
+pytest test/integration/rag/test_rag_collections_integration.py -v
+```
+
+**Repository Metadata Preservation Tests:**
+```bash
+make test-metadata-integ
+# or
+pytest test/integration/test_repository_update_metadata_preservation.py -v
+```
+
+### Run with Custom Configuration
+
+```bash
+export LISA_API_URL="https://your-api-url.com"
+export LISA_DEPLOYMENT_NAME="your-deployment"
+export AWS_DEFAULT_REGION="us-east-1"
+pytest test/integration -v
+```
+
+## Test Behavior
+
+- **Skipped Tests:** Integration tests are automatically skipped if required environment variables are not set
+- **Cleanup:** Tests include cleanup fixtures to remove created resources after test completion
+- **Isolation:** Each test suite manages its own test data and cleans up after itself
+
+## Excluding from Regular Test Runs
+
+Integration tests are excluded from regular test runs via `pytest.ini`:
+
+```ini
+norecursedirs = test/integration
+```
+
+This ensures that:
+- `make test` runs only unit tests (fast, no external dependencies)
+- `make test-rag-integ` runs integration tests (slower, requires deployed environment)
+- CI/CD pipelines can run unit tests quickly without requiring a deployed environment
+
+## Adding New Integration Tests
+
+When adding new integration tests:
+
+1. Place them in the appropriate subdirectory under `test/integration/`
+2. Use `pytest.skip()` to skip tests when required environment variables are missing
+3. Include cleanup fixtures to remove test resources
+4. Document required environment variables in this README
+5. Add a new make target in the Makefile if needed
diff --git a/lisa-sdk/tests/__init__.py b/test/integration/__init__.py
similarity index 100%
rename from lisa-sdk/tests/__init__.py
rename to test/integration/__init__.py
diff --git a/test/integration/rag/__init__.py b/test/integration/rag/__init__.py
new file mode 100644
index 000000000..4139ae4d0
--- /dev/null
+++ b/test/integration/rag/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/test/lambda/rag/run-integration-tests.sh b/test/integration/rag/run-integration-tests.sh
similarity index 98%
rename from test/lambda/rag/run-integration-tests.sh
rename to test/integration/rag/run-integration-tests.sh
index 887cec897..fb3e5d84c 100755
--- a/test/lambda/rag/run-integration-tests.sh
+++ b/test/integration/rag/run-integration-tests.sh
@@ -165,7 +165,7 @@ fi
# Run pytest with -x flag to stop on first failure
cd "${PROJECT_DIR}"
-python3 -m pytest test/lambda/rag/test_rag_collections_integration.py -v -s -x
+python3 -m pytest test/integration/rag/test_rag_collections_integration.py -v -s -x
echo ""
echo "✓ Integration tests completed"
diff --git a/test/lambda/rag/test_rag_collections_integration.py b/test/integration/rag/test_rag_collections_integration.py
similarity index 98%
rename from test/lambda/rag/test_rag_collections_integration.py
rename to test/integration/rag/test_rag_collections_integration.py
index 251b38db1..10de098c4 100644
--- a/test/lambda/rag/test_rag_collections_integration.py
+++ b/test/integration/rag/test_rag_collections_integration.py
@@ -31,7 +31,6 @@
import sys
import tempfile
import time
-from typing import Dict
import pytest
@@ -141,7 +140,7 @@ def test_embedding_model(self) -> str:
return os.getenv("TEST_EMBEDDING_MODEL", "titan-embed")
@pytest.fixture(scope="class")
- def test_collection(self, lisa_client: LisaApi, test_repository_id: str, test_embedding_model: str) -> Dict:
+ def test_collection(self, lisa_client: LisaApi, test_repository_id: str, test_embedding_model: str) -> dict:
"""Create a test collection for integration tests.
Args:
@@ -213,7 +212,7 @@ def test_document_file(self) -> str:
except Exception as e:
logger.warning(f"Failed to cleanup test document file: {e}")
- def test_01_create_collection(self, lisa_client: LisaApi, test_repository_id: str, test_collection: Dict):
+ def test_01_create_collection(self, lisa_client: LisaApi, test_repository_id: str, test_collection: dict):
"""Test 1: Verify collection was created via fixture.
Verifies:
@@ -241,7 +240,7 @@ def test_02_ingest_document_to_collection(
self,
lisa_client: LisaApi,
test_repository_id: str,
- test_collection: Dict,
+ test_collection: dict,
test_embedding_model: str,
test_document_file: str,
):
@@ -318,7 +317,7 @@ def test_03_similarity_search_on_collection(
self,
lisa_client: LisaApi,
test_repository_id: str,
- test_collection: Dict,
+ test_collection: dict,
):
"""Test 3: Perform similarity search on collection.
@@ -376,7 +375,7 @@ def test_04_delete_document_and_verify_cleanup(
self,
lisa_client: LisaApi,
test_repository_id: str,
- test_collection: Dict,
+ test_collection: dict,
):
"""Test 4: Delete document and verify cleanup.
@@ -445,7 +444,7 @@ def test_04_delete_document_and_verify_cleanup(
def test_05_get_user_collections(
self,
lisa_client: LisaApi,
- test_collection: Dict,
+ test_collection: dict,
):
"""Test 5: Get user collections.
diff --git a/test/integration/sdk/README.md b/test/integration/sdk/README.md
new file mode 100644
index 000000000..157b91848
--- /dev/null
+++ b/test/integration/sdk/README.md
@@ -0,0 +1,106 @@
+# LISA SDK Integration Tests
+
+This directory contains integration tests for the LISA SDK that require a deployed LISA environment to run.
+
+## Test Files
+
+- `test_api.py` - Tests basic API operations (list models, repositories, configs, sessions)
+- `test_models.py` - Tests LisaLlm model listing
+- `test_llm_proxy.py` - Tests LLM proxy operations (mostly skipped, require specific models)
+- `test_rag.py` - Tests RAG operations (mostly skipped, require deployed environment)
+- `conftest.py` - Fixtures and configuration for integration tests
+
+## Prerequisites
+
+These tests require:
+
+1. **Deployed LISA Environment** - A running LISA deployment in AWS
+2. **AWS Credentials** - Configured AWS credentials with access to:
+ - Secrets Manager (for API key retrieval)
+ - DynamoDB (for token management)
+3. **Command Line Arguments:**
+ - `--url` or `--api` - REST API or API Gateway URL
+ - `--region` - AWS region (default: us-west-2)
+ - `--deployment` - Deployment name (default: app)
+ - `--profile` - AWS profile (default: default)
+ - `--verify` - SSL verification (default: false)
+ - `--stage` - Deployment stage (default: dev)
+
+## Running Integration Tests
+
+### Basic Usage
+
+```bash
+pytest test/integration/sdk/ \
+ --api https://your-api-gateway-url.execute-api.us-west-2.amazonaws.com/prod \
+ --region us-west-2 \
+ --deployment my-deployment \
+ --profile my-aws-profile
+```
+
+### Using REST URL
+
+```bash
+pytest test/integration/sdk/ \
+ --url https://your-rest-url.elb.amazonaws.com/lisa \
+ --region us-west-2
+```
+
+### Run Specific Test File
+
+```bash
+pytest test/integration/sdk/test_api.py -v \
+ --api https://your-api-url.com \
+ --region us-west-2
+```
+
+## Test Behavior
+
+- **Authentication**: Tests automatically retrieve the management API key from AWS Secrets Manager
+- **Token Management**: Tests create temporary tokens in DynamoDB and clean them up after execution
+- **Skipped Tests**: Many tests are skipped because they require specific models or configurations to be deployed
+
+## Differences from Unit Tests
+
+| Aspect | Unit Tests | Integration Tests |
+|--------|-----------|-------------------|
+| Location | `test/lisa-sdk/` | `test/integration/sdk/` |
+| Dependencies | None (mocked) | Deployed LISA environment |
+| Speed | Fast (~0.1s) | Slow (network calls) |
+| Isolation | Fully isolated | Requires AWS resources |
+| Purpose | Test SDK logic | Test end-to-end functionality |
+
+## Adding New Integration Tests
+
+When adding new integration tests:
+
+1. Place them in this directory (`test/integration/sdk/`)
+2. Use the fixtures from `conftest.py` for authentication
+3. Add `pytest.skip()` decorators for tests that require specific configurations
+4. Document any special requirements in this README
+5. Clean up any resources created during tests
+
+## Common Issues
+
+### Authentication Errors
+
+If you see authentication errors:
+- Verify AWS credentials are configured correctly
+- Check that the deployment name matches your LISA deployment
+- Ensure the management key exists in Secrets Manager
+
+### Connection Errors
+
+If you see connection errors:
+- Verify the API URL is correct and accessible
+- Check SSL verification settings (`--verify false` for self-signed certs)
+- Ensure network connectivity to the LISA deployment
+
+### Skipped Tests
+
+Many tests are skipped by default because they require:
+- Specific models to be deployed (TGI, instructor embeddings, etc.)
+- Specific configurations (API Gateway vs REST URL)
+- Management tokens (not all deployments support this)
+
+This is expected behavior and not an error.
diff --git a/test/integration/sdk/__init__.py b/test/integration/sdk/__init__.py
new file mode 100644
index 000000000..4139ae4d0
--- /dev/null
+++ b/test/integration/sdk/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/lisa-sdk/tests/conftest.py b/test/integration/sdk/conftest.py
similarity index 94%
rename from lisa-sdk/tests/conftest.py
rename to test/integration/sdk/conftest.py
index 409e62f26..f84ccf0c9 100644
--- a/lisa-sdk/tests/conftest.py
+++ b/test/integration/sdk/conftest.py
@@ -16,7 +16,8 @@
import logging
import time
-from typing import Any, Generator, Union
+from collections.abc import Generator
+from typing import Any
import boto3
import pytest
@@ -65,7 +66,7 @@ def headers(api_key: str) -> dict:
@pytest.fixture(scope="session")
-def verify(pytestconfig: pytest.Config) -> Union[bool, Any]:
+def verify(pytestconfig: pytest.Config) -> bool | Any:
"""Get the verify argument."""
if pytestconfig.getoption("verify") == "false":
return False
@@ -121,10 +122,10 @@ def api_token(pytestconfig: pytest.Config, api_key: str) -> Generator:
@pytest.fixture(scope="session")
-def lisa_api(api: str, verify: Union[bool, str], headers: dict) -> LisaApi:
+def lisa_api(api: str, verify: bool | str, headers: dict) -> LisaApi:
return LisaApi(url=api, verify=verify, headers=headers)
@pytest.fixture(scope="session")
-def lisa_llm(url: str, verify: Union[bool, str], headers: dict) -> LisaLlm:
+def lisa_llm(url: str, verify: bool | str, headers: dict) -> LisaLlm:
return LisaLlm(url=url, verify=verify, headers=headers)
diff --git a/lisa-sdk/tests/test_api.py b/test/integration/sdk/test_api.py
similarity index 100%
rename from lisa-sdk/tests/test_api.py
rename to test/integration/sdk/test_api.py
diff --git a/lisa-sdk/tests/test_llm_proxy.py b/test/integration/sdk/test_llm_proxy.py
similarity index 100%
rename from lisa-sdk/tests/test_llm_proxy.py
rename to test/integration/sdk/test_llm_proxy.py
diff --git a/lisa-sdk/tests/test_models.py b/test/integration/sdk/test_models.py
similarity index 100%
rename from lisa-sdk/tests/test_models.py
rename to test/integration/sdk/test_models.py
diff --git a/lisa-sdk/tests/test_rag.py b/test/integration/sdk/test_rag.py
similarity index 100%
rename from lisa-sdk/tests/test_rag.py
rename to test/integration/sdk/test_rag.py
diff --git a/test/lambda/test_repository_update_metadata_preservation.py b/test/integration/test_repository_update_metadata_preservation.py
similarity index 100%
rename from test/lambda/test_repository_update_metadata_preservation.py
rename to test/integration/test_repository_update_metadata_preservation.py
diff --git a/test/lambda/conftest.py b/test/lambda/conftest.py
index d32dc2245..c749cebac 100644
--- a/test/lambda/conftest.py
+++ b/test/lambda/conftest.py
@@ -108,11 +108,22 @@ def setup_auth_patches(request, mock_auth, aws_credentials):
yield mock_auth
return
+ # Reset the auth provider singleton to ensure clean state between tests
+ try:
+ import utilities.auth_provider as auth_provider_module
+
+ auth_provider_module._auth_provider = None
+ except ImportError:
+ pass
+
patches = [
patch("utilities.auth.get_username", mock_auth.get_username),
patch("utilities.auth.get_groups", mock_auth.get_groups),
patch("utilities.auth.is_admin", mock_auth.is_admin),
patch("utilities.auth.get_user_context", mock_auth.get_user_context),
+ # 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),
]
for p in patches:
@@ -125,6 +136,14 @@ def setup_auth_patches(request, mock_auth, aws_credentials):
mock_auth.reset()
+ # Reset the auth provider singleton after test
+ try:
+ import utilities.auth_provider as auth_provider_module
+
+ auth_provider_module._auth_provider = None
+ except ImportError:
+ pass
+
@pytest.fixture
def lambda_context():
diff --git a/test/lambda/repository/services/test_repository_service.py b/test/lambda/repository/services/test_repository_service.py
index 8f3ac129f..6e545b5ba 100644
--- a/test/lambda/repository/services/test_repository_service.py
+++ b/test/lambda/repository/services/test_repository_service.py
@@ -15,7 +15,7 @@
"""Tests for repository service base class."""
import os
-from typing import Any, Dict, List, Optional
+from typing import Any
import pytest
@@ -37,14 +37,14 @@ def supports_custom_collections(self) -> bool:
def should_create_default_collection(self) -> bool:
return True
- def get_collection_id_from_config(self, pipeline_config: Dict[str, Any]) -> str:
+ def get_collection_id_from_config(self, pipeline_config: dict[str, Any]) -> str:
return pipeline_config.get("collectionId", "default-collection")
def ingest_document(
self,
job: IngestionJob,
- texts: List[str],
- metadatas: List[Dict[str, Any]],
+ texts: list[str],
+ metadatas: list[dict[str, Any]],
) -> RagDocument:
return RagDocument(
repository_id=job.repository_id,
@@ -61,7 +61,7 @@ def delete_document(
self,
document: RagDocument,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
pass
@@ -69,7 +69,7 @@ def delete_collection(
self,
collection_id: str,
s3_client: Any,
- bedrock_agent_client: Optional[Any] = None,
+ bedrock_agent_client: Any | None = None,
) -> None:
pass
@@ -78,17 +78,17 @@ def retrieve_documents(
query: str,
collection_id: str,
top_k: int,
- bedrock_agent_client: Optional[Any] = None,
- ) -> List[Dict[str, Any]]:
+ bedrock_agent_client: Any | None = None,
+ ) -> list[dict[str, Any]]:
return []
def validate_document_source(self, s3_path: str) -> str:
return s3_path
- def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Optional[Any]:
+ def get_vector_store_client(self, collection_id: str, embeddings: Any) -> Any | None:
return None
- def create_default_collection(self) -> Optional[RagCollectionConfig]:
+ def create_default_collection(self) -> RagCollectionConfig | None:
return None
diff --git a/test/lambda/test_api_tokens.py b/test/lambda/test_api_tokens.py
index f3f8284df..0f726f187 100644
--- a/test/lambda/test_api_tokens.py
+++ b/test/lambda/test_api_tokens.py
@@ -32,7 +32,9 @@
os.environ["AWS_SESSION_TOKEN"] = "testing"
os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
os.environ["AWS_REGION"] = "us-east-1"
-os.environ["TOKEN_TABLE_NAME"] = "test-token-table"
+# Set TOKEN_TABLE_NAME before importing - this will be used by lambda_functions.py
+if "TOKEN_TABLE_NAME" not in os.environ:
+ os.environ["TOKEN_TABLE_NAME"] = "test-token-table"
# Import after environment setup
from api_tokens.domain_objects import (
@@ -771,3 +773,475 @@ def test_delete_token_handler_legacy_token_admin_only(token_table, future_timest
# Admin should be able to delete legacy token
result = handler("legacy-hash", "admin", is_admin=True)
assert "deleted successfully" in result.message
+
+
+# =====================
+# Test lambda_functions.py - FastAPI endpoints
+# =====================
+
+
+@pytest.fixture
+def mock_request():
+ """Create a mock FastAPI Request object."""
+ from unittest.mock import MagicMock
+
+ request = MagicMock()
+ request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "claims": {
+ "username": "test-user",
+ "cognito:groups": "user-group",
+ }
+ }
+ }
+ }
+ }
+ return request
+
+
+@pytest.fixture
+def mock_admin_request():
+ """Create a mock FastAPI Request object for admin user."""
+ from unittest.mock import MagicMock
+
+ request = MagicMock()
+ request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "claims": {
+ "username": "admin-user",
+ "cognito:groups": "admin-group",
+ }
+ }
+ }
+ }
+ }
+ return request
+
+
+@pytest.fixture
+def mock_api_user_request():
+ """Create a mock FastAPI Request object for API user."""
+ from unittest.mock import MagicMock
+
+ request = MagicMock()
+ request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "claims": {
+ "username": "api-user",
+ "cognito:groups": "api-group",
+ }
+ }
+ }
+ }
+ }
+ return request
+
+
+@pytest.mark.asyncio
+async def test_create_token_for_user_endpoint_success(token_table, mock_admin_request, future_timestamp):
+ """Test create_token_for_user endpoint with admin user."""
+ from api_tokens.lambda_functions import create_token_for_user
+
+ with patch("api_tokens.lambda_functions.token_table", token_table):
+ with patch("api_tokens.lambda_functions.get_user_context") as mock_get_user:
+ with patch("api_tokens.lambda_functions.CreateTokenAdminHandler") as mock_handler_class:
+ mock_get_user.return_value = ("admin-user", True, ["admin-group"])
+
+ mock_handler = MagicMock()
+ mock_handler.return_value = CreateTokenResponse(
+ token="test-token",
+ tokenUUID="test-uuid",
+ tokenExpiration=future_timestamp,
+ createdDate=int(datetime.now().timestamp()),
+ username="target-user",
+ name="Test Token",
+ groups=["admin"],
+ isSystemToken=False,
+ )
+ mock_handler_class.return_value = mock_handler
+
+ request_data = CreateTokenAdminRequest(
+ tokenExpiration=future_timestamp, groups=["admin"], name="Test Token", isSystemToken=False
+ )
+
+ result = await create_token_for_user("target-user", mock_admin_request, request_data)
+
+ assert result.token == "test-token"
+ assert result.username == "target-user"
+ mock_handler.assert_called_once_with("target-user", request_data, "admin-user", True)
+
+
+@pytest.mark.asyncio
+async def test_create_token_for_user_endpoint_unauthorized(token_table, future_timestamp):
+ """Test create_token_for_user endpoint without AWS event context."""
+ from api_tokens.lambda_functions import create_token_for_user
+ from fastapi import HTTPException
+
+ mock_request = MagicMock()
+ mock_request.scope = {} # No aws.event
+
+ request_data = CreateTokenAdminRequest(tokenExpiration=future_timestamp, name="Test Token")
+
+ with pytest.raises(HTTPException) as excinfo:
+ await create_token_for_user("target-user", mock_request, request_data)
+ assert excinfo.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_create_own_token_endpoint_success(token_table, mock_api_user_request, future_timestamp):
+ """Test create_own_token endpoint with API user."""
+ from api_tokens.lambda_functions import create_own_token
+
+ with patch("api_tokens.lambda_functions.token_table", token_table):
+ with patch("api_tokens.lambda_functions.get_user_context") as mock_get_user:
+ with patch("api_tokens.lambda_functions.is_api_user") as mock_is_api:
+ with patch("api_tokens.lambda_functions.CreateTokenUserHandler") as mock_handler_class:
+ mock_get_user.return_value = ("api-user", False, ["api-group"])
+ mock_is_api.return_value = True
+
+ mock_handler = MagicMock()
+ mock_handler.return_value = CreateTokenResponse(
+ token="user-token",
+ tokenUUID="user-uuid",
+ tokenExpiration=future_timestamp,
+ createdDate=int(datetime.now().timestamp()),
+ username="api-user",
+ name="My Token",
+ groups=["api-group"],
+ isSystemToken=False,
+ )
+ mock_handler_class.return_value = mock_handler
+
+ request_data = CreateTokenUserRequest(name="My Token", tokenExpiration=future_timestamp)
+
+ result = await create_own_token(mock_api_user_request, request_data)
+
+ assert result.token == "user-token"
+ assert result.username == "api-user"
+ mock_handler.assert_called_once_with(request_data, "api-user", ["api-group"], False, True)
+
+
+@pytest.mark.asyncio
+async def test_create_own_token_endpoint_unauthorized(token_table, future_timestamp):
+ """Test create_own_token endpoint without AWS event context."""
+ from api_tokens.lambda_functions import create_own_token
+ from fastapi import HTTPException
+
+ mock_request = MagicMock()
+ mock_request.scope = {} # No aws.event
+
+ request_data = CreateTokenUserRequest(name="My Token", tokenExpiration=future_timestamp)
+
+ with pytest.raises(HTTPException) as excinfo:
+ await create_own_token(mock_request, request_data)
+ assert excinfo.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_list_tokens_endpoint_success(token_table, mock_request, future_timestamp):
+ """Test list_tokens endpoint."""
+ from api_tokens.lambda_functions import list_tokens
+
+ # Add test token
+ token_table.put_item(
+ Item={
+ "token": "hash1",
+ "tokenUUID": "uuid1",
+ "username": "test-user",
+ "tokenExpiration": future_timestamp,
+ "createdDate": int(datetime.now().timestamp()),
+ "createdBy": "test-user",
+ "name": "Test Token",
+ "groups": [],
+ "isSystemToken": False,
+ }
+ )
+
+ with patch("api_tokens.lambda_functions.token_table", token_table):
+ with patch("api_tokens.lambda_functions.get_user_context") as mock_get_user:
+ mock_get_user.return_value = ("test-user", False, ["user-group"])
+
+ result = await list_tokens(mock_request)
+
+ assert len(result.tokens) == 1
+ assert result.tokens[0].username == "test-user"
+
+
+@pytest.mark.asyncio
+async def test_list_tokens_endpoint_unauthorized(token_table):
+ """Test list_tokens endpoint without AWS event context."""
+ from api_tokens.lambda_functions import list_tokens
+ from fastapi import HTTPException
+
+ mock_request = MagicMock()
+ mock_request.scope = {} # No aws.event
+
+ with pytest.raises(HTTPException) as excinfo:
+ await list_tokens(mock_request)
+ assert excinfo.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_get_token_endpoint_success(token_table, mock_request, future_timestamp):
+ """Test get_token endpoint."""
+ from api_tokens.lambda_functions import get_token
+
+ # Add test token
+ token_table.put_item(
+ Item={
+ "token": "hash1",
+ "tokenUUID": "uuid1",
+ "username": "test-user",
+ "tokenExpiration": future_timestamp,
+ "createdDate": int(datetime.now().timestamp()),
+ "createdBy": "test-user",
+ "name": "Test Token",
+ "groups": [],
+ "isSystemToken": False,
+ }
+ )
+
+ with patch("api_tokens.lambda_functions.token_table", token_table):
+ with patch("api_tokens.lambda_functions.get_user_context") as mock_get_user:
+ mock_get_user.return_value = ("test-user", False, ["user-group"])
+
+ result = await get_token("uuid1", mock_request)
+
+ assert result.tokenUUID == "uuid1"
+ assert result.username == "test-user"
+
+
+@pytest.mark.asyncio
+async def test_get_token_endpoint_unauthorized(token_table):
+ """Test get_token endpoint without AWS event context."""
+ from api_tokens.lambda_functions import get_token
+ from fastapi import HTTPException
+
+ mock_request = MagicMock()
+ mock_request.scope = {} # No aws.event
+
+ with pytest.raises(HTTPException) as excinfo:
+ await get_token("uuid1", mock_request)
+ assert excinfo.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_delete_token_endpoint_success(token_table, mock_request, future_timestamp):
+ """Test delete_token endpoint."""
+ from api_tokens.lambda_functions import delete_token
+
+ # Add test token
+ token_table.put_item(
+ Item={
+ "token": "hash1",
+ "tokenUUID": "uuid1",
+ "username": "test-user",
+ "tokenExpiration": future_timestamp,
+ "createdDate": int(datetime.now().timestamp()),
+ "createdBy": "test-user",
+ "name": "Test Token",
+ "groups": [],
+ "isSystemToken": False,
+ }
+ )
+
+ with patch("api_tokens.lambda_functions.token_table", token_table):
+ with patch("api_tokens.lambda_functions.get_user_context") as mock_get_user:
+ mock_get_user.return_value = ("test-user", False, ["user-group"])
+
+ result = await delete_token("uuid1", mock_request)
+
+ assert result.message == "Token deleted successfully"
+ assert result.tokenUUID == "uuid1"
+
+
+@pytest.mark.asyncio
+async def test_delete_token_endpoint_unauthorized(token_table):
+ """Test delete_token endpoint without AWS event context."""
+ from api_tokens.lambda_functions import delete_token
+ from fastapi import HTTPException
+
+ mock_request = MagicMock()
+ mock_request.scope = {} # No aws.event
+
+ with pytest.raises(HTTPException) as excinfo:
+ await delete_token("uuid1", mock_request)
+ assert excinfo.value.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_exception_handlers():
+ """Test FastAPI exception handlers."""
+ from api_tokens.lambda_functions import (
+ token_not_found_handler,
+ user_error_handler,
+ )
+
+ mock_request = MagicMock()
+
+ # Test TokenNotFoundError handler
+ exc = TokenNotFoundError("Token not found")
+ response = await token_not_found_handler(mock_request, exc)
+ assert response.status_code == 404
+ assert "Token not found" in response.body.decode()
+
+ # Test TokenAlreadyExistsError handler
+ exc = TokenAlreadyExistsError("Token exists")
+ response = await user_error_handler(mock_request, exc)
+ assert response.status_code == 400
+ assert "Token exists" in response.body.decode()
+
+ # Test ValueError handler
+ exc = ValueError("Invalid value")
+ response = await user_error_handler(mock_request, exc)
+ assert response.status_code == 400
+ assert "Invalid value" in response.body.decode()
+
+
+@pytest.mark.asyncio
+async def test_validation_exception_handler():
+ """Test RequestValidationError handler."""
+ import json
+
+ from api_tokens.lambda_functions import app
+ from fastapi.exceptions import RequestValidationError
+
+ mock_request = MagicMock()
+
+ # Get the validation handler from the app's exception handlers
+ # The handler is registered by create_fastapi_app()
+ validation_handler = None
+ for exc_class, handler in app.exception_handlers.items():
+ if exc_class == RequestValidationError:
+ validation_handler = handler
+ break
+
+ assert validation_handler is not None, "RequestValidationError handler not found"
+
+ # Create a validation error
+ exc = RequestValidationError(
+ [
+ {
+ "loc": ("body", "name"),
+ "msg": "field required",
+ "type": "value_error.missing",
+ }
+ ]
+ )
+
+ response = await validation_handler(mock_request, exc)
+ assert response.status_code == 422
+ body = json.loads(response.body)
+ assert "detail" in body
+ assert body["type"] == "RequestValidationError"
+
+
+def test_mangum_handler_initialization():
+ """Test that Mangum handlers are properly initialized."""
+ from api_tokens.lambda_functions import docs, handler
+
+ assert handler is not None
+ assert docs is not None
+
+
+def test_fastapi_app_configuration():
+ """Test FastAPI app configuration."""
+ from api_tokens.lambda_functions import app
+
+ # Test app configuration
+ assert app.docs_url == "/docs"
+ assert app.openapi_url == "/openapi.json"
+ # Check middleware is configured
+ assert len(app.user_middleware) > 0
+
+
+def test_dynamodb_initialization():
+ """Test DynamoDB resource and table initialization."""
+ from api_tokens.lambda_functions import dynamodb, token_table
+
+ assert dynamodb is not None
+ assert token_table is not None
+ # Table name can vary based on environment (test-token-table or token-table)
+ assert "token" in token_table.name.lower()
+ assert "table" in token_table.name.lower()
+
+
+# =====================
+# Test handler.py - Exception handling paths
+# =====================
+
+
+def test_get_token_handler_scan_exception(token_table, future_timestamp):
+ """Test GetTokenHandler handles scan exceptions gracefully."""
+ # Add a legacy token (no tokenUUID)
+ token_table.put_item(
+ Item={
+ "token": "legacy-hash",
+ "username": "user1",
+ "tokenExpiration": future_timestamp,
+ "createdDate": int(datetime.now().timestamp()),
+ "createdBy": "user1",
+ "groups": [],
+ "isSystemToken": False,
+ }
+ )
+
+ handler = GetTokenHandler(token_table)
+
+ # Mock scan to raise exception, should fall back to get_item
+ with patch.object(token_table, "scan", side_effect=Exception("Scan failed")):
+ result = handler("legacy-hash", "user1", is_admin=False)
+ assert result.isLegacy is True
+ assert result.name == "legacy-hash"
+
+
+def test_get_token_handler_both_lookups_fail(token_table):
+ """Test GetTokenHandler when both scan and get_item fail."""
+ handler = GetTokenHandler(token_table)
+
+ # Mock both scan and get_item to raise exceptions
+ with patch.object(token_table, "scan", side_effect=Exception("Scan failed")):
+ with patch.object(token_table, "get_item", side_effect=Exception("Get failed")):
+ with pytest.raises(TokenNotFoundError):
+ handler("non-existent", "user1", is_admin=False)
+
+
+def test_delete_token_handler_scan_exception(token_table, future_timestamp):
+ """Test DeleteTokenHandler handles scan exceptions gracefully."""
+ # Add a legacy token (no tokenUUID)
+ token_table.put_item(
+ Item={
+ "token": "legacy-hash",
+ "username": "user1",
+ "tokenExpiration": future_timestamp,
+ "createdDate": int(datetime.now().timestamp()),
+ "createdBy": "user1",
+ "groups": [],
+ "isSystemToken": False,
+ }
+ )
+
+ handler = DeleteTokenHandler(token_table)
+
+ # Mock scan to raise exception, should fall back to get_item
+ with patch.object(token_table, "scan", side_effect=Exception("Scan failed")):
+ # Admin should be able to delete legacy token even when scan fails
+ result = handler("legacy-hash", "admin", is_admin=True)
+ assert "deleted successfully" in result.message
+
+
+def test_delete_token_handler_both_lookups_fail(token_table):
+ """Test DeleteTokenHandler when both scan and get_item fail."""
+ handler = DeleteTokenHandler(token_table)
+
+ # Mock both scan and get_item to raise exceptions
+ with patch.object(token_table, "scan", side_effect=Exception("Scan failed")):
+ with patch.object(token_table, "get_item", side_effect=Exception("Get failed")):
+ with pytest.raises(TokenNotFoundError):
+ handler("non-existent", "user1", is_admin=False)
diff --git a/test/lambda/test_audit_logging.py b/test/lambda/test_audit_logging.py
new file mode 100644
index 000000000..a0e0fd8df
--- /dev/null
+++ b/test/lambda/test_audit_logging.py
@@ -0,0 +1,735 @@
+# 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.
+
+"""Unit tests for CreateModel audit logging."""
+import json
+import logging
+import os
+from unittest.mock import MagicMock, patch
+
+# Set mock AWS credentials BEFORE any imports that use them
+os.environ["AWS_ACCESS_KEY_ID"] = "testing"
+os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
+os.environ["AWS_SECURITY_TOKEN"] = "testing"
+os.environ["AWS_SESSION_TOKEN"] = "testing"
+os.environ["AWS_DEFAULT_REGION"] = "us-east-1"
+os.environ["AWS_REGION"] = "us-east-1"
+os.environ["MODEL_TABLE_NAME"] = "model-table"
+os.environ["LISA_RAG_VECTOR_STORE_TABLE"] = "vector-store-table"
+os.environ["GUARDRAILS_TABLE_NAME"] = "guardrails-table"
+os.environ["LISA_RAG_VECTOR_STORE_TABLE_PS_NAME"] = "/test/ragVectorStoreTableName"
+os.environ["LISA_RAG_COLLECTIONS_TABLE_PS_NAME"] = "/test/ragCollectionsTableName"
+os.environ["CREATE_SFN_ARN"] = "arn:aws:states:us-east-1:123456789012:stateMachine:CreateModelStateMachine"
+os.environ["DELETE_SFN_ARN"] = "arn:aws:states:us-east-1:123456789012:stateMachine:DeleteModelStateMachine"
+os.environ["UPDATE_SFN_ARN"] = "arn:aws:states:us-east-1:123456789012:stateMachine:UpdateModelStateMachine"
+os.environ["ADMIN_GROUP"] = "admin-group"
+
+import pytest
+from fastapi import Request
+from models.domain_objects import (
+ AutoScalingConfig,
+ ContainerConfig,
+ ContainerConfigImage,
+ ContainerHealthCheckConfig,
+ CreateModelRequest,
+ MetricConfig,
+ ModelType,
+)
+from models.lambda_functions import create_model
+
+
+class TestCreateModelAuditLogging:
+ """Test audit logging for CreateModel API endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_create_model_logs_all_required_fields(self, caplog):
+ """Test that CreateModel logs contain all required security audit fields."""
+ # Setup mock request with API Gateway event context
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ },
+ "identity": {
+ "sourceIp": "192.168.1.100",
+ },
+ },
+ }
+ }
+
+ # Create request with container config (non-LISA hosted model)
+ create_request = CreateModelRequest(
+ modelId="test-model-123",
+ modelName="test-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ modelUrl="https://example.com/model-endpoint",
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Verify log was created
+ assert len(caplog.records) > 0
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None, "CREATE_MODEL_REQUEST log entry not found"
+
+ # Verify user context fields
+ assert hasattr(log_record, "user")
+ assert log_record.user["username"] == "test-admin"
+ assert log_record.user["auth_type"] == "JWT"
+ assert log_record.user["source_ip"] == "192.168.1.100"
+
+ # Verify model configuration fields
+ assert hasattr(log_record, "model")
+ assert log_record.model["model_id"] == "test-model-123"
+ assert log_record.model["model_name"] == "test-model-name"
+
+ # Verify container field is None for non-LISA hosted model
+ assert log_record.container is None
+
+ @pytest.mark.asyncio
+ async def test_create_model_logs_container_details_for_lisa_hosted(self, caplog):
+ """Test that CreateModel logs container details for LISA-hosted models."""
+ from models.domain_objects import InferenceContainer, LoadBalancerConfig, LoadBalancerHealthCheckConfig
+
+ # Setup mock request
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ },
+ "identity": {
+ "sourceIp": "192.168.1.100",
+ },
+ },
+ }
+ }
+
+ # Create LISA-hosted model request with all required fields
+ create_request = CreateModelRequest(
+ modelId="lisa-hosted-model",
+ modelName="lisa-hosted-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ instanceType="t2.micro",
+ autoScalingConfig=AutoScalingConfig(
+ minCapacity=1,
+ maxCapacity=3,
+ desiredCapacity=2,
+ metricConfig=MetricConfig(
+ estimatedInstanceWarmup=60,
+ targetValue=60,
+ albMetricName="RequestCountPerTarget",
+ duration=60,
+ ),
+ cooldown=60,
+ defaultInstanceWarmup=60,
+ ),
+ containerConfig=ContainerConfig(
+ image=ContainerConfigImage(
+ baseImage="123456789012.dkr.ecr.us-east-1.amazonaws.com/my-model:latest",
+ type="ecr",
+ ),
+ sharedMemorySize=1024,
+ healthCheckConfig=ContainerHealthCheckConfig(
+ command=["CMD-SHELL", "curl -f http://localhost:8080/health"],
+ interval=30,
+ startPeriod=60,
+ timeout=10,
+ retries=3,
+ ),
+ ),
+ loadBalancerConfig=LoadBalancerConfig(
+ healthCheckConfig=LoadBalancerHealthCheckConfig(
+ healthyThresholdCount=2,
+ unhealthyThresholdCount=2,
+ path="/health",
+ port="8080",
+ protocol="HTTP",
+ timeout=5,
+ interval=10,
+ )
+ ),
+ inferenceContainer=InferenceContainer.VLLM,
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None
+
+ # Verify container image details are logged
+ assert hasattr(log_record, "container")
+ assert log_record.container["base_image"] == "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-model:latest"
+ assert log_record.container["registry_domain"] == "123456789012.dkr.ecr.us-east-1.amazonaws.com"
+ assert log_record.container["image_type"] == "ecr"
+ assert log_record.container["healthcheck_command"] == ["CMD-SHELL", "curl -f http://localhost:8080/health"]
+
+ # Verify model config includes instance type and autoscaling
+ assert log_record.model["instance_type"] == "t2.micro"
+ assert log_record.model["auto_scaling"]["min_capacity"] == 1
+ assert log_record.model["auto_scaling"]["max_capacity"] == 3
+
+ @pytest.mark.asyncio
+ async def test_create_model_logs_without_container_config(self, caplog):
+ """Test that CreateModel logs work when containerConfig is not provided."""
+ # Setup mock request
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "API_KEY",
+ },
+ "identity": {
+ "sourceIp": "10.0.0.50",
+ },
+ },
+ }
+ }
+
+ # Create request WITHOUT container config
+ create_request = CreateModelRequest(
+ modelId="simple-model",
+ modelName="simple-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=False,
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None
+
+ # Verify container field is None when no containerConfig
+ assert log_record.container is None
+
+ # Verify other fields still present
+ assert log_record.user["username"] == "test-admin"
+ assert log_record.model["model_id"] == "simple-model"
+
+ @pytest.mark.asyncio
+ async def test_create_model_does_not_log_sensitive_data(self, caplog):
+ """Test that sensitive data like secrets and tokens are not logged."""
+ # Setup mock request
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ "token": "super-secret-jwt-token-12345", # Should NOT be logged
+ },
+ "identity": {
+ "sourceIp": "192.168.1.100",
+ "accessKey": "AKIAIOSFODNN7EXAMPLE", # Should NOT be logged
+ },
+ },
+ }
+ }
+
+ # Create request
+ create_request = CreateModelRequest(
+ modelId="test-model",
+ modelName="test-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None
+
+ # Verify sensitive data is NOT in the logged user context
+ assert "token" not in log_record.user
+ assert "accessKey" not in log_record.user
+
+ # Get all log record attributes as strings to check they're not accidentally logged
+ record_str = str(vars(log_record))
+ assert "super-secret-jwt-token-12345" not in record_str
+ assert "AKIAIOSFODNN7EXAMPLE" not in record_str
+
+ # Verify non-sensitive data IS in logs
+ assert log_record.user["username"] == "test-admin"
+ assert log_record.model["model_id"] == "test-model"
+
+ @pytest.mark.asyncio
+ async def test_create_model_logs_for_successful_request(self, caplog):
+ """Test that logs are written for successful CreateModel requests."""
+ # Setup mock request
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ },
+ "identity": {
+ "sourceIp": "192.168.1.100",
+ },
+ },
+ }
+ }
+
+ create_request = CreateModelRequest(
+ modelId="success-model",
+ modelName="success-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks for successful creation
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Verify both request and success logs exist
+ event_types = [getattr(record, "event_type", None) for record in caplog.records]
+ assert "CREATE_MODEL_REQUEST" in event_types
+ assert "CREATE_MODEL_SUCCESS" in event_types
+
+ @pytest.mark.asyncio
+ async def test_create_model_logs_for_failed_request(self, caplog):
+ """Test that logs are written for failed CreateModel requests."""
+ from models.exception import ModelAlreadyExistsError
+
+ # Setup mock request
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ },
+ "identity": {
+ "sourceIp": "192.168.1.100",
+ },
+ },
+ }
+ }
+
+ create_request = CreateModelRequest(
+ modelId="existing-model",
+ modelName="existing-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ )
+
+ # Mock the handler to raise ModelAlreadyExistsError
+ with patch("models.lambda_functions.CreateModelHandler") as mock_handler_class, patch(
+ "utilities.auth.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(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.side_effect = ModelAlreadyExistsError("Model 'existing-model' already exists")
+
+ # Call the endpoint and expect HTTPException
+ from fastapi import HTTPException
+
+ with pytest.raises(HTTPException) as exc_info:
+ await create_model(create_request, mock_request)
+
+ assert exc_info.value.status_code == 409
+
+ # Verify request and failure logs exist
+ event_types = [getattr(record, "event_type", None) for record in caplog.records]
+ assert "CREATE_MODEL_REQUEST" in event_types
+ assert "CREATE_MODEL_FAILURE" in event_types
+
+ # Find the failure log and verify it contains error details
+ failure_log = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_FAILURE":
+ failure_log = record
+ break
+
+ assert failure_log is not None
+ assert failure_log.model_id == "existing-model"
+ assert failure_log.username == "test-admin"
+ assert "already exists" in failure_log.error
+
+ @pytest.mark.asyncio
+ async def test_create_model_extracts_real_ip_from_api_gateway_context(self, caplog):
+ """Test that real client IP is extracted from API Gateway context, not headers."""
+ # Setup mock request with API Gateway context
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ },
+ "identity": {
+ "sourceIp": "203.0.113.42", # Real IP from API Gateway
+ },
+ },
+ "headers": {
+ "x-forwarded-for": "192.0.2.1, 198.51.100.1", # User-provided, should be ignored
+ },
+ }
+ }
+
+ create_request = CreateModelRequest(
+ modelId="test-model",
+ modelName="test-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None
+
+ # Verify the real IP from API Gateway context is used, not x-forwarded-for
+ assert log_record.user["source_ip"] == "203.0.113.42"
+
+ # Verify the user-provided x-forwarded-for is NOT in the log
+ log_message = log_record.getMessage()
+ assert "192.0.2.1" not in log_message
+ assert "198.51.100.1" not in log_message
+
+ @pytest.mark.asyncio
+ async def test_create_model_handles_missing_event_context(self, caplog):
+ """Test that logging handles missing API Gateway event context gracefully."""
+ # Setup mock request WITHOUT aws.event
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {} # No aws.event
+
+ create_request = CreateModelRequest(
+ modelId="test-model",
+ modelName="test-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ )
+
+ # 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):
+
+ # Setup mocks - simulate admin with no event context
+ mock_is_admin.return_value = True
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None
+
+ # Verify default values are used when context is missing
+ assert log_record.user["username"] == "unknown"
+ assert log_record.user["auth_type"] == "unknown"
+ assert log_record.user["source_ip"] == "unknown"
+
+ @pytest.mark.asyncio
+ async def test_create_model_extracts_registry_domain_from_various_formats(self, caplog):
+ """Test that registry domain is correctly extracted from different image URL formats."""
+ from models.domain_objects import InferenceContainer, LoadBalancerConfig, LoadBalancerHealthCheckConfig
+
+ test_cases = [
+ # (image_url, expected_domain)
+ (
+ "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-model:latest",
+ "123456789012.dkr.ecr.us-east-1.amazonaws.com",
+ ),
+ ("public.ecr.aws/my-repo/model:v1", "public.ecr.aws"),
+ ("docker.io/library/nginx:latest", "docker.io"),
+ ("https://registry.example.com/model:tag", "registry.example.com"),
+ ("ghcr.io/org/repo:sha-abc123", "ghcr.io"),
+ ("simple-name", "unknown"), # No slash, returns "unknown"
+ ]
+
+ for image_url, expected_domain in test_cases:
+ caplog.clear()
+
+ # Setup mock request
+ mock_request = MagicMock(spec=Request)
+ mock_request.scope = {
+ "aws.event": {
+ "requestContext": {
+ "authorizer": {
+ "username": "test-admin",
+ "groups": json.dumps(["admin-group"]),
+ "authType": "JWT",
+ },
+ "identity": {
+ "sourceIp": "192.168.1.100",
+ },
+ },
+ }
+ }
+
+ # Create LISA-hosted model with all required fields
+ create_request = CreateModelRequest(
+ modelId="test-model",
+ modelName="test-model-name",
+ modelType=ModelType.TEXTGEN,
+ streaming=True,
+ instanceType="t2.micro",
+ autoScalingConfig=AutoScalingConfig(
+ minCapacity=1,
+ maxCapacity=3,
+ desiredCapacity=2,
+ metricConfig=MetricConfig(
+ estimatedInstanceWarmup=60,
+ targetValue=60,
+ albMetricName="RequestCountPerTarget",
+ duration=60,
+ ),
+ cooldown=60,
+ defaultInstanceWarmup=60,
+ ),
+ containerConfig=ContainerConfig(
+ image=ContainerConfigImage(
+ baseImage=image_url,
+ type="custom",
+ ),
+ sharedMemorySize=1024,
+ healthCheckConfig=ContainerHealthCheckConfig(
+ command=["CMD", "test"],
+ interval=30,
+ startPeriod=60,
+ timeout=10,
+ retries=3,
+ ),
+ ),
+ loadBalancerConfig=LoadBalancerConfig(
+ healthCheckConfig=LoadBalancerHealthCheckConfig(
+ healthyThresholdCount=2,
+ unhealthyThresholdCount=2,
+ path="/health",
+ port="8080",
+ protocol="HTTP",
+ timeout=5,
+ interval=10,
+ )
+ ),
+ inferenceContainer=InferenceContainer.VLLM,
+ )
+
+ # 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, patch("utilities.auth.get_groups") as mock_get_groups, patch(
+ "utilities.auth.get_username"
+ ) as mock_get_username, caplog.at_level(
+ logging.INFO
+ ):
+
+ # Setup mocks
+ mock_is_admin.return_value = True
+ mock_get_groups.return_value = ["admin-group"]
+ mock_get_username.return_value = "test-admin"
+
+ mock_handler = MagicMock()
+ mock_handler_class.return_value = mock_handler
+ mock_handler.return_value = MagicMock()
+
+ # Call the endpoint
+ await create_model(create_request, mock_request)
+
+ # Find the CreateModel request log entry
+ log_record = None
+ for record in caplog.records:
+ if hasattr(record, "event_type") and record.event_type == "CREATE_MODEL_REQUEST":
+ log_record = record
+ break
+
+ assert log_record is not None, f"Log not found for image: {image_url}"
+ actual_domain = log_record.container["registry_domain"]
+ assert actual_domain == expected_domain, (
+ f"Expected domain '{expected_domain}' for image '{image_url}', " f"got '{actual_domain}'"
+ )
diff --git a/test/lambda/test_auth.py b/test/lambda/test_auth.py
index 42fb74c0a..47a87cb28 100644
--- a/test/lambda/test_auth.py
+++ b/test/lambda/test_auth.py
@@ -203,10 +203,12 @@ def test_is_admin_logging(sample_event_with_username):
"""Test is_admin logs the groups and admin group."""
if "utilities.auth" in sys.modules:
del sys.modules["utilities.auth"]
+ if "utilities.auth_provider" in sys.modules:
+ del sys.modules["utilities.auth_provider"]
with patch.dict(os.environ, {"ADMIN_GROUP": "admin"}):
with patch("utilities.auth.get_groups", return_value=["group1", "admin"]):
- with patch("utilities.auth.logger") as mock_logger:
+ with patch("utilities.auth_provider.logger") as mock_logger:
from utilities.auth import is_admin
is_admin(sample_event_with_username)
diff --git a/test/lambda/test_authorizer_lambda.py b/test/lambda/test_authorizer_lambda.py
index 4fe3874e9..3accdf906 100644
--- a/test/lambda/test_authorizer_lambda.py
+++ b/test/lambda/test_authorizer_lambda.py
@@ -96,7 +96,6 @@ def wrapper(*args, **kwargs):
generate_policy,
get_management_tokens,
id_token_is_valid,
- is_admin,
is_valid_api_token,
lambda_handler,
)
@@ -197,21 +196,6 @@ def test_find_jwt_username(sample_jwt_data):
assert "No username found in JWT" in str(excinfo.value)
-def test_is_admin(sample_jwt_data):
- """Test the is_admin function."""
- # Test when user is admin
- sample_jwt_data["groups"] = ["test-group", "admin-group"]
- assert is_admin(sample_jwt_data, "admin-group", "groups") is True
-
- # Test when user is not admin
- sample_jwt_data["groups"] = ["test-group"]
- assert is_admin(sample_jwt_data, "admin-group", "groups") is False
-
- # Test when groups property doesn't exist
- del sample_jwt_data["groups"]
- assert is_admin(sample_jwt_data, "admin-group", "groups") is False
-
-
def test_is_valid_api_token(token_table):
"""Test the is_valid_api_token function."""
import hashlib
@@ -423,11 +407,11 @@ def test_lambda_handler_with_api_token(
@patch("authorizer.lambda_functions.get_management_tokens")
@patch("authorizer.lambda_functions.is_valid_api_token")
@patch("authorizer.lambda_functions.id_token_is_valid")
-@patch("authorizer.lambda_functions.is_admin")
+@patch("authorizer.lambda_functions.get_authorization_provider")
@patch("authorizer.lambda_functions.find_jwt_username")
def test_lambda_handler_with_jwt(
mock_find_jwt_username,
- mock_is_admin,
+ mock_get_auth_provider,
mock_id_token_is_valid,
mock_is_valid_api_token,
mock_get_management_tokens,
@@ -444,9 +428,14 @@ def test_lambda_handler_with_jwt(
"groups": ["test-group"],
}
mock_id_token_is_valid.return_value = jwt_data
- mock_is_admin.return_value = False
mock_find_jwt_username.return_value = "test-user"
+ # Mock auth provider
+ mock_auth_provider = MagicMock()
+ mock_auth_provider.check_admin_access.return_value = False
+ mock_auth_provider.check_app_access.return_value = True
+ mock_get_auth_provider.return_value = mock_auth_provider
+
# Test with JWT token
response = lambda_handler(sample_event, lambda_context)
@@ -459,11 +448,11 @@ def test_lambda_handler_with_jwt(
@patch("authorizer.lambda_functions.get_management_tokens")
@patch("authorizer.lambda_functions.is_valid_api_token")
@patch("authorizer.lambda_functions.id_token_is_valid")
-@patch("authorizer.lambda_functions.is_admin")
+@patch("authorizer.lambda_functions.get_authorization_provider")
@patch("authorizer.lambda_functions.find_jwt_username")
def test_lambda_handler_admin_models_access(
mock_find_jwt_username,
- mock_is_admin,
+ mock_get_auth_provider,
mock_id_token_is_valid,
mock_is_valid_api_token,
mock_get_management_tokens,
@@ -480,9 +469,14 @@ def test_lambda_handler_admin_models_access(
"groups": ["admin-group"],
}
mock_id_token_is_valid.return_value = jwt_data
- mock_is_admin.return_value = True
mock_find_jwt_username.return_value = "test-user"
+ # Mock auth provider with admin access
+ mock_auth_provider = MagicMock()
+ mock_auth_provider.check_admin_access.return_value = True
+ mock_auth_provider.check_app_access.return_value = True
+ mock_get_auth_provider.return_value = mock_auth_provider
+
# Set up test event for models endpoint
sample_event["resource"] = "/models/{modelId}"
sample_event["path"] = "/models/specific-model"
@@ -497,11 +491,11 @@ def test_lambda_handler_admin_models_access(
@patch("authorizer.lambda_functions.get_management_tokens")
@patch("authorizer.lambda_functions.is_valid_api_token")
@patch("authorizer.lambda_functions.id_token_is_valid")
-@patch("authorizer.lambda_functions.is_admin")
+@patch("authorizer.lambda_functions.get_authorization_provider")
@patch("authorizer.lambda_functions.find_jwt_username")
def test_lambda_handler_non_admin_models_access(
mock_find_jwt_username,
- mock_is_admin,
+ mock_get_auth_provider,
mock_id_token_is_valid,
mock_is_valid_api_token,
mock_get_management_tokens,
@@ -518,18 +512,24 @@ def test_lambda_handler_non_admin_models_access(
"groups": ["test-group"],
}
mock_id_token_is_valid.return_value = jwt_data
- mock_is_admin.return_value = False
mock_find_jwt_username.return_value = "test-user"
+ # Mock auth provider without admin access but with app access
+ mock_auth_provider = MagicMock()
+ mock_auth_provider.check_admin_access.return_value = False
+ mock_auth_provider.check_app_access.return_value = True
+ mock_get_auth_provider.return_value = mock_auth_provider
+
# Set up test event for models endpoint with specific model
sample_event["resource"] = "/models/{modelId}"
sample_event["path"] = "/models/specific-model"
# Test with non-admin accessing specific model endpoint
+ # The new auth pattern allows access for users with app access
response = lambda_handler(sample_event, lambda_context)
- # Verify response - should be denied
- assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny"
+ # Verify response - should be allowed for authenticated users with app access
+ assert response["policyDocument"]["Statement"][0]["Effect"] == "Allow"
# Set up test event for models list endpoint
sample_event["resource"] = "/models"
@@ -545,11 +545,11 @@ def test_lambda_handler_non_admin_models_access(
@patch("authorizer.lambda_functions.get_management_tokens")
@patch("authorizer.lambda_functions.is_valid_api_token")
@patch("authorizer.lambda_functions.id_token_is_valid")
-@patch("authorizer.lambda_functions.is_admin")
+@patch("authorizer.lambda_functions.get_authorization_provider")
@patch("authorizer.lambda_functions.find_jwt_username")
def test_lambda_handler_non_admin_configuration_update(
mock_find_jwt_username,
- mock_is_admin,
+ mock_get_auth_provider,
mock_id_token_is_valid,
mock_is_valid_api_token,
mock_get_management_tokens,
@@ -566,19 +566,25 @@ def test_lambda_handler_non_admin_configuration_update(
"groups": ["test-group"],
}
mock_id_token_is_valid.return_value = jwt_data
- mock_is_admin.return_value = False
mock_find_jwt_username.return_value = "test-user"
+ # Mock auth provider without admin access but with app access
+ mock_auth_provider = MagicMock()
+ mock_auth_provider.check_admin_access.return_value = False
+ mock_auth_provider.check_app_access.return_value = True
+ mock_get_auth_provider.return_value = mock_auth_provider
+
# Set up test event for configuration update
sample_event["resource"] = "/configuration"
sample_event["path"] = "/configuration"
sample_event["httpMethod"] = "PUT"
# Test with non-admin updating configuration
+ # The new auth pattern allows access for users with app access
response = lambda_handler(sample_event, lambda_context)
- # Verify response - should be denied
- assert response["policyDocument"]["Statement"][0]["Effect"] == "Deny"
+ # Verify response - should be allowed for authenticated users with app access
+ assert response["policyDocument"]["Statement"][0]["Effect"] == "Allow"
@patch("authorizer.lambda_functions.get_management_tokens")
diff --git a/test/lambda/test_aws_helpers.py b/test/lambda/test_aws_helpers.py
new file mode 100644
index 000000000..eb454bb19
--- /dev/null
+++ b/test/lambda/test_aws_helpers.py
@@ -0,0 +1,235 @@
+# 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.
+
+"""Unit tests for aws_helpers module."""
+
+import os
+from unittest.mock import MagicMock, patch
+
+# Set required environment variables before importing aws_helpers
+os.environ.setdefault("AWS_REGION", "us-east-1")
+
+from utilities.aws_helpers import (
+ get_account_and_partition,
+ get_cert_path,
+ get_lambda_role_name,
+ get_rest_api_container_endpoint,
+)
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:acm:us-east-1:123456789012:certificate/abc-123"}, clear=False)
+def test_returns_true_for_acm_certificate():
+ """Test get_cert_path returns True for ACM certificates."""
+ mock_iam = MagicMock()
+
+ # Clear cache
+ get_cert_path.cache_clear()
+
+ result = get_cert_path(mock_iam)
+
+ assert result is True
+ mock_iam.get_server_certificate.assert_not_called()
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/test-cert"}, clear=False)
+def test_retrieves_iam_certificate():
+ """Test get_cert_path retrieves IAM certificate."""
+ mock_iam = MagicMock()
+ mock_iam.get_server_certificate.return_value = {
+ "ServerCertificate": {"CertificateBody": "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}
+ }
+
+ # Clear cache
+ get_cert_path.cache_clear()
+
+ result = get_cert_path(mock_iam)
+
+ assert isinstance(result, str)
+ assert result != ""
+ mock_iam.get_server_certificate.assert_called_once_with(ServerCertificateName="test-cert")
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/my-cert"}, clear=False)
+def test_falls_back_on_iam_error():
+ """Test get_cert_path falls back to True when IAM call fails."""
+ mock_iam = MagicMock()
+ mock_iam.get_server_certificate.side_effect = Exception("IAM error")
+
+ # Clear cache
+ get_cert_path.cache_clear()
+
+ result = get_cert_path(mock_iam)
+
+ assert result is True
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/test"}, clear=False)
+def test_caches_result():
+ """Test get_cert_path caches the result."""
+ mock_iam = MagicMock()
+ mock_iam.get_server_certificate.return_value = {
+ "ServerCertificate": {"CertificateBody": "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}
+ }
+
+ # Clear cache
+ get_cert_path.cache_clear()
+
+ # First call
+ result1 = get_cert_path(mock_iam)
+ # Second call
+ result2 = get_cert_path(mock_iam)
+
+ assert result1 == result2
+ # Should only call IAM once due to caching
+ assert mock_iam.get_server_certificate.call_count == 1
+
+
+class TestGetRestApiContainerEndpoint:
+ """Test get_rest_api_container_endpoint function."""
+
+ @patch("utilities.aws_helpers.ssm_client")
+ @patch.dict(os.environ, {"LISA_API_URL_PS_NAME": "/lisa/api/url", "REST_API_VERSION": "v2"}, clear=False)
+ def test_retrieves_endpoint_from_ssm(self, mock_ssm):
+ """Test get_rest_api_container_endpoint retrieves endpoint from SSM."""
+ mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "https://api.example.com"}}
+
+ # Clear cache
+ get_rest_api_container_endpoint.cache_clear()
+
+ result = get_rest_api_container_endpoint()
+
+ assert result == "https://api.example.com/v2/serve"
+ mock_ssm.get_parameter.assert_called_once_with(Name="/lisa/api/url")
+
+ @patch("utilities.aws_helpers.ssm_client")
+ @patch.dict(os.environ, {"LISA_API_URL_PS_NAME": "/test/url", "REST_API_VERSION": "v1"}, clear=False)
+ def test_constructs_correct_endpoint(self, mock_ssm):
+ """Test get_rest_api_container_endpoint constructs correct endpoint."""
+ mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "https://test.api.com"}}
+
+ # Clear cache
+ get_rest_api_container_endpoint.cache_clear()
+
+ result = get_rest_api_container_endpoint()
+
+ assert result == "https://test.api.com/v1/serve"
+
+ @patch("utilities.aws_helpers.ssm_client")
+ @patch.dict(os.environ, {"LISA_API_URL_PS_NAME": "/api/url", "REST_API_VERSION": "v3"}, clear=False)
+ def test_caches_result(self, mock_ssm):
+ """Test get_rest_api_container_endpoint caches the result."""
+ mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "https://cached.api.com"}}
+
+ # Clear cache
+ get_rest_api_container_endpoint.cache_clear()
+
+ # First call
+ result1 = get_rest_api_container_endpoint()
+ # Second call
+ result2 = get_rest_api_container_endpoint()
+
+ assert result1 == result2
+ # Should only call SSM once due to caching
+ assert mock_ssm.get_parameter.call_count == 1
+
+
+class TestGetLambdaRoleName:
+ """Test get_lambda_role_name function."""
+
+ @patch("utilities.aws_helpers.boto3.client")
+ def test_extracts_role_name_from_arn(self, mock_boto_client):
+ """Test get_lambda_role_name extracts role name from ARN."""
+ mock_sts = MagicMock()
+ mock_sts.get_caller_identity.return_value = {
+ "Arn": "arn:aws:sts::123456789012:assumed-role/MyLambdaRole/lambda-function"
+ }
+ mock_boto_client.return_value = mock_sts
+
+ role_name = get_lambda_role_name()
+
+ assert role_name == "MyLambdaRole"
+
+ @patch("utilities.aws_helpers.boto3.client")
+ def test_handles_different_role_names(self, mock_boto_client):
+ """Test get_lambda_role_name handles different role name formats."""
+ mock_sts = MagicMock()
+ mock_sts.get_caller_identity.return_value = {
+ "Arn": "arn:aws:sts::987654321098:assumed-role/CustomRole-With-Dashes/function-name"
+ }
+ mock_boto_client.return_value = mock_sts
+
+ role_name = get_lambda_role_name()
+
+ assert role_name == "CustomRole-With-Dashes"
+
+
+class TestGetAccountAndPartition:
+ """Test get_account_and_partition function."""
+
+ @patch.dict(os.environ, {"AWS_ACCOUNT_ID": "123456789012", "AWS_PARTITION": "aws"}, clear=True)
+ def test_returns_from_environment_variables(self):
+ """Test get_account_and_partition returns from environment variables."""
+ os.environ["AWS_REGION"] = "us-east-1"
+
+ account, partition = get_account_and_partition()
+
+ assert account == "123456789012"
+ assert partition == "aws"
+
+ @patch.dict(
+ os.environ,
+ {"ECR_REPOSITORY_ARN": "arn:aws-us-gov:ecr:us-gov-west-1:987654321098:repository/my-repo"},
+ clear=True,
+ )
+ def test_extracts_from_ecr_arn(self):
+ """Test get_account_and_partition extracts from ECR ARN."""
+ os.environ["AWS_REGION"] = "us-gov-west-1"
+
+ account, partition = get_account_and_partition()
+
+ assert account == "987654321098"
+ assert partition == "aws-us-gov"
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_returns_defaults_when_missing(self):
+ """Test get_account_and_partition returns defaults when variables missing."""
+ os.environ["AWS_REGION"] = "us-east-1"
+
+ account, partition = get_account_and_partition()
+
+ assert account == ""
+ assert partition == "aws"
+
+ @patch.dict(os.environ, {"AWS_PARTITION": "aws-cn"}, clear=True)
+ def test_uses_partition_from_env(self):
+ """Test get_account_and_partition uses partition from environment."""
+ os.environ["AWS_REGION"] = "cn-north-1"
+
+ account, partition = get_account_and_partition()
+
+ assert partition == "aws-cn"
+
+ @patch.dict(
+ os.environ,
+ {"AWS_ACCOUNT_ID": "111111111111", "ECR_REPOSITORY_ARN": "arn:aws:ecr:us-east-1:222222222222:repository/repo"},
+ clear=True,
+ )
+ def test_prefers_env_over_ecr_arn(self):
+ """Test get_account_and_partition prefers environment variables over ECR ARN."""
+ os.environ["AWS_REGION"] = "us-east-1"
+
+ account, partition = get_account_and_partition()
+
+ # Should use account from AWS_ACCOUNT_ID, not ECR ARN
+ assert account == "111111111111"
diff --git a/test/lambda/test_bedrock_kb_collections.py b/test/lambda/test_bedrock_kb_collections.py
index 85d11b9c8..077cd9ed0 100644
--- a/test/lambda/test_bedrock_kb_collections.py
+++ b/test/lambda/test_bedrock_kb_collections.py
@@ -14,7 +14,7 @@
"""Tests for Bedrock Knowledge Base collection support."""
-from typing import Any, Dict
+from typing import Any
from unittest.mock import create_autospec, MagicMock
import pytest
@@ -50,7 +50,7 @@ def collection_service(mock_vector_store_repo, mock_document_repo):
@pytest.fixture
-def bedrock_kb_repository() -> Dict[str, Any]:
+def bedrock_kb_repository() -> dict[str, Any]:
"""Sample Bedrock Knowledge Base repository configuration."""
return {
"repositoryId": "test-bedrock-kb",
diff --git a/test/lambda/test_common_functions.py b/test/lambda/test_common_functions.py
index 9e89171e6..b092dbd0e 100644
--- a/test/lambda/test_common_functions.py
+++ b/test/lambda/test_common_functions.py
@@ -15,7 +15,6 @@
import json
import os
import sys
-from contextvars import ContextVar
from datetime import datetime
from decimal import Decimal
from types import SimpleNamespace
@@ -35,26 +34,21 @@
os.environ["AWS_REGION"] = "us-east-1"
# Import after environment setup
-from utilities.common_functions import (
- api_wrapper,
- authorization_wrapper,
- DecimalEncoder,
- generate_exception_response,
- generate_html_response,
+# Import from specific modules (refactored structure)
+from utilities.aws_helpers import (
get_account_and_partition,
- get_bearer_token,
get_cert_path,
- get_id_token,
- get_item,
get_lambda_role_name,
- get_principal_id,
- get_property_path,
get_rest_api_container_endpoint,
- get_session_id,
- LambdaContextFilter,
- merge_fields,
)
+# Import logging components from common_functions (still there)
+from utilities.common_functions import LambdaContextFilter
+from utilities.dict_helpers import get_item, get_property_path, merge_fields
+from utilities.event_parser import get_bearer_token, get_id_token, get_principal_id, get_session_id
+from utilities.lambda_decorators import api_wrapper, authorization_wrapper
+from utilities.response_builder import DecimalEncoder, generate_exception_response, generate_html_response
+
# =====================
# Test LambdaContextFilter
# =====================
@@ -62,7 +56,7 @@
def test_lambda_context_filter_with_context():
"""Test LambdaContextFilter with valid context."""
- from utilities.common_functions import ctx_context
+ from utilities.lambda_decorators import ctx_context
# Create a mock context
mock_context = SimpleNamespace(aws_request_id="test-request-id", function_name="test-function")
@@ -81,17 +75,15 @@ def test_lambda_context_filter_with_context():
def test_lambda_context_filter_without_context():
"""Test LambdaContextFilter when context is missing."""
+ from unittest.mock import patch
- try:
- # Create a new context var instance to truly clear it
- import utilities.common_functions
-
- old_ctx = utilities.common_functions.ctx_context
- utilities.common_functions.ctx_context = ContextVar("lamdbacontext")
+ # Create filter and log record
+ filter = LambdaContextFilter()
+ record = MagicMock()
- # Create filter and log record
- filter = LambdaContextFilter()
- record = MagicMock()
+ # Mock the ctx_context module-level import to raise LookupError
+ with patch("utilities.common_functions.ctx_context") as mock_ctx:
+ mock_ctx.get.side_effect = LookupError("No context")
result = filter.filter(record)
@@ -99,15 +91,6 @@ def test_lambda_context_filter_without_context():
assert record.requestid == "RID-MISSING"
assert record.functionname == "FN-MISSING"
- # Restore
- utilities.common_functions.ctx_context = old_ctx
- except LookupError:
- # Context is already clear
- filter = LambdaContextFilter()
- record = MagicMock()
-
- result = filter.filter(record)
-
assert result is True
assert record.requestid == "RID-MISSING"
assert record.functionname == "FN-MISSING"
@@ -217,8 +200,8 @@ def test_generate_exception_response_generic():
response = generate_exception_response(error)
- assert response["statusCode"] == 400
- assert "Bad Request" in response["body"]
+ assert response["statusCode"] == 500
+ assert "An unexpected error occurred" in response["body"]
# =====================
@@ -283,7 +266,7 @@ def test_function(event, context):
return {"result": "success"}
mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
- event = {"headers": {}}
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
response = test_function(event, mock_context)
@@ -299,12 +282,46 @@ def test_function(event, context):
raise ValueError("Test error")
mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
- event = {"headers": {}}
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
response = test_function(event, mock_context)
- assert response["statusCode"] == 400
- assert "Test error" in response["body"]
+ assert response["statusCode"] == 500
+ assert "An unexpected error occurred" in response["body"]
+
+
+def test_api_wrapper_with_custom_max_request_size():
+ """Test api_wrapper with custom max_request_size parameter."""
+
+ @api_wrapper(max_request_size=10 * 1024 * 1024) # 10MB
+ def test_function(event, context):
+ return {"result": "success"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ # Create a large body that exceeds default 1MB but is under 10MB
+ large_body = "x" * (2 * 1024 * 1024) # 2MB
+ event = {"headers": {}, "httpMethod": "POST", "path": "/test", "body": large_body}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 200
+ assert "success" in response["body"]
+
+
+def test_api_wrapper_with_parentheses_no_args():
+ """Test api_wrapper() with parentheses but no arguments."""
+
+ @api_wrapper()
+ def test_function(event, context):
+ return {"result": "success"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 200
+ assert "success" in response["body"]
# =====================
@@ -395,7 +412,7 @@ def test_get_cert_path_iam_error():
# =====================
-@patch("utilities.common_functions.ssm_client")
+@patch("utilities.aws_helpers.ssm_client")
@patch.dict(os.environ, {"LISA_API_URL_PS_NAME": "/lisa/api/url", "REST_API_VERSION": "v2"})
def test_get_rest_api_container_endpoint(mock_ssm):
"""Test get_rest_api_container_endpoint retrieves endpoint from SSM."""
@@ -515,7 +532,7 @@ def test_merge_fields_nested_missing():
# =====================
-@patch("utilities.common_functions.boto3.client")
+@patch("utilities.aws_helpers.boto3.client")
def test_get_lambda_role_name(mock_boto_client):
"""Test get_lambda_role_name extracts role name from ARN."""
mock_sts = MagicMock()
diff --git a/test/lambda/test_configuration_lambda.py b/test/lambda/test_configuration_lambda.py
index 3723cbbc9..84085810e 100644
--- a/test/lambda/test_configuration_lambda.py
+++ b/test/lambda/test_configuration_lambda.py
@@ -399,11 +399,12 @@ def test_update_configuration_client_error(lambda_context):
# Call the function
response = update_configuration(event, lambda_context)
- # Verify response
- assert response["statusCode"] == 200 # The function still returns 200 even with errors
- # The ClientError is caught and logged, but the function returns None
- # Our mock_api_wrapper wraps this as a 200 response with an empty body
- assert response["body"] == "null"
+ # Verify response - InternalServerError should result in 500 status code
+ # (The status code comes from the ResponseMetadata in the ClientError)
+ assert response["statusCode"] >= 400 # Should be an error status code
+ # The error message should be in the body
+ body = response["body"]
+ assert "error" in body.lower() or "internal" in body.lower()
def test_update_configuration_complex_data(config_table, lambda_context):
diff --git a/test/lambda/test_db_setup_iam_auth.py b/test/lambda/test_db_setup_iam_auth.py
index 796429108..b305657ca 100644
--- a/test/lambda/test_db_setup_iam_auth.py
+++ b/test/lambda/test_db_setup_iam_auth.py
@@ -36,7 +36,7 @@
os.environ["AWS_REGION"] = "us-east-1"
# Import the module to test
-from utilities.db_setup_iam_auth import create_db_user, get_db_credentials, handler
+from utilities.db_setup_iam_auth import create_db_user, delete_bootstrap_secret, get_db_credentials, handler
@pytest.fixture(scope="function")
@@ -94,19 +94,79 @@ def test_get_db_credentials_success():
mock_secretsmanager.get_secret_value.assert_called_once_with(SecretId=secret_arn)
-def test_get_db_credentials_error():
- """Test error handling when Secrets Manager fails."""
- # Use patch to force a ClientError
- with patch("boto3.client") as mock_boto3_client:
- mock_client = MagicMock()
- mock_client.get_secret_value.side_effect = ClientError(
+def test_get_db_credentials_not_found():
+ """Test handling when secret is not found (returns None)."""
+ with patch("utilities.db_setup_iam_auth.boto3.client") as mock_client:
+ mock_secretsmanager = MagicMock()
+ mock_client.return_value = mock_secretsmanager
+ mock_secretsmanager.get_secret_value.side_effect = ClientError(
{"Error": {"Code": "ResourceNotFoundException", "Message": "Secret not found"}}, "GetSecretValue"
)
- mock_boto3_client.return_value = mock_client
- # Call the function and assert it raises the expected exception
+ # ResourceNotFoundException returns None (secret already deleted)
+ result = get_db_credentials("non-existent-arn")
+ assert result is None
+
+
+def test_get_db_credentials_error():
+ """Test error handling when Secrets Manager fails with non-ResourceNotFound error."""
+ with patch("utilities.db_setup_iam_auth.boto3.client") as mock_client:
+ mock_secretsmanager = MagicMock()
+ mock_client.return_value = mock_secretsmanager
+ mock_secretsmanager.get_secret_value.side_effect = ClientError(
+ {"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}}, "GetSecretValue"
+ )
+
+ # Other errors should raise an exception
with pytest.raises(Exception, match="Error retrieving secrets"):
- get_db_credentials("non-existent-arn")
+ get_db_credentials("test-arn")
+
+
+def test_delete_bootstrap_secret_success():
+ """Test successful deletion of bootstrap secret."""
+ secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret"
+
+ with patch("utilities.db_setup_iam_auth.boto3.client") as mock_client:
+ mock_secretsmanager = MagicMock()
+ mock_client.return_value = mock_secretsmanager
+
+ result = delete_bootstrap_secret(secret_arn)
+
+ assert result is True
+ mock_client.assert_called_once_with("secretsmanager", region_name="us-east-1")
+ mock_secretsmanager.delete_secret.assert_called_once_with(SecretId=secret_arn, ForceDeleteWithoutRecovery=True)
+
+
+def test_delete_bootstrap_secret_already_deleted():
+ """Test handling when secret is already deleted."""
+ secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret"
+
+ with patch("utilities.db_setup_iam_auth.boto3.client") as mock_client:
+ mock_secretsmanager = MagicMock()
+ mock_client.return_value = mock_secretsmanager
+ mock_secretsmanager.delete_secret.side_effect = ClientError(
+ {"Error": {"Code": "ResourceNotFoundException", "Message": "Secret not found"}}, "DeleteSecret"
+ )
+
+ result = delete_bootstrap_secret(secret_arn)
+
+ assert result is True
+
+
+def test_delete_bootstrap_secret_error():
+ """Test handling when secret deletion fails."""
+ secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret"
+
+ with patch("utilities.db_setup_iam_auth.boto3.client") as mock_client:
+ mock_secretsmanager = MagicMock()
+ mock_client.return_value = mock_secretsmanager
+ mock_secretsmanager.delete_secret.side_effect = ClientError(
+ {"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}}, "DeleteSecret"
+ )
+
+ result = delete_bootstrap_secret(secret_arn)
+
+ assert result is False
def test_create_db_user_success(mock_psycopg2_connection):
@@ -176,11 +236,13 @@ def test_create_db_user_existing_user(mock_psycopg2_connection):
mock_conn, mock_cursor = mock_psycopg2_connection
class PsycopgError(psycopg2.Error):
- pgcode = "23505" # Permission denied
+ pgcode = "23505" # Unique violation
- # Configure the cursor to raise a unique violation error on the first execute call
+ # Configure the cursor: vector extension succeeds, CREATE USER raises unique violation, rest succeed
+ # Order: CREATE EXTENSION (success), CREATE USER (unique violation), then GRANT commands (success)
+ # There are 14 GRANT/ALTER commands after CREATE USER
unique_violation_error = PsycopgError("unique violation")
- mock_cursor.execute.side_effect = [unique_violation_error] + [None] * 10 # First call raises, rest succeed
+ mock_cursor.execute.side_effect = [None, unique_violation_error] + [None] * 14
# Mock the psycopg2.connect function
with patch("psycopg2.connect", return_value=mock_conn):
@@ -205,14 +267,15 @@ class PsycopgError(psycopg2.Error):
def test_create_db_user_error(mock_psycopg2_connection):
- """Test error handling for other PostgreSQL errors."""
+ """Test error handling for other PostgreSQL errors during CREATE USER."""
mock_conn, mock_cursor = mock_psycopg2_connection
- # Configure the cursor to raise a non-unique violation error
+ # Configure the cursor to raise a non-unique violation error on CREATE USER
class PsycopgError(psycopg2.Error):
pgcode = "42P01" # Table does not exist
- mock_cursor.execute.side_effect = PsycopgError("relation does not exist")
+ # Vector extension succeeds, CREATE USER fails with non-unique-violation error
+ mock_cursor.execute.side_effect = [None, PsycopgError("relation does not exist")]
# Mock the psycopg2.connect function
with patch("psycopg2.connect", return_value=mock_conn):
@@ -236,12 +299,12 @@ def test_create_db_user_grant_error(mock_psycopg2_connection):
"""Test error handling when granting privileges fails."""
mock_conn, mock_cursor = mock_psycopg2_connection
- # Configure the cursor to raise an error on the second execute call (first GRANT command)
+ # Configure the cursor to raise an error on the GRANT command
class PsycopgError(psycopg2.Error):
pgcode = "42501" # Permission denied
- # First call succeeds (CREATE USER), second call fails (GRANT)
- mock_cursor.execute.side_effect = [None, PsycopgError("permission denied")]
+ # Order: CREATE EXTENSION (success), CREATE USER (success), first GRANT (fails)
+ mock_cursor.execute.side_effect = [None, None, PsycopgError("permission denied")]
# Mock the psycopg2.connect function
with patch("psycopg2.connect", return_value=mock_conn):
@@ -266,71 +329,72 @@ class PsycopgError(psycopg2.Error):
def test_handler_success(lambda_context):
- """Test successful execution of the handler."""
- # Set up environment variables
- env_vars = {
- "SECRET_ARN": "test-arn",
- "DB_HOST": "test-host",
- "DB_PORT": "5432",
- "DB_NAME": "test-db",
- "DB_USER": "admin",
- "IAM_NAME": "test-iam-user",
+ """Test successful handler execution with event payload."""
+ event = {
+ "secretArn": "test-arn",
+ "dbHost": "test-host",
+ "dbPort": "5432",
+ "dbName": "test-db",
+ "dbUser": "admin",
+ "iamName": "test-iam-user",
}
- # Mock the create_db_user function
with patch("utilities.db_setup_iam_auth.create_db_user") as mock_create_db_user:
- # Mock environment variables
- with patch.dict(os.environ, env_vars):
- # Call the handler
- response = handler({}, lambda_context)
-
- # Verify create_db_user was called with the correct parameters
- mock_create_db_user.assert_called_once_with(
- "test-host", "5432", "test-db", "admin", "test-arn", "test-iam-user"
- )
+ mock_create_db_user.return_value = True
+
+ response = handler(event, lambda_context)
+
+ mock_create_db_user.assert_called_once_with(
+ "test-host", "5432", "test-db", "admin", "test-arn", "test-iam-user"
+ )
- # Verify the response
- assert response["statusCode"] == 200
- assert response["body"] == "Database user created successfully"
-
-
-def test_handler_missing_env_vars(lambda_context):
- """Test handler with missing environment variables."""
- # Set up incomplete environment variables
- env_vars = {
- "SECRET_ARN": "test-arn",
- # Missing DB_HOST
- "DB_PORT": "5432",
- "DB_NAME": "test-db",
- "DB_USER": "admin",
- "IAM_NAME": "test-iam-user",
+ assert response["statusCode"] == 200
+ body = json.loads(response["body"])
+ assert body["userCreated"] is True
+ # Secret is now retained for CloudFormation compatibility
+ assert body["secretDeleted"] is False
+
+
+def test_handler_missing_fields(lambda_context):
+ """Test handler with missing required fields in event payload."""
+ # Missing dbHost field
+ event = {
+ "secretArn": "test-arn",
+ # Missing dbHost
+ "dbPort": "5432",
+ "dbName": "test-db",
+ "dbUser": "admin",
+ "iamName": "test-iam-user",
}
- # Mock environment variables
- with patch.dict(os.environ, env_vars, clear=True):
- # Call the handler and expect a KeyError
- with pytest.raises(KeyError):
- handler({}, lambda_context)
+ response = handler(event, lambda_context)
+
+ # Handler returns 400 for invalid payload
+ assert response["statusCode"] == 400
+ body = json.loads(response["body"])
+ assert "error" in body
+ assert "dbHost" in body["error"]
def test_handler_create_db_user_error(lambda_context):
"""Test handler when create_db_user raises an exception."""
- # Set up environment variables
- env_vars = {
- "SECRET_ARN": "test-arn",
- "DB_HOST": "test-host",
- "DB_PORT": "5432",
- "DB_NAME": "test-db",
- "DB_USER": "admin",
- "IAM_NAME": "test-iam-user",
+ event = {
+ "secretArn": "test-arn",
+ "dbHost": "test-host",
+ "dbPort": "5432",
+ "dbName": "test-db",
+ "dbUser": "admin",
+ "iamName": "test-iam-user",
}
# Mock the create_db_user function to raise an exception
with patch("utilities.db_setup_iam_auth.create_db_user") as mock_create_db_user:
mock_create_db_user.side_effect = Exception("Database connection failed")
- # Mock environment variables
- with patch.dict(os.environ, env_vars):
- # Call the handler and expect the exception to propagate
- with pytest.raises(Exception, match="Database connection failed"):
- handler({}, lambda_context)
+ # Handler catches exceptions and returns 500
+ response = handler(event, lambda_context)
+
+ assert response["statusCode"] == 500
+ body = json.loads(response["body"])
+ assert "error" in body
+ assert "Database connection failed" in body["error"]
diff --git a/test/lambda/test_dict_helpers.py b/test/lambda/test_dict_helpers.py
new file mode 100644
index 000000000..e693dba22
--- /dev/null
+++ b/test/lambda/test_dict_helpers.py
@@ -0,0 +1,229 @@
+# 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.
+
+"""Unit tests for dict_helpers module."""
+
+from utilities.dict_helpers import get_item, get_property_path, merge_fields
+
+
+class TestMergeFields:
+ """Test merge_fields function."""
+
+ def test_merge_top_level_fields(self):
+ """Test merge_fields with top-level fields."""
+ source = {"field1": "value1", "field2": "value2", "field3": "value3"}
+ target = {"existing": "data"}
+ fields = ["field1", "field2"]
+
+ result = merge_fields(source, target, fields)
+
+ assert result["field1"] == "value1"
+ assert result["field2"] == "value2"
+ assert "field3" not in result
+ assert result["existing"] == "data"
+
+ def test_merge_nested_fields(self):
+ """Test merge_fields with nested fields using dot notation."""
+ source = {"user": {"profile": {"name": "John", "age": 30}, "email": "john@example.com"}}
+ target = {"id": "123"}
+ fields = ["user.profile.name", "user.email"]
+
+ result = merge_fields(source, target, fields)
+
+ assert result["user"]["profile"]["name"] == "John"
+ assert result["user"]["email"] == "john@example.com"
+ assert "age" not in result.get("user", {}).get("profile", {})
+ assert result["id"] == "123"
+
+ def test_merge_missing_source_field(self):
+ """Test merge_fields when source field doesn't exist."""
+ source = {"field1": "value1"}
+ target = {"existing": "data"}
+ fields = ["field1", "field2", "field3"]
+
+ result = merge_fields(source, target, fields)
+
+ assert result["field1"] == "value1"
+ assert "field2" not in result
+ assert "field3" not in result
+ assert result["existing"] == "data"
+
+ def test_merge_nested_missing_field(self):
+ """Test merge_fields with missing nested fields."""
+ source = {"user": {"name": "John"}}
+ target = {}
+ fields = ["user.profile.age"]
+
+ result = merge_fields(source, target, fields)
+
+ # Should not create nested structure if source doesn't have it
+ assert result == {}
+
+ def test_merge_mixed_fields(self):
+ """Test merge_fields with mix of top-level and nested fields."""
+ source = {"name": "John", "address": {"city": "Seattle", "state": "WA"}, "age": 30}
+ target = {}
+ fields = ["name", "address.city", "age"]
+
+ result = merge_fields(source, target, fields)
+
+ assert result["name"] == "John"
+ assert result["address"]["city"] == "Seattle"
+ assert "state" not in result["address"]
+ assert result["age"] == 30
+
+ def test_merge_overwrites_existing_fields(self):
+ """Test merge_fields overwrites existing fields in target."""
+ source = {"field1": "new_value"}
+ target = {"field1": "old_value", "field2": "keep"}
+ fields = ["field1"]
+
+ result = merge_fields(source, target, fields)
+
+ assert result["field1"] == "new_value"
+ assert result["field2"] == "keep"
+
+ def test_merge_with_none_values(self):
+ """Test merge_fields handles None values."""
+ source = {"field1": None, "field2": "value"}
+ target = {}
+ fields = ["field1", "field2"]
+
+ result = merge_fields(source, target, fields)
+
+ # Top-level None values ARE merged
+ assert result["field1"] is None
+ assert result["field2"] == "value"
+
+ def test_merge_empty_fields_list(self):
+ """Test merge_fields with empty fields list."""
+ source = {"field1": "value1"}
+ target = {"existing": "data"}
+ fields = []
+
+ result = merge_fields(source, target, fields)
+
+ assert result == {"existing": "data"}
+
+
+class TestGetPropertyPath:
+ """Test get_property_path function."""
+
+ def test_get_simple_property(self):
+ """Test get_property_path with simple property."""
+ data = {"name": "John", "age": 30}
+
+ result = get_property_path(data, "name")
+
+ assert result == "John"
+
+ def test_get_nested_property(self):
+ """Test get_property_path with nested property."""
+ data = {"user": {"profile": {"name": "John", "age": 30}, "email": "john@example.com"}}
+
+ result = get_property_path(data, "user.profile.name")
+
+ assert result == "John"
+
+ def test_get_deeply_nested_property(self):
+ """Test get_property_path with deeply nested property."""
+ data = {"level1": {"level2": {"level3": {"level4": "value"}}}}
+
+ result = get_property_path(data, "level1.level2.level3.level4")
+
+ assert result == "value"
+
+ def test_returns_none_for_missing_property(self):
+ """Test get_property_path returns None for missing property."""
+ data = {"name": "John"}
+
+ result = get_property_path(data, "age")
+
+ assert result is None
+
+ def test_returns_none_for_missing_nested_property(self):
+ """Test get_property_path returns None for missing nested property."""
+ data = {"user": {"name": "John"}}
+
+ result = get_property_path(data, "user.profile.age")
+
+ assert result is None
+
+ def test_handles_empty_path(self):
+ """Test get_property_path with empty path."""
+ data = {"name": "John"}
+
+ result = get_property_path(data, "")
+
+ # Empty path returns None (no valid property to access)
+ assert result is None
+
+ def test_handles_list_values(self):
+ """Test get_property_path with list values."""
+ data = {"users": [{"name": "John"}, {"name": "Jane"}]}
+
+ result = get_property_path(data, "users")
+
+ assert result == [{"name": "John"}, {"name": "Jane"}]
+
+ def test_handles_numeric_values(self):
+ """Test get_property_path with numeric values."""
+ data = {"count": 42, "price": 19.99}
+
+ assert get_property_path(data, "count") == 42
+ assert get_property_path(data, "price") == 19.99
+
+
+class TestGetItem:
+ """Test get_item function."""
+
+ def test_returns_first_item(self):
+ """Test get_item returns first item from DynamoDB response."""
+ response = {"Items": [{"id": "1", "name": "First"}, {"id": "2", "name": "Second"}]}
+
+ result = get_item(response)
+
+ assert result == {"id": "1", "name": "First"}
+
+ def test_returns_none_for_empty_items(self):
+ """Test get_item returns None when Items list is empty."""
+ response = {"Items": []}
+
+ result = get_item(response)
+
+ assert result is None
+
+ def test_returns_none_for_missing_items_key(self):
+ """Test get_item returns None when Items key is missing."""
+ response = {"Count": 0}
+
+ result = get_item(response)
+
+ assert result is None
+
+ def test_handles_single_item(self):
+ """Test get_item with single item in response."""
+ response = {"Items": [{"id": "123", "data": "value"}]}
+
+ result = get_item(response)
+
+ assert result == {"id": "123", "data": "value"}
+
+ def test_ignores_additional_items(self):
+ """Test get_item only returns first item, ignoring others."""
+ response = {"Items": [{"id": "1"}, {"id": "2"}, {"id": "3"}]}
+
+ result = get_item(response)
+
+ assert result == {"id": "1"}
diff --git a/test/lambda/test_dockerimagebuilder.py b/test/lambda/test_dockerimagebuilder.py
index fd5eeb875..5fecd2aaf 100644
--- a/test/lambda/test_dockerimagebuilder.py
+++ b/test/lambda/test_dockerimagebuilder.py
@@ -51,7 +51,7 @@ def lambda_context():
def test_handler_success(lambda_context):
"""Test successful handler execution."""
- event = {"base_image": "python:3.13-slim", "layer_to_add": "test-layer"}
+ event = {"base_image": "public.ecr.aws/docker/library/python:3.13-slim", "layer_to_add": "test-layer"}
# Mock boto3 resources and clients
mock_ec2_resource = MagicMock()
@@ -77,7 +77,7 @@ def test_handler_success(lambda_context):
# Verify SSM call
mock_ssm_client.get_parameter.assert_called_once_with(
- Name="/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2"
+ Name="/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
)
# Verify EC2 instance creation
@@ -89,7 +89,7 @@ def test_handler_success(lambda_context):
assert call_args["InstanceType"] == "m5.large"
assert call_args["SubnetId"] == "subnet-12345678"
assert "UserData" in call_args
- assert "python:3.13-slim" in call_args["UserData"]
+ assert "public.ecr.aws/docker/library/python:3.13-slim" in call_args["UserData"]
assert "test-layer" in call_args["UserData"]
@@ -99,7 +99,7 @@ def test_handler_without_subnet_id(lambda_context):
original_subnet_id = os.environ.pop("LISA_SUBNET_ID", None)
try:
- event = {"base_image": "python:3.13-slim", "layer_to_add": "test-layer"}
+ event = {"base_image": "public.ecr.aws/docker/library/python:3.13-slim", "layer_to_add": "test-layer"}
# Mock boto3 resources and clients
mock_ec2_resource = MagicMock()
@@ -134,7 +134,7 @@ def test_handler_without_subnet_id(lambda_context):
def test_handler_client_error(lambda_context):
"""Test handler with ClientError."""
- event = {"base_image": "python:3.13-slim", "layer_to_add": "test-layer"}
+ event = {"base_image": "public.ecr.aws/docker/library/python:3.13-slim", "layer_to_add": "test-layer"}
# Mock boto3 resources and clients
mock_ec2_resource = MagicMock()
@@ -159,7 +159,7 @@ def test_handler_client_error(lambda_context):
def test_handler_ssm_error(lambda_context):
"""Test handler with SSM ClientError."""
- event = {"base_image": "python:3.13-slim", "layer_to_add": "test-layer"}
+ event = {"base_image": "public.ecr.aws/docker/library/python:3.13-slim", "layer_to_add": "test-layer"}
# Mock boto3 resources and clients
mock_ec2_resource = MagicMock()
@@ -182,7 +182,7 @@ def test_handler_ssm_error(lambda_context):
def test_user_data_template_rendering(lambda_context):
"""Test that user data template is properly rendered."""
- event = {"base_image": "python:3.13-slim", "layer_to_add": "test-layer"}
+ event = {"base_image": "public.ecr.aws/docker/library/python:3.13-slim", "layer_to_add": "test-layer"}
# Mock boto3 resources and clients
mock_ec2_resource = MagicMock()
@@ -209,7 +209,7 @@ def test_user_data_template_rendering(lambda_context):
assert "us-east-1" in user_data # AWS_REGION
assert "test-bucket" in user_data # BUCKET_NAME
assert "test-layer" in user_data # LAYER_TO_ADD
- assert "python:3.13-slim" in user_data # BASE_IMAGE
+ assert "public.ecr.aws/docker/library/python:3.13-slim" in user_data # BASE_IMAGE
assert "https://example.com/mounts3.deb" in user_data # MOUNTS3_DEB_URL
assert "123456789012.dkr.ecr.us-east-1.amazonaws.com/test-repo" in user_data # ECR_URI
assert result["image_tag"] in user_data # IMAGE_ID
diff --git a/test/lambda/test_event_parser.py b/test/lambda/test_event_parser.py
new file mode 100644
index 000000000..ce43081ad
--- /dev/null
+++ b/test/lambda/test_event_parser.py
@@ -0,0 +1,339 @@
+# 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.
+
+"""Unit tests for event_parser module."""
+
+import json
+
+import pytest
+from utilities.event_parser import (
+ get_bearer_token,
+ get_id_token,
+ get_principal_id,
+ get_session_id,
+ sanitize_event_for_logging,
+)
+
+
+class TestSanitizeEventForLogging:
+ """Test sanitize_event_for_logging function."""
+
+ def test_redacts_authorization_header(self):
+ """Test sanitize_event_for_logging redacts authorization header."""
+ event = {"headers": {"Authorization": "Bearer secret-token-123"}, "path": "/test"}
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ assert parsed["headers"]["authorization"] == ""
+ assert "secret-token-123" not in sanitized
+
+ def test_normalizes_header_keys(self):
+ """Test sanitize_event_for_logging normalizes header keys to lowercase."""
+ event = {
+ "headers": {"Content-Type": "application/json", "User-Agent": "test-agent"},
+ "path": "/test",
+ "requestContext": {
+ "identity": {"sourceIp": "203.0.113.42"},
+ "domainName": "api.example.com",
+ "stage": "prod",
+ "requestId": "test-request-123",
+ },
+ }
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ assert "content-type" in parsed["headers"]
+ assert "user-agent" in parsed["headers"]
+ assert "Content-Type" not in parsed["headers"]
+ assert "User-Agent" not in parsed["headers"]
+
+ def test_handles_multi_value_headers(self):
+ """Test sanitize_event_for_logging handles multiValueHeaders."""
+ event = {
+ "headers": {"authorization": "Bearer token"},
+ "multiValueHeaders": {"Authorization": ["Bearer token"], "Accept": ["application/json", "text/html"]},
+ "path": "/test",
+ }
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ assert parsed["multiValueHeaders"]["authorization"] == [""]
+ assert "accept" in parsed["multiValueHeaders"]
+
+ def test_preserves_other_fields(self):
+ """Test sanitize_event_for_logging preserves non-header fields."""
+ event = {
+ "headers": {"content-type": "application/json"},
+ "path": "/users/123",
+ "httpMethod": "GET",
+ "queryStringParameters": {"filter": "active"},
+ }
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ assert parsed["path"] == "/users/123"
+ assert parsed["httpMethod"] == "GET"
+ assert parsed["queryStringParameters"]["filter"] == "active"
+
+ def test_does_not_modify_original_event(self):
+ """Test sanitize_event_for_logging does not modify original event."""
+ event = {"headers": {"Authorization": "Bearer token"}, "path": "/test"}
+ original_auth = event["headers"]["Authorization"]
+
+ sanitize_event_for_logging(event)
+
+ assert event["headers"]["Authorization"] == original_auth
+
+ def test_removes_actiontrace_headers(self):
+ """Test sanitize_event_for_logging removes x-amzn-actiontrace headers."""
+ event = {
+ "headers": {
+ "accept": "application/json",
+ "x-amzn-actiontrace": "vof-test-injected-header-1770159407",
+ "x-amzn-actiontrace-caller": "malicious-caller",
+ "x-amzn-actiontrace-target-operation": "malicious-operation",
+ "user-agent": "curl/8.7.1",
+ },
+ "requestContext": {
+ "identity": {"sourceIp": "203.0.113.42"},
+ "domainName": "api.example.com",
+ "stage": "prod",
+ "requestId": "test-request-123",
+ },
+ "path": "/test",
+ }
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ # Actiontrace headers should be removed
+ assert "x-amzn-actiontrace" not in parsed["headers"]
+ assert "x-amzn-actiontrace-caller" not in parsed["headers"]
+ assert "x-amzn-actiontrace-target-operation" not in parsed["headers"]
+
+ # Other headers should remain
+ assert parsed["headers"]["accept"] == "application/json"
+ assert parsed["headers"]["user-agent"] == "curl/8.7.1"
+
+ def test_removes_actiontrace_from_multi_value_headers(self):
+ """Test sanitize_event_for_logging removes actiontrace from multiValueHeaders."""
+ event = {
+ "headers": {"accept": "application/json"},
+ "multiValueHeaders": {
+ "accept": ["application/json"],
+ "x-amzn-actiontrace": ["vof-test-injected-header"],
+ "x-amzn-actiontrace-caller": ["malicious-caller"],
+ "user-agent": ["curl/8.7.1"],
+ },
+ "requestContext": {
+ "identity": {"sourceIp": "203.0.113.42"},
+ "domainName": "api.example.com",
+ "stage": "prod",
+ "requestId": "test-request-123",
+ },
+ "path": "/test",
+ }
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ # Actiontrace headers should be removed from multiValueHeaders
+ assert "x-amzn-actiontrace" not in parsed["multiValueHeaders"]
+ assert "x-amzn-actiontrace-caller" not in parsed["multiValueHeaders"]
+
+ # Other headers should remain
+ assert "accept" in parsed["multiValueHeaders"]
+ assert "user-agent" in parsed["multiValueHeaders"]
+
+ def test_replaces_security_critical_headers(self):
+ """Test sanitize_event_for_logging replaces security-critical headers."""
+ event = {
+ "headers": {
+ "x-forwarded-for": "1.2.3.4, 5.6.7.8",
+ "x-forwarded-host": "malicious.example.com",
+ "accept": "application/json",
+ },
+ "requestContext": {
+ "identity": {"sourceIp": "203.0.113.42"},
+ "domainName": "api.example.com",
+ "stage": "prod",
+ "requestId": "test-request-123",
+ },
+ "path": "/test",
+ }
+
+ sanitized = sanitize_event_for_logging(event)
+ parsed = json.loads(sanitized)
+
+ # Security-critical headers should be replaced with server values
+ assert parsed["headers"]["x-forwarded-for"] == "203.0.113.42"
+ assert parsed["headers"]["x-forwarded-host"] == "api.example.com"
+
+ # Other headers should remain unchanged
+ assert parsed["headers"]["accept"] == "application/json"
+
+
+class TestGetSessionId:
+ """Test get_session_id function."""
+
+ def test_extracts_session_id(self):
+ """Test get_session_id extracts session ID from path parameters."""
+ event = {"pathParameters": {"sessionId": "session-abc-123"}}
+
+ session_id = get_session_id(event)
+
+ assert session_id == "session-abc-123"
+
+ def test_returns_none_when_missing(self):
+ """Test get_session_id returns None when session ID is missing."""
+ event = {"pathParameters": {}}
+
+ session_id = get_session_id(event)
+
+ assert session_id is None
+
+ def test_handles_missing_path_parameters(self):
+ """Test get_session_id handles missing pathParameters."""
+ event = {}
+
+ session_id = get_session_id(event)
+
+ assert session_id is None
+
+
+class TestGetPrincipalId:
+ """Test get_principal_id function."""
+
+ def test_extracts_principal_id(self):
+ """Test get_principal_id extracts principal from authorizer context."""
+ event = {"requestContext": {"authorizer": {"principal": "user-123"}}}
+
+ principal = get_principal_id(event)
+
+ assert principal == "user-123"
+
+ def test_returns_empty_string_when_missing(self):
+ """Test get_principal_id returns empty string when principal is missing."""
+ event = {"requestContext": {"authorizer": {}}}
+
+ principal = get_principal_id(event)
+
+ assert principal == ""
+
+ def test_handles_missing_request_context(self):
+ """Test get_principal_id handles missing requestContext."""
+ event = {}
+
+ principal = get_principal_id(event)
+
+ assert principal == ""
+
+
+class TestGetBearerToken:
+ """Test get_bearer_token function."""
+
+ def test_extracts_token_uppercase_authorization(self):
+ """Test get_bearer_token extracts token from uppercase Authorization header."""
+ event = {"headers": {"Authorization": "Bearer test-token-123"}}
+
+ token = get_bearer_token(event)
+
+ assert token == "test-token-123"
+
+ def test_extracts_token_lowercase_authorization(self):
+ """Test get_bearer_token extracts token from lowercase authorization header."""
+ event = {"headers": {"authorization": "Bearer test-token-456"}}
+
+ token = get_bearer_token(event)
+
+ assert token == "test-token-456"
+
+ def test_returns_none_when_missing(self):
+ """Test get_bearer_token returns None when header is missing."""
+ event = {"headers": {}}
+
+ token = get_bearer_token(event)
+
+ assert token is None
+
+ def test_returns_none_for_non_bearer(self):
+ """Test get_bearer_token returns None for non-Bearer auth."""
+ event = {"headers": {"Authorization": "Basic dXNlcjpwYXNz"}}
+
+ token = get_bearer_token(event)
+
+ assert token is None
+
+ def test_handles_missing_headers(self):
+ """Test get_bearer_token handles missing headers."""
+ event = {}
+
+ token = get_bearer_token(event)
+
+ assert token is None
+
+ def test_strips_whitespace(self):
+ """Test get_bearer_token strips whitespace from token."""
+ event = {"headers": {"Authorization": "Bearer token-with-spaces "}}
+
+ token = get_bearer_token(event)
+
+ assert token == "token-with-spaces"
+
+
+class TestGetIdToken:
+ """Test get_id_token function."""
+
+ def test_extracts_token_lowercase_authorization(self):
+ """Test get_id_token extracts token from lowercase authorization header."""
+ event = {"headers": {"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"}}
+
+ token = get_id_token(event)
+
+ assert token == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+
+ def test_extracts_token_uppercase_authorization(self):
+ """Test get_id_token extracts token from uppercase Authorization header."""
+ event = {"headers": {"Authorization": "Bearer eyJzdWIiOiIxMjM0NTY3ODkwIn0"}}
+
+ token = get_id_token(event)
+
+ assert token == "eyJzdWIiOiIxMjM0NTY3ODkwIn0"
+
+ def test_handles_lowercase_bearer(self):
+ """Test get_id_token handles lowercase bearer prefix."""
+ event = {"headers": {"authorization": "bearer test-token"}}
+
+ token = get_id_token(event)
+
+ assert token == "test-token"
+
+ def test_raises_error_when_missing(self):
+ """Test get_id_token raises ValueError when header is missing."""
+ event = {"headers": {}}
+
+ with pytest.raises(ValueError, match="Missing authorization token"):
+ get_id_token(event)
+
+ def test_strips_whitespace(self):
+ """Test get_id_token strips whitespace from token."""
+ event = {"headers": {"Authorization": "Bearer token-123 "}}
+
+ token = get_id_token(event)
+
+ assert token == "token-123"
diff --git a/test/lambda/test_header_sanitizer.py b/test/lambda/test_header_sanitizer.py
new file mode 100644
index 000000000..0b2c702c1
--- /dev/null
+++ b/test/lambda/test_header_sanitizer.py
@@ -0,0 +1,205 @@
+# 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.
+
+"""Unit tests for header_sanitizer module."""
+
+import pytest
+from utilities.header_sanitizer import (
+ get_sanitized_headers_for_logging,
+ sanitize_headers,
+)
+
+
+@pytest.fixture
+def mock_event():
+ """Create a mock API Gateway event."""
+ return {
+ "requestContext": {
+ "identity": {"sourceIp": "203.0.113.42"},
+ "domainName": "api.example.com",
+ "stage": "prod",
+ "requestId": "test-request-123",
+ }
+ }
+
+
+class TestSanitizeHeaders:
+ """Test sanitize_headers function."""
+
+ def test_only_logs_allowlisted_headers(self, mock_event):
+ """Test that only allowlisted headers are included in output."""
+ headers = {
+ "accept": "application/json",
+ "x-amzn-actiontrace": "vof-test-injected-header-1770159407",
+ "x-amzn-actiontrace-caller": "malicious-caller",
+ "x-custom-malicious-header": "malicious-value",
+ "user-agent": "curl/8.7.1",
+ "content-type": "application/json",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ # Only allowlisted headers should be present
+ assert result["accept"] == "application/json"
+ assert result["user-agent"] == "curl/8.7.1"
+ assert result["content-type"] == "application/json"
+
+ # Non-allowlisted headers should be removed
+ assert "x-amzn-actiontrace" not in result
+ assert "x-amzn-actiontrace-caller" not in result
+ assert "x-custom-malicious-header" not in result
+
+ def test_drops_all_non_allowlisted_headers(self, mock_event):
+ """Test that all non-allowlisted headers are dropped."""
+ headers = {
+ "X-Amzn-ActionTrace": "injected-value",
+ "X-AMZN-ACTIONTRACE-CALLER": "injected-caller",
+ "X-Custom-Header": "custom-value",
+ "X-Forwarded-Server": "malicious-server",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ # No non-allowlisted headers should be present
+ assert len(result) == 0
+
+ def test_replaces_x_forwarded_for_with_real_ip(self, mock_event):
+ """Test that x-forwarded-for is replaced with real client IP."""
+ headers = {
+ "x-forwarded-for": "1.2.3.4, 5.6.7.8",
+ "accept": "application/json",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ assert result["x-forwarded-for"] == "203.0.113.42"
+ assert result["accept"] == "application/json"
+
+ def test_replaces_x_forwarded_host_with_domain_name(self, mock_event):
+ """Test that x-forwarded-host is replaced with API Gateway domain."""
+ headers = {
+ "x-forwarded-host": "malicious.example.com",
+ "accept": "application/json",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ assert result["x-forwarded-host"] == "api.example.com"
+ assert result["accept"] == "application/json"
+
+ def test_replaces_x_forwarded_proto_with_https(self, mock_event):
+ """Test that x-forwarded-proto is replaced with https."""
+ headers = {
+ "x-forwarded-proto": "http",
+ "accept": "application/json",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ assert result["x-forwarded-proto"] == "https"
+ assert result["accept"] == "application/json"
+
+ def test_handles_empty_headers(self, mock_event):
+ """Test that empty headers dict is handled correctly."""
+ result = sanitize_headers({}, mock_event)
+ assert result == {}
+
+ def test_handles_none_headers(self, mock_event):
+ """Test that None headers is handled correctly."""
+ result = sanitize_headers(None, mock_event)
+ assert result == {}
+
+ def test_preserves_safe_headers(self, mock_event):
+ """Test that allowlisted headers are preserved."""
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ "user-agent": "curl/8.7.1",
+ "host": "api.example.com",
+ "accept-encoding": "gzip, deflate",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ assert result["accept"] == "application/json"
+ assert result["content-type"] == "application/json"
+ assert result["user-agent"] == "curl/8.7.1"
+ assert result["host"] == "api.example.com"
+ assert result["accept-encoding"] == "gzip, deflate"
+
+ def test_combined_sanitization(self, mock_event):
+ """Test sanitization with mix of allowlisted, server-controlled, and malicious headers."""
+ headers = {
+ "accept": "application/json",
+ "x-forwarded-for": "1.2.3.4",
+ "x-amzn-actiontrace": "injected-trace",
+ "x-amzn-actiontrace-caller": "injected-caller",
+ "user-agent": "curl/8.7.1",
+ "x-forwarded-host": "malicious.com",
+ "x-custom-header": "should-be-dropped",
+ }
+
+ result = sanitize_headers(headers, mock_event)
+
+ # allowlisted headers preserved
+ assert result["accept"] == "application/json"
+ assert result["user-agent"] == "curl/8.7.1"
+
+ # Server-controlled headers replaced
+ assert result["x-forwarded-for"] == "203.0.113.42"
+ assert result["x-forwarded-host"] == "api.example.com"
+
+ # Non-allowlisted headers dropped
+ assert "x-amzn-actiontrace" not in result
+ assert "x-amzn-actiontrace-caller" not in result
+ assert "x-custom-header" not in result
+
+
+class TestGetSanitizedHeadersForLogging:
+ """Test get_sanitized_headers_for_logging function."""
+
+ def test_extracts_and_sanitizes_headers(self, mock_event):
+ """Test that headers are extracted from event and sanitized."""
+ event = {
+ **mock_event,
+ "headers": {
+ "accept": "application/json",
+ "x-amzn-actiontrace": "injected-value",
+ "x-forwarded-for": "1.2.3.4",
+ "x-malicious-header": "bad-value",
+ },
+ }
+
+ result = get_sanitized_headers_for_logging(event)
+
+ # allowlisted header preserved
+ assert result["accept"] == "application/json"
+
+ # Server-controlled header replaced
+ assert result["x-forwarded-for"] == "203.0.113.42"
+
+ # Non-allowlisted headers dropped
+ assert "x-amzn-actiontrace" not in result
+ assert "x-malicious-header" not in result
+
+ def test_handles_missing_headers(self, mock_event):
+ """Test that missing headers key is handled correctly."""
+ result = get_sanitized_headers_for_logging(mock_event)
+ assert result == {}
+
+ def test_handles_empty_headers(self, mock_event):
+ """Test that empty headers dict is handled correctly."""
+ event = {**mock_event, "headers": {}}
+ result = get_sanitized_headers_for_logging(event)
+ assert result == {}
diff --git a/test/lambda/test_healthcheck_validator.py b/test/lambda/test_healthcheck_validator.py
new file mode 100644
index 000000000..7f5a75e81
--- /dev/null
+++ b/test/lambda/test_healthcheck_validator.py
@@ -0,0 +1,205 @@
+# 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.
+
+"""Unit tests for ECS healthcheck command validator."""
+import pytest
+from utilities.healthcheck_validator import validate_healthcheck_command
+
+
+class TestValidHealthcheckFormats:
+ """Test valid healthcheck command formats."""
+
+ def test_valid_string_command(self):
+ """Test that a non-empty string command is valid."""
+ # Valid string format - ECS converts to CMD-SHELL
+ validate_healthcheck_command("curl -f http://localhost:8080/health")
+ # Should not raise any exception
+
+ def test_valid_string_with_complex_command(self):
+ """Test that complex string commands are valid."""
+ validate_healthcheck_command("curl -f http://localhost:8080/health || exit 1")
+ # Should not raise any exception
+
+ def test_valid_cmd_shell_array(self):
+ """Test that CMD-SHELL array format is valid."""
+ validate_healthcheck_command(["CMD-SHELL", "curl -f http://localhost:8080/health"])
+ # Should not raise any exception
+
+ def test_valid_cmd_shell_array_with_complex_command(self):
+ """Test that CMD-SHELL with complex shell command is valid."""
+ validate_healthcheck_command(["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"])
+ # Should not raise any exception
+
+ def test_valid_cmd_array(self):
+ """Test that CMD array format is valid."""
+ validate_healthcheck_command(["CMD", "curl", "-f", "http://localhost:8080/health"])
+ # Should not raise any exception
+
+ def test_valid_cmd_array_single_argument(self):
+ """Test that CMD array with single argument is valid."""
+ validate_healthcheck_command(["CMD", "/healthcheck.sh"])
+ # Should not raise any exception
+
+ def test_valid_cmd_array_multiple_arguments(self):
+ """Test that CMD array with multiple arguments is valid."""
+ validate_healthcheck_command(["CMD", "python", "-c", "import requests; requests.get('http://localhost:8080')"])
+ # Should not raise any exception
+
+
+class TestInvalidHealthcheckFormats:
+ """Test invalid healthcheck command formats."""
+
+ def test_none_command_raises_error(self):
+ """Test that None command raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(None)
+
+ assert "cannot be None" in str(exc_info.value)
+
+ def test_empty_string_raises_error(self):
+ """Test that empty string raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command("")
+
+ assert "cannot be an empty string" in str(exc_info.value)
+
+ def test_whitespace_only_string_raises_error(self):
+ """Test that whitespace-only string raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(" ")
+
+ assert "cannot be an empty string" in str(exc_info.value)
+
+ def test_empty_array_raises_error(self):
+ """Test that empty array raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command([])
+
+ assert "array cannot be empty" in str(exc_info.value)
+
+ def test_array_without_cmd_prefix_raises_error(self):
+ """Test that array without CMD/CMD-SHELL prefix raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["curl", "-f", "http://localhost:8080/health"])
+
+ error_message = str(exc_info.value)
+ assert "must start with 'CMD' or 'CMD-SHELL'" in error_message
+ assert "got: 'curl'" in error_message
+
+ def test_array_with_invalid_prefix_raises_error(self):
+ """Test that array with invalid prefix raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["SHELL", "curl -f http://localhost:8080/health"])
+
+ error_message = str(exc_info.value)
+ assert "must start with 'CMD' or 'CMD-SHELL'" in error_message
+ assert "got: 'SHELL'" in error_message
+
+ def test_array_with_only_cmd_prefix_raises_error(self):
+ """Test that array with only CMD prefix and no command raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["CMD"])
+
+ error_message = str(exc_info.value)
+ assert "must contain a command after 'CMD'" in error_message
+
+ def test_array_with_only_cmd_shell_prefix_raises_error(self):
+ """Test that array with only CMD-SHELL prefix and no command raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["CMD-SHELL"])
+
+ error_message = str(exc_info.value)
+ assert "must contain a command after 'CMD-SHELL'" in error_message
+
+ def test_array_with_empty_command_after_prefix_raises_error(self):
+ """Test that array with empty command after prefix raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["CMD-SHELL", ""])
+
+ assert "cannot be empty after CMD/CMD-SHELL prefix" in str(exc_info.value)
+
+ def test_array_with_whitespace_command_after_prefix_raises_error(self):
+ """Test that array with whitespace-only command after prefix raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["CMD-SHELL", " "])
+
+ assert "cannot be empty after CMD/CMD-SHELL prefix" in str(exc_info.value)
+
+ def test_integer_command_raises_error(self):
+ """Test that integer command raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(123)
+
+ error_message = str(exc_info.value)
+ assert "must be a string or array" in error_message
+ assert "got: int" in error_message
+
+ def test_dict_command_raises_error(self):
+ """Test that dict command raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command({"command": "curl"})
+
+ error_message = str(exc_info.value)
+ assert "must be a string or array" in error_message
+ assert "got: dict" in error_message
+
+ def test_boolean_command_raises_error(self):
+ """Test that boolean command raises ValueError."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(True)
+
+ error_message = str(exc_info.value)
+ assert "must be a string or array" in error_message
+ assert "got: bool" in error_message
+
+
+class TestErrorMessageHelpfulness:
+ """Test that error messages provide helpful guidance."""
+
+ def test_missing_prefix_error_includes_example(self):
+ """Test that missing prefix error includes example format."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["curl", "http://localhost:8080"])
+
+ error_message = str(exc_info.value)
+ assert "Example:" in error_message
+ assert "CMD-SHELL" in error_message
+ assert "curl" in error_message
+
+ def test_empty_array_after_prefix_error_includes_example(self):
+ """Test that empty command error includes example format."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["CMD"])
+
+ error_message = str(exc_info.value)
+ assert "Example:" in error_message
+ assert "CMD-SHELL" in error_message
+
+ def test_wrong_type_error_includes_example(self):
+ """Test that wrong type error includes example format."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(123)
+
+ error_message = str(exc_info.value)
+ assert "Example:" in error_message
+ assert "CMD-SHELL" in error_message
+
+ def test_error_messages_mention_both_cmd_and_cmd_shell(self):
+ """Test that error messages mention both valid prefixes."""
+ with pytest.raises(ValueError) as exc_info:
+ validate_healthcheck_command(["INVALID", "command"])
+
+ error_message = str(exc_info.value)
+ assert "CMD" in error_message
+ assert "CMD-SHELL" in error_message
diff --git a/test/lambda/test_input_validation.py b/test/lambda/test_input_validation.py
new file mode 100644
index 000000000..9684b3738
--- /dev/null
+++ b/test/lambda/test_input_validation.py
@@ -0,0 +1,285 @@
+# 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.
+
+"""Unit tests for input validation decorator."""
+
+import json
+import os
+import sys
+from unittest.mock import MagicMock
+
+import pytest
+
+# Add the lambda directory to the Python path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../lambda"))
+
+from utilities.input_validation import contains_null_bytes, validate_input
+
+
+class TestContainsNullBytes:
+ """Test null byte detection function."""
+
+ def test_no_null_bytes(self):
+ """Test string without null bytes."""
+ assert not contains_null_bytes("normal string")
+ assert not contains_null_bytes("string with special chars: !@#$%^&*()")
+ assert not contains_null_bytes("")
+
+ def test_with_null_bytes(self):
+ """Test string with null bytes."""
+ assert contains_null_bytes("string\x00with null")
+ assert contains_null_bytes("\x00at start")
+ assert contains_null_bytes("at end\x00")
+ assert contains_null_bytes("\x00")
+
+
+class TestValidateInputDecorator:
+ """Test input validation decorator."""
+
+ @pytest.fixture
+ def mock_context(self):
+ """Create mock Lambda context."""
+ context = MagicMock()
+ context.function_name = "test-function"
+ context.aws_request_id = "test-request-id"
+ return context
+
+ @pytest.fixture
+ def valid_event(self):
+ """Create valid Lambda event."""
+ return {
+ "httpMethod": "GET",
+ "path": "/test/path",
+ "queryStringParameters": {"param1": "value1"},
+ "body": None,
+ "headers": {"content-type": "application/json"},
+ }
+
+ def test_valid_request_passes(self, valid_event, mock_context):
+ """Test that valid request passes validation."""
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
+
+ def test_invalid_http_method(self, valid_event, mock_context):
+ """Test that invalid HTTP method returns 405."""
+ valid_event["httpMethod"] = "TRACE"
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 405
+ body = json.loads(result["body"])
+ assert body["error"] == "Method Not Allowed"
+ assert "TRACE" in body["message"]
+
+ def test_oversized_request_body(self, valid_event, mock_context):
+ """Test that oversized request body returns 413."""
+ # Create body larger than 1MB
+ large_body = "x" * (1024 * 1024 + 1)
+ valid_event["body"] = large_body
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 413
+ body = json.loads(result["body"])
+ assert body["error"] == "Payload Too Large"
+
+ def test_custom_max_request_size(self, valid_event, mock_context):
+ """Test custom max request size limit."""
+ # Create body larger than custom limit (100 bytes)
+ valid_event["body"] = "x" * 101
+
+ @validate_input(max_request_size=100)
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 413
+
+ def test_null_byte_in_path(self, valid_event, mock_context):
+ """Test that null byte in path returns 400."""
+ valid_event["path"] = "/test\x00/path"
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Bad Request"
+ assert "path" in body["message"]
+
+ def test_null_byte_in_path_param_key(self, valid_event, mock_context):
+ """Test that null byte in path parameter key returns 400."""
+ valid_event["pathParameters"] = {"param\x00": "value"}
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Bad Request"
+ assert "path parameters" in body["message"]
+
+ def test_null_byte_in_path_param_value(self, valid_event, mock_context):
+ """Test that null byte in path parameter value returns 400."""
+ valid_event["pathParameters"] = {"promptTemplateId": "value\x00with_null"}
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Bad Request"
+ assert "path parameters" in body["message"]
+
+ def test_null_byte_in_query_param_key(self, valid_event, mock_context):
+ """Test that null byte in query parameter key returns 400."""
+ valid_event["queryStringParameters"] = {"param\x00": "value"}
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Bad Request"
+ assert "query parameters" in body["message"]
+
+ def test_null_byte_in_query_param_value(self, valid_event, mock_context):
+ """Test that null byte in query parameter value returns 400."""
+ valid_event["queryStringParameters"] = {"param": "value\x00"}
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Bad Request"
+ assert "query parameters" in body["message"]
+
+ def test_null_byte_in_request_body(self, valid_event, mock_context):
+ """Test that null byte in request body returns 400."""
+ valid_event["httpMethod"] = "POST"
+ valid_event["body"] = '{"key": "value\x00"}'
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["statusCode"] == 400
+ body = json.loads(result["body"])
+ assert body["error"] == "Bad Request"
+ assert "request body" in body["message"]
+
+ def test_empty_path_parameters(self, valid_event, mock_context):
+ """Test that None path parameters are handled correctly."""
+ valid_event["pathParameters"] = None
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
+
+ def test_empty_query_parameters(self, valid_event, mock_context):
+ """Test that None query parameters are handled correctly."""
+ valid_event["queryStringParameters"] = None
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
+
+ def test_empty_body(self, valid_event, mock_context):
+ """Test that empty body is handled correctly."""
+ valid_event["body"] = ""
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
+
+ def test_post_request_with_valid_body(self, valid_event, mock_context):
+ """Test POST request with valid body passes validation."""
+ valid_event["httpMethod"] = "POST"
+ valid_event["body"] = '{"key": "value"}'
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
+
+ def test_all_valid_http_methods(self, valid_event, mock_context):
+ """Test that all valid HTTP methods are accepted."""
+ valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
+
+ for method in valid_methods:
+ valid_event["httpMethod"] = method
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True, f"Method {method} should be valid"
+
+ def test_special_characters_allowed(self, valid_event, mock_context):
+ """Test that legitimate special characters are allowed."""
+ valid_event["path"] = "/test/path-with_special.chars"
+ valid_event["queryStringParameters"] = {"param": "value-with_special.chars!@#$%"}
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
+
+ def test_unicode_characters_allowed(self, valid_event, mock_context):
+ """Test that unicode characters are allowed."""
+ valid_event["queryStringParameters"] = {"param": "value with émojis 🎉"}
+
+ @validate_input()
+ def handler(event, context):
+ return {"success": True}
+
+ result = handler(valid_event, mock_context)
+ assert result["success"] is True
diff --git a/test/lambda/test_lambda_decorators.py b/test/lambda/test_lambda_decorators.py
new file mode 100644
index 000000000..0099d8e54
--- /dev/null
+++ b/test/lambda/test_lambda_decorators.py
@@ -0,0 +1,196 @@
+# 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.
+
+"""Unit tests for lambda_decorators module."""
+
+import json
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+from utilities.lambda_decorators import api_wrapper, authorization_wrapper, ctx_context, get_lambda_context
+
+
+class TestApiWrapper:
+ """Test api_wrapper decorator."""
+
+ def test_success_response(self):
+ """Test api_wrapper with successful function execution."""
+
+ @api_wrapper
+ def test_function(event, context):
+ return {"result": "success", "data": "test"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 200
+ body = json.loads(response["body"])
+ assert body["result"] == "success"
+ assert body["data"] == "test"
+ assert "Strict-Transport-Security" in response["headers"]
+
+ def test_exception_handling(self):
+ """Test api_wrapper handles exceptions properly."""
+
+ @api_wrapper
+ def test_function(event, context):
+ raise ValueError("Test error message")
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 500
+ assert "An unexpected error occurred" in response["body"]
+
+ def test_input_validation_null_bytes(self):
+ """Test api_wrapper rejects null bytes in path."""
+
+ @api_wrapper
+ def test_function(event, context):
+ return {"result": "success"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test\x00/path"}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 400
+ body = json.loads(response["body"])
+ assert "Invalid characters" in body["message"]
+
+ def test_input_validation_invalid_method(self):
+ """Test api_wrapper rejects invalid HTTP methods."""
+
+ @api_wrapper
+ def test_function(event, context):
+ return {"result": "success"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"headers": {}, "httpMethod": "TRACE", "path": "/test"}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 405
+ body = json.loads(response["body"])
+ assert "Method Not Allowed" in body["error"]
+
+ def test_context_is_set(self):
+ """Test api_wrapper sets Lambda context."""
+
+ @api_wrapper
+ def test_function(event, context):
+ # Access context from context variable
+ current_context = ctx_context.get()
+ return {"function_name": current_context.function_name}
+
+ mock_context = SimpleNamespace(function_name="test-func-context", aws_request_id="req-456")
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
+
+ response = test_function(event, mock_context)
+
+ assert response["statusCode"] == 200
+ body = json.loads(response["body"])
+ assert body["function_name"] == "test-func-context"
+
+ @patch("utilities.lambda_decorators.logger")
+ def test_request_logging(self, mock_logger):
+ """Test api_wrapper logs requests."""
+
+ @api_wrapper
+ def test_function(event, context):
+ return {"result": "success"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"headers": {}, "httpMethod": "GET", "path": "/test"}
+
+ test_function(event, mock_context)
+
+ # Verify logging was called
+ assert mock_logger.info.called
+
+
+class TestAuthorizationWrapper:
+ """Test authorization_wrapper decorator."""
+
+ def test_passes_through_result(self):
+ """Test authorization_wrapper passes through result unchanged."""
+
+ @authorization_wrapper
+ def test_function(event, context):
+ return {"authorized": True, "principal": "user-123"}
+
+ mock_context = SimpleNamespace(function_name="test-func", aws_request_id="req-123")
+ event = {"authorizationToken": "Bearer token"}
+
+ result = test_function(event, mock_context)
+
+ assert result == {"authorized": True, "principal": "user-123"}
+
+ def test_context_is_set(self):
+ """Test authorization_wrapper sets Lambda context."""
+
+ @authorization_wrapper
+ def test_function(event, context):
+ current_context = ctx_context.get()
+ return {"function_name": current_context.function_name}
+
+ mock_context = SimpleNamespace(function_name="authorizer-func-test", aws_request_id="req-789")
+ event = {"authorizationToken": "Bearer token"}
+
+ result = test_function(event, mock_context)
+
+ assert result["function_name"] == "authorizer-func-test"
+
+ def test_exception_propagates(self):
+ """Test authorization_wrapper allows exceptions to propagate."""
+
+ @authorization_wrapper
+ def test_function(event, context):
+ raise ValueError("Authorization failed")
+
+ mock_context = SimpleNamespace(function_name="authorizer-func", aws_request_id="req-456")
+ event = {"authorizationToken": "Bearer token"}
+
+ with pytest.raises(ValueError, match="Authorization failed"):
+ test_function(event, mock_context)
+
+
+class TestGetLambdaContext:
+ """Test get_lambda_context function."""
+
+ def test_get_context_when_set(self):
+ """Test get_lambda_context returns context when set."""
+
+ mock_context = SimpleNamespace(function_name="get-context-test", aws_request_id="req-999")
+ ctx_context.set(mock_context)
+
+ context = get_lambda_context()
+
+ assert context.function_name == "get-context-test"
+ assert context.aws_request_id == "req-999"
+
+ def test_get_context_when_not_set(self):
+ """Test get_lambda_context raises error when context not set."""
+ # Create a new context var to ensure it's not set
+ from contextvars import ContextVar
+
+ test_ctx = ContextVar("test_context")
+
+ with pytest.raises(LookupError):
+ test_ctx.get()
diff --git a/test/lambda/test_mcp_server_lambda.py b/test/lambda/test_mcp_server_lambda.py
index c5e8a9bf0..aae9756d2 100644
--- a/test/lambda/test_mcp_server_lambda.py
+++ b/test/lambda/test_mcp_server_lambda.py
@@ -133,8 +133,8 @@ def wrapper(event, context, *args, **kwargs):
get,
get_hosted_mcp_server,
get_mcp_server_id,
- list,
list_hosted_mcp_servers,
+ list_mcp_servers,
update,
update_hosted_mcp_server,
)
@@ -566,7 +566,7 @@ def test_list_mcp_servers_regular_user(mcp_servers_table, sample_mcp_server, lam
event = {"requestContext": {"authorizer": {"claims": {"username": "test-user"}}}}
- response = list(event, lambda_context)
+ response = list_mcp_servers(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert "Items" in body
@@ -580,7 +580,7 @@ def test_list_mcp_servers_admin(mcp_servers_table, sample_mcp_server, lambda_con
set_auth_user(mock_auth, "admin-user", [], True)
- response = list(event, lambda_context)
+ response = list_mcp_servers(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert "Items" in body
diff --git a/test/lambda/test_model_api_key_cleanup.py b/test/lambda/test_model_api_key_cleanup.py
index d4abc5b9f..158de966f 100644
--- a/test/lambda/test_model_api_key_cleanup.py
+++ b/test/lambda/test_model_api_key_cleanup.py
@@ -71,7 +71,8 @@ def test_get_all_dynamodb_models_no_prefix(monkeypatch):
assert result == []
-def test_get_database_connection_success(setup_env):
+def test_get_database_connection_password_auth(setup_env):
+ """Test database connection using password authentication."""
with patch("boto3.client") as mock_client:
mock_ssm = MagicMock()
mock_secrets = MagicMock()
@@ -106,6 +107,42 @@ def client_factory(service, **kwargs):
assert conn is not None
+def test_get_database_connection_iam_auth(setup_env):
+ """Test database connection using IAM authentication."""
+ with patch("boto3.client") as mock_client:
+ mock_ssm = MagicMock()
+
+ def client_factory(service, **kwargs):
+ return mock_ssm
+
+ mock_client.side_effect = client_factory
+ mock_ssm.get_parameter.return_value = {
+ "Parameter": {
+ "Value": json.dumps(
+ {
+ "dbHost": "localhost",
+ "dbPort": 5432,
+ "dbName": "test",
+ }
+ )
+ }
+ }
+
+ with patch("psycopg2.connect") as mock_connect:
+ mock_connect.return_value = MagicMock()
+
+ with patch("models.model_api_key_cleanup.get_lambda_role_name") as mock_role:
+ mock_role.return_value = "test-role"
+
+ with patch("models.model_api_key_cleanup.generate_auth_token") as mock_token:
+ mock_token.return_value = "test-token"
+
+ from models.model_api_key_cleanup import get_database_connection
+
+ conn = get_database_connection()
+ assert conn is not None
+
+
def test_lambda_handler_missing_env_var(monkeypatch):
from models.model_api_key_cleanup import lambda_handler
diff --git a/test/lambda/test_models_lambda.py b/test/lambda/test_models_lambda.py
index 16010e8ed..48b37fa5e 100644
--- a/test/lambda/test_models_lambda.py
+++ b/test/lambda/test_models_lambda.py
@@ -33,6 +33,7 @@
os.environ["CREATE_SFN_ARN"] = "arn:aws:states:us-east-1:123456789012:stateMachine:CreateModelStateMachine"
os.environ["DELETE_SFN_ARN"] = "arn:aws:states:us-east-1:123456789012:stateMachine:DeleteModelStateMachine"
os.environ["UPDATE_SFN_ARN"] = "arn:aws:states:us-east-1:123456789012:stateMachine:UpdateModelStateMachine"
+os.environ["ADMIN_GROUP"] = "admin-group"
import boto3
import pytest
@@ -78,7 +79,6 @@
model_not_found_handler,
update_model,
user_error_handler,
- validation_exception_handler,
)
from moto import mock_aws
@@ -715,8 +715,6 @@ def test_update_model_validation(
@pytest.mark.asyncio
async def test_exception_handlers():
"""Test exception handlers."""
- from fastapi.encoders import jsonable_encoder
- from fastapi.exceptions import RequestValidationError
# Setup mock request
request = MagicMock()
@@ -727,14 +725,9 @@ async def test_exception_handlers():
assert response.status_code == 404
assert json.loads(response.body)["detail"] == "Model not found"
- # Test RequestValidationError handler
- mock_errors = [{"loc": ["body", "modelId"], "msg": "field required", "type": "value_error.missing"}]
- exc = MagicMock(spec=RequestValidationError)
- exc.errors.return_value = mock_errors
- response = await validation_exception_handler(request, exc)
- assert response.status_code == 422
- assert json.loads(response.body)["detail"] == jsonable_encoder(mock_errors)
- assert json.loads(response.body)["type"] == "RequestValidationError"
+ # Note: RequestValidationError handler is now tested through the FastAPI app
+ # It's defined in the fastapi_factory and automatically registered
+ # Test it through integration tests instead of unit tests
# Test user error handler with ModelAlreadyExistsError
exc = ModelAlreadyExistsError("Model already exists")
@@ -750,10 +743,15 @@ async def test_exception_handlers():
@pytest.mark.asyncio
-async def test_fastapi_endpoints(sample_model, model_table, mock_autoscaling_client, mock_stepfunctions_client):
+async def test_fastapi_endpoints(
+ sample_model, model_table, mock_autoscaling_client, mock_stepfunctions_client, mock_auth
+):
"""Test FastAPI endpoints."""
from fastapi.testclient import TestClient
+ # Set admin access for this test
+ mock_auth.set_user("admin-user", ["admin-group"], is_admin=True)
+
# Create test client
client = TestClient(app)
@@ -764,11 +762,7 @@ async def test_fastapi_endpoints(sample_model, model_table, mock_autoscaling_cli
"models.lambda_functions.UpdateModelHandler"
) as mock_update_handler, patch(
"models.lambda_functions.DeleteModelHandler"
- ) as mock_delete_handler, patch(
- "models.lambda_functions.get_admin_status_and_groups"
- ) as mock_get_admin_status:
- # Mock admin status - return admin=True for all operations in this test
- mock_get_admin_status.return_value = (True, [])
+ ) as mock_delete_handler:
# Setup handler mocks
create_handler_instance = MagicMock()
@@ -930,7 +924,7 @@ def non_admin_event():
@pytest.mark.asyncio
-async def test_get_admin_status_and_groups():
+async def test_get_admin_status_and_groups(mock_auth):
"""Test the get_admin_status_and_groups helper function."""
# Test with admin event
@@ -946,15 +940,12 @@ async def test_get_admin_status_and_groups():
mock_request = MagicMock(spec=Request)
mock_request.scope = {"aws.event": admin_event}
- with patch("models.lambda_functions.is_admin") as mock_is_admin, patch(
- "models.lambda_functions.get_groups"
- ) as mock_get_groups:
- mock_is_admin.return_value = True
- mock_get_groups.return_value = ["admin-group"]
+ # Set admin user via mock_auth fixture
+ mock_auth.set_user("admin-user", ["admin-group"], is_admin=True)
- admin_status, user_groups = get_admin_status_and_groups(mock_request)
- assert admin_status is True
- assert user_groups == ["admin-group"]
+ admin_status, user_groups = get_admin_status_and_groups(mock_request)
+ assert admin_status is True
+ assert user_groups == ["admin-group"]
# Test with non-admin event
non_admin_event = {
@@ -968,15 +959,12 @@ async def test_get_admin_status_and_groups():
mock_request.scope = {"aws.event": non_admin_event}
- with patch("models.lambda_functions.is_admin") as mock_is_admin, patch(
- "models.lambda_functions.get_groups"
- ) as mock_get_groups:
- mock_is_admin.return_value = False
- mock_get_groups.return_value = ["user-group"]
+ # Set non-admin user via mock_auth fixture
+ mock_auth.set_user("regular-user", ["user-group"], is_admin=False)
- admin_status, user_groups = get_admin_status_and_groups(mock_request)
- assert admin_status is False
- assert user_groups == ["user-group"]
+ admin_status, user_groups = get_admin_status_and_groups(mock_request)
+ assert admin_status is False
+ assert user_groups == ["user-group"]
# Test with no event in scope
mock_request.scope = {}
@@ -999,9 +987,7 @@ async def test_create_model_admin_required(
modelId="test-model", modelName="test-model", modelType=ModelType.TEXTGEN, streaming=True
)
- with patch("models.lambda_functions.is_admin") as mock_is_admin, patch(
- "models.lambda_functions.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:
mock_is_admin.return_value = False
mock_get_groups.return_value = []
@@ -1023,9 +1009,7 @@ async def test_update_model_admin_required(
update_request = UpdateModelRequest(streaming=False)
- with patch("models.lambda_functions.is_admin") as mock_is_admin, patch(
- "models.lambda_functions.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:
mock_is_admin.return_value = False
mock_get_groups.return_value = []
@@ -1045,9 +1029,7 @@ async def test_delete_model_admin_required(
mock_request = MagicMock(spec=Request)
mock_request.scope = {"aws.event": non_admin_event}
- with patch("models.lambda_functions.is_admin") as mock_is_admin, patch(
- "models.lambda_functions.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:
mock_is_admin.return_value = False
mock_get_groups.return_value = []
@@ -1059,22 +1041,19 @@ async def test_delete_model_admin_required(
@pytest.mark.asyncio
async def test_create_update_delete_admin_allowed(
- sample_model, model_table, mock_autoscaling_client, mock_stepfunctions_client, admin_event
+ sample_model, model_table, mock_autoscaling_client, mock_stepfunctions_client, admin_event, mock_auth
):
"""Test that admin users can successfully create, update, and delete models."""
+ # Set admin access via mock_auth fixture
+ mock_auth.set_user("admin-user", ["admin-group"], is_admin=True)
+
mock_request = MagicMock(spec=Request)
mock_request.scope = {"aws.event": admin_event}
- with patch("models.lambda_functions.is_admin") as mock_is_admin, patch(
- "models.lambda_functions.get_groups"
- ) as mock_get_groups, patch("models.lambda_functions.CreateModelHandler") as mock_create_handler, patch(
+ with patch("models.lambda_functions.CreateModelHandler") as mock_create_handler, patch(
"models.lambda_functions.UpdateModelHandler"
- ) as mock_update_handler, patch(
- "models.lambda_functions.DeleteModelHandler"
- ) as mock_delete_handler:
- mock_is_admin.return_value = True
- mock_get_groups.return_value = ["admin-group"]
+ ) as mock_update_handler, patch("models.lambda_functions.DeleteModelHandler") as mock_delete_handler:
# Mock create handler
create_handler_instance = MagicMock()
diff --git a/test/lambda/test_prompt_templates_lambda.py b/test/lambda/test_prompt_templates_lambda.py
index 49f523c29..fcfcc3c77 100644
--- a/test/lambda/test_prompt_templates_lambda.py
+++ b/test/lambda/test_prompt_templates_lambda.py
@@ -121,7 +121,7 @@ def wrapper(event, context):
patch("utilities.common_functions.api_wrapper", mock_api_wrapper).start() # Patch the API wrapper
# Now import the lambda functions
-from prompt_templates.lambda_functions import _get_prompt_templates, create, delete, get, list, update
+from prompt_templates.lambda_functions import _get_prompt_templates, create, delete, get, list_prompt, update
@pytest.fixture
@@ -338,7 +338,7 @@ def test_list_prompt_templates(prompt_templates_table, lambda_context, mock_is_a
mock_common.get_username.return_value = "different-user"
mock_common.get_groups.return_value = ["different-group"]
- response = list(list_event, lambda_context)
+ response = list_prompt(list_event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert len(body["Items"]) == 1
@@ -378,7 +378,7 @@ def test_list_prompt_templates_admin(prompt_templates_table, lambda_context, moc
# Set admin to True for this test to increase coverage
mock_is_admin.return_value = True
- response = list(list_event, lambda_context)
+ response = list_prompt(list_event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert len(body["Items"]) == 1
@@ -413,7 +413,7 @@ def test_list_prompt_templates_for_user(prompt_templates_table, lambda_context):
"requestContext": {"authorizer": {"claims": {"username": "test-user"}}},
}
- response = list(list_event, lambda_context)
+ response = list_prompt(list_event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert len(body["Items"]) == 1
diff --git a/test/lambda/test_repository_lambda.py b/test/lambda/test_repository_lambda.py
index 8b6b26dc9..2652bb42c 100644
--- a/test/lambda/test_repository_lambda.py
+++ b/test/lambda/test_repository_lambda.py
@@ -1112,16 +1112,20 @@ def test_pipeline_embeddings_embed_documents():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
+ # Mock the session and its post method
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+
# Mock successful API response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}, {"embedding": [0.4, 0.5, 0.6]}]}
- mock_post.return_value = mock_response
+ mock_session.post.return_value = mock_response
embeddings = RagEmbeddings("test-model", "test-token")
result = embeddings.embed_documents(["text1", "text2"])
@@ -1155,11 +1159,14 @@ def test_pipeline_embeddings_embed_documents_api_error():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
- mock_post.side_effect = Exception("API request failed")
+
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+ mock_session.post.side_effect = Exception("API request failed")
embeddings = RagEmbeddings("test-model", "test-token")
@@ -1173,11 +1180,14 @@ def test_pipeline_embeddings_embed_documents_timeout():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
- mock_post.side_effect = requests.Timeout("Request timed out")
+
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+ mock_session.post.side_effect = requests.Timeout("Request timed out")
embeddings = RagEmbeddings("test-model", "test-token")
@@ -1191,18 +1201,21 @@ def test_pipeline_embeddings_embed_documents_different_formats():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+
embeddings = RagEmbeddings("test-model", "test-token")
# Test OpenAI format
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"embedding": [0.1, 0.2]}, {"embedding": [0.3, 0.4]}]}
- mock_post.return_value = mock_response
+ mock_session.post.return_value = mock_response
result = embeddings.embed_documents(["text1", "text2"])
assert len(result) == 2
@@ -1218,15 +1231,18 @@ def test_pipeline_embeddings_embed_documents_no_embeddings():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "No embeddings"}
- mock_post.return_value = mock_response
+ mock_session.post.return_value = mock_response
embeddings = RagEmbeddings("test-model", "test-token")
@@ -1240,15 +1256,18 @@ def test_pipeline_embeddings_embed_documents_mismatch():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"embedding": [0.1, 0.2]}]} # Only 1 embedding for 2 texts
- mock_post.return_value = mock_response
+ mock_session.post.return_value = mock_response
embeddings = RagEmbeddings("test-model", "test-token")
@@ -1262,15 +1281,18 @@ def test_pipeline_embeddings_embed_query():
with patch("repository.embeddings.get_rest_api_container_endpoint") as mock_endpoint, patch(
"repository.embeddings.get_cert_path"
- ) as mock_cert, patch("repository.embeddings.requests.post") as mock_post:
+ ) as mock_cert, patch("repository.embeddings._get_http_session") as mock_get_session:
mock_endpoint.return_value = "https://api.example.com"
mock_cert.return_value = "/path/to/cert"
+ mock_session = MagicMock()
+ mock_get_session.return_value = mock_session
+
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"embedding": [0.1, 0.2, 0.3]}]}
- mock_post.return_value = mock_response
+ mock_session.post.return_value = mock_response
embeddings = RagEmbeddings("test-model", "test-token")
result = embeddings.embed_query("test query")
diff --git a/test/lambda/test_response_builder.py b/test/lambda/test_response_builder.py
new file mode 100644
index 000000000..a5bbbe2fe
--- /dev/null
+++ b/test/lambda/test_response_builder.py
@@ -0,0 +1,187 @@
+# 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.
+
+"""Unit tests for response_builder module."""
+
+import json
+from datetime import datetime
+from decimal import Decimal
+
+from utilities.response_builder import DecimalEncoder, generate_exception_response, generate_html_response
+
+
+class TestDecimalEncoder:
+ """Test DecimalEncoder class."""
+
+ def test_encode_decimal(self):
+ """Test DecimalEncoder handles Decimal objects."""
+ obj = {"price": Decimal("123.45"), "quantity": Decimal("10")}
+ result = json.dumps(obj, cls=DecimalEncoder)
+ parsed = json.loads(result)
+
+ assert parsed["price"] == 123.45
+ assert parsed["quantity"] == 10.0
+
+ def test_encode_datetime(self):
+ """Test DecimalEncoder handles datetime objects."""
+ dt = datetime(2025, 1, 15, 10, 30, 45)
+ obj = {"timestamp": dt}
+ result = json.dumps(obj, cls=DecimalEncoder)
+
+ assert "2025-01-15T10:30:45" in result
+
+ def test_encode_mixed_types(self):
+ """Test DecimalEncoder handles mixed types."""
+ obj = {
+ "decimal_value": Decimal("99.99"),
+ "datetime_value": datetime(2025, 1, 1),
+ "string_value": "test",
+ "int_value": 42,
+ }
+ result = json.dumps(obj, cls=DecimalEncoder)
+ parsed = json.loads(result)
+
+ assert parsed["decimal_value"] == 99.99
+ assert "2025-01-01" in result
+ assert parsed["string_value"] == "test"
+ assert parsed["int_value"] == 42
+
+
+class TestGenerateHtmlResponse:
+ """Test generate_html_response function."""
+
+ def test_success_response(self):
+ """Test generate_html_response creates proper success response."""
+ response = generate_html_response(200, {"message": "success", "data": {"id": "123"}})
+
+ assert response["statusCode"] == 200
+ assert response["headers"]["Content-Type"] == "application/json"
+ assert response["headers"]["Access-Control-Allow-Origin"] == "*"
+
+ body = json.loads(response["body"])
+ assert body["message"] == "success"
+ assert body["data"]["id"] == "123"
+
+ def test_security_headers(self):
+ """Test generate_html_response includes security headers."""
+ response = generate_html_response(200, {})
+
+ headers = response["headers"]
+ assert headers["Strict-Transport-Security"] == "max-age:47304000; includeSubDomains"
+ assert headers["X-Content-Type-Options"] == "nosniff"
+ assert headers["X-Frame-Options"] == "DENY"
+ assert headers["Cache-Control"] == "no-store, no-cache"
+ assert headers["Pragma"] == "no-cache"
+
+ def test_error_response(self):
+ """Test generate_html_response creates proper error response."""
+ response = generate_html_response(404, {"error": "Not Found", "message": "Resource not found"})
+
+ assert response["statusCode"] == 404
+ body = json.loads(response["body"])
+ assert body["error"] == "Not Found"
+ assert body["message"] == "Resource not found"
+
+ def test_with_decimal_values(self):
+ """Test generate_html_response handles Decimal values."""
+ response = generate_html_response(200, {"price": Decimal("19.99")})
+
+ assert response["statusCode"] == 200
+ body = json.loads(response["body"])
+ assert body["price"] == 19.99
+
+
+class TestGenerateExceptionResponse:
+ """Test generate_exception_response function."""
+
+ def test_validation_error(self):
+ """Test generate_exception_response with ValidationError."""
+
+ class ValidationError(Exception):
+ pass
+
+ error = ValidationError("Invalid input format")
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 400
+ assert "Invalid input format" in response["body"]
+
+ def test_aws_sdk_error(self):
+ """Test generate_exception_response with AWS SDK error."""
+ error = Exception("DynamoDB error")
+ error.response = {"ResponseMetadata": {"HTTPStatusCode": 403}} # type: ignore
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 403
+ assert "DynamoDB error" in response["body"]
+
+ def test_custom_http_status_code(self):
+ """Test generate_exception_response with http_status_code attribute."""
+ error = Exception("Custom error")
+ error.http_status_code = 404 # type: ignore
+ error.message = "Resource not found" # type: ignore
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 404
+ assert "Resource not found" in response["body"]
+
+ def test_custom_status_code(self):
+ """Test generate_exception_response with status_code attribute."""
+ error = Exception("Another error")
+ error.status_code = 409 # type: ignore
+ error.message = "Conflict detected" # type: ignore
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 409
+ assert "Conflict detected" in response["body"]
+
+ def test_missing_request_context(self):
+ """Test generate_exception_response with missing requestContext."""
+ error = KeyError("requestContext")
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 400
+ assert "requestContext" in response["body"]
+
+ def test_missing_path_parameters(self):
+ """Test generate_exception_response with missing pathParameters."""
+ error = KeyError("pathParameters")
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 400
+ assert "pathParameters" in response["body"]
+
+ def test_generic_exception(self):
+ """Test generate_exception_response with generic exception."""
+ error = Exception("Something went wrong")
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 500
+ assert "An unexpected error occurred" in response["body"]
+
+ def test_exception_without_response_metadata(self):
+ """Test generate_exception_response with AWS error missing metadata."""
+ error = Exception("AWS error")
+ error.response = {} # type: ignore
+
+ response = generate_exception_response(error)
+
+ assert response["statusCode"] == 400
+ assert "AWS error" in response["body"]
diff --git a/test/lambda/test_session_lambda.py b/test/lambda/test_session_lambda.py
index 5444eec65..4fcc8766a 100644
--- a/test/lambda/test_session_lambda.py
+++ b/test/lambda/test_session_lambda.py
@@ -47,33 +47,29 @@
retry_config = Config(retries=dict(max_attempts=3), defaults_mode="standard")
-def mock_api_wrapper(func):
- @functools.wraps(func)
- def wrapper(*args, **kwargs):
- try:
- result = func(*args, **kwargs)
- if isinstance(result, dict) and "statusCode" in result:
- return result
- return {
- "statusCode": 200,
- "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"},
- "body": json.dumps(result, default=str),
- }
- except ValueError as e:
- return {
- "statusCode": 400,
- "headers": {"Content-Type": "application/json", "Access-Control-Allow-Origin": "*"},
- "body": json.dumps({"error": str(e)}),
- }
- 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
+def mock_api_wrapper(_func=None, **kwargs):
+ """Mock api_wrapper that accepts any kwargs and uses real response builder."""
+ from utilities.response_builder import generate_exception_response, generate_html_response
+
+ def decorator(func):
+ @functools.wraps(func)
+ def wrapper(*args, **kw):
+ try:
+ result = func(*args, **kw)
+ if isinstance(result, dict) and "statusCode" in result:
+ return result
+ return generate_html_response(200, result)
+ except ValueError as e:
+ return generate_exception_response(e)
+ except Exception as e:
+ logging.error(f"Error in {func.__name__}: {str(e)}")
+ return generate_exception_response(e)
+
+ return wrapper
+
+ if _func is not None:
+ return decorator(_func)
+ return decorator
@pytest.fixture(scope="function")
@@ -169,8 +165,17 @@ def config_table(dynamodb):
patch("utilities.common_functions.retry_config", retry_config).start()
patch("utilities.common_functions.api_wrapper", mock_api_wrapper).start()
+# Import Pydantic models for type-safe testing
+from models.domain_objects import DeleteResponse, SuccessResponse
+
# Now import the lambda functions
from session.lambda_functions import delete_session, delete_user_sessions, get_session, list_sessions, put_session
+from session.models import (
+ PutSessionRequest,
+ RenameSessionRequest,
+ Session,
+ SessionSummary,
+)
@pytest.fixture
@@ -213,8 +218,11 @@ def test_list_sessions(dynamodb_table, sample_session, lambda_context):
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert len(body) == 2
- assert any(s["sessionId"] == "test-session" for s in body)
- assert any(s["sessionId"] == "test-session-2" for s in body)
+ # Validate response items match SessionSummary structure
+ session_summaries = [SessionSummary.model_validate(s) for s in body]
+ session_ids = [s.sessionId for s in session_summaries]
+ assert "test-session" in session_ids
+ assert "test-session-2" in session_ids
def test_get_session(dynamodb_table, sample_session, lambda_context):
@@ -228,8 +236,10 @@ def test_get_session(dynamodb_table, sample_session, lambda_context):
response = get_session(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
- assert body["sessionId"] == sample_session["sessionId"]
- assert body["userId"] == "test-user"
+ # Validate response matches Session model structure
+ session = Session.model_validate(body)
+ assert session.sessionId == sample_session["sessionId"]
+ assert session.userId == "test-user"
def test_missing_path_parameters(lambda_context):
@@ -270,8 +280,8 @@ def test_missing_username(lambda_context):
def mock_s3_operations():
"""Mock S3 operations to avoid errors."""
with patch("session.lambda_functions._delete_user_session") as mock_delete:
- # Make the mocked function return True by default
- mock_delete.return_value = {"deleted": True}
+ # Make the mocked function return DeleteResponse by default
+ mock_delete.return_value = DeleteResponse(deleted=True)
yield mock_delete
@@ -287,7 +297,9 @@ def test_delete_session(dynamodb_table, sample_session, lambda_context, mock_s3_
response = delete_session(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
- assert body["deleted"] is True
+ # Validate response matches DeleteResponse model
+ delete_response = DeleteResponse.model_validate(body)
+ assert delete_response.deleted is True
# Verify the mock was called with correct parameters
mock_s3_operations.assert_called_once_with("test-session", "test-user")
@@ -307,7 +319,9 @@ def test_delete_user_sessions(dynamodb_table, sample_session, lambda_context, mo
response = delete_user_sessions(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
- assert body["deleted"] is True
+ # Validate response matches DeleteResponse model
+ delete_response = DeleteResponse.model_validate(body)
+ assert delete_response.deleted is True
def test_delete_session_not_found(dynamodb_table, lambda_context, mock_s3_operations):
@@ -325,7 +339,9 @@ def test_delete_session_not_found(dynamodb_table, lambda_context, mock_s3_operat
response = delete_session(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
- assert body["deleted"] is True
+ # Validate response matches DeleteResponse model
+ delete_response = DeleteResponse.model_validate(body)
+ assert delete_response.deleted is True
# Verify the mock was called with correct parameters
mock_s3_operations.assert_called_once_with("non-existent-session", "test-user")
@@ -336,13 +352,13 @@ def test_delete_session_not_found(dynamodb_table, lambda_context, mock_s3_operat
def test_put_session(dynamodb_table, config_table, sample_session, lambda_context):
"""Test putting a session."""
+ # Create request using PutSessionRequest model
+ put_request = PutSessionRequest(messages=sample_session["history"], configuration=sample_session["configuration"])
event = {
"requestContext": {"authorizer": {"claims": {"username": "test-user"}}},
"pathParameters": {"sessionId": "test-session"},
- "body": json.dumps(
- {"messages": sample_session["history"], "configuration": sample_session["configuration"]}, default=str
- ),
+ "body": put_request.model_dump_json(),
}
response = put_session(event, lambda_context)
@@ -387,7 +403,8 @@ def test_put_session_missing_required_fields(dynamodb_table, lambda_context):
response = put_session(event, lambda_context)
assert response["statusCode"] == 400
body = json.loads(response["body"])
- assert "Missing required fields" in body["error"]
+ # Pydantic validation error for missing 'messages' field
+ assert "error" in body
def test_list_sessions_empty(dynamodb_table, lambda_context):
@@ -397,6 +414,7 @@ def test_list_sessions_empty(dynamodb_table, lambda_context):
response = list_sessions(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
+ assert isinstance(body, list)
assert len(body) == 0
@@ -662,7 +680,9 @@ def test_delete_user_session_resource_not_found(mock_s3_resource, mock_table):
)
result = _delete_user_session("test-session", "test-user")
- assert result == {"deleted": False}
+ # Result should be DeleteResponse model
+ assert isinstance(result, DeleteResponse)
+ assert result.deleted is False
@patch("session.lambda_functions.table")
@@ -676,7 +696,9 @@ def test_delete_user_session_general_client_error(mock_s3_resource, mock_table):
)
result = _delete_user_session("test-session", "test-user")
- assert result == {"deleted": False}
+ # Result should be DeleteResponse model
+ assert isinstance(result, DeleteResponse)
+ assert result.deleted is False
@patch("session.lambda_functions.s3_client")
@@ -852,7 +874,9 @@ def test_get_session_encrypted_success(mock_decrypt, dynamodb_table, sample_sess
response = get_session(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
- assert body["sessionId"] == "test-session"
+ # Validate response matches Session model
+ session = Session.model_validate(body)
+ assert session.sessionId == "test-session"
mock_decrypt.assert_called_once()
@@ -947,7 +971,8 @@ def test_attach_image_to_session_missing_message(lambda_context):
response = attach_image_to_session(event, lambda_context)
assert response["statusCode"] == 400
body = json.loads(response["body"])
- assert "Missing required fields" in body["error"]
+ # Pydantic validation error for missing 'message' field
+ assert "error" in body
def test_attach_image_to_session_s3_upload_error(lambda_context):
@@ -987,17 +1012,22 @@ def test_rename_session_success(dynamodb_table, lambda_context):
session = {"sessionId": "test-session", "userId": "test-user", "name": "Old Name"}
dynamodb_table.put_item(Item=session)
+ # Create request using RenameSessionRequest model
+ rename_request = RenameSessionRequest(name="New Name")
+
event = {
"requestContext": {"authorizer": {"claims": {"username": "test-user"}}},
"pathParameters": {"sessionId": "test-session"},
- "body": json.dumps({"name": "New Name"}),
+ "body": rename_request.model_dump_json(),
}
with patch("session.lambda_functions.table", dynamodb_table):
response = rename_session(event, lambda_context)
assert response["statusCode"] == 200
body = json.loads(response["body"])
- assert "Session name updated successfully" in body["message"]
+ # Validate response matches SuccessResponse model
+ success_response = SuccessResponse.model_validate(body)
+ assert "Session name updated successfully" in success_response.message
def test_rename_session_invalid_json(lambda_context):
@@ -1025,7 +1055,8 @@ def test_rename_session_missing_name(lambda_context):
response = rename_session(event, lambda_context)
assert response["statusCode"] == 400
body = json.loads(response["body"])
- assert "Missing required field: name" in body["error"]
+ # Pydantic validation error for missing 'name' field
+ assert "error" in body
# Put Session Edge Cases Tests
@@ -1189,3 +1220,568 @@ def test_put_session_model_config_update(
response = put_session(event, lambda_context)
assert response["statusCode"] == 200
mock_update_config.assert_called_once()
+
+
+# Import additional functions for testing
+from session.lambda_functions import (
+ _extract_video_s3_keys,
+ _generate_presigned_video_url,
+ _map_session,
+ _process_video,
+)
+
+
+# Video S3 Key Extraction Tests
+def test_extract_video_s3_keys_no_history():
+ """Test _extract_video_s3_keys with no history."""
+ session = {}
+ result = _extract_video_s3_keys(session)
+ assert result == []
+
+
+def test_extract_video_s3_keys_empty_history():
+ """Test _extract_video_s3_keys with empty history."""
+ session = {"history": []}
+ result = _extract_video_s3_keys(session)
+ assert result == []
+
+
+def test_extract_video_s3_keys_no_videos():
+ """Test _extract_video_s3_keys with history but no videos."""
+ session = {
+ "history": [
+ {"type": "human", "content": "Hello"},
+ {"type": "assistant", "content": "Hi there!"},
+ ]
+ }
+ result = _extract_video_s3_keys(session)
+ assert result == []
+
+
+def test_extract_video_s3_keys_with_videos():
+ """Test _extract_video_s3_keys with videos in history."""
+ session = {
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/video.mp4", "s3_key": "videos/video1.mp4"},
+ }
+ ],
+ }
+ ]
+ }
+ result = _extract_video_s3_keys(session)
+ assert result == ["videos/video1.mp4"]
+
+
+def test_extract_video_s3_keys_multiple_videos():
+ """Test _extract_video_s3_keys with multiple videos."""
+ session = {
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/video1.mp4", "s3_key": "videos/v1.mp4"},
+ },
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/video2.mp4", "s3_key": "videos/v2.mp4"},
+ },
+ ],
+ },
+ {
+ "type": "assistant",
+ "content": [
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/video3.mp4", "s3_key": "videos/v3.mp4"},
+ },
+ ],
+ },
+ ]
+ }
+ result = _extract_video_s3_keys(session)
+ assert result == ["videos/v1.mp4", "videos/v2.mp4", "videos/v3.mp4"]
+
+
+def test_extract_video_s3_keys_mixed_content():
+ """Test _extract_video_s3_keys with mixed image and video content."""
+ session = {
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": "https://example.com/image.png", "s3_key": "images/img.png"},
+ },
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/video.mp4", "s3_key": "videos/vid.mp4"},
+ },
+ {"type": "text", "text": "Here is your content"},
+ ],
+ }
+ ]
+ }
+ result = _extract_video_s3_keys(session)
+ assert result == ["videos/vid.mp4"]
+
+
+def test_extract_video_s3_keys_no_s3_key():
+ """Test _extract_video_s3_keys with video missing s3_key."""
+ session = {
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}, # No s3_key
+ ],
+ }
+ ]
+ }
+ result = _extract_video_s3_keys(session)
+ assert result == []
+
+
+def test_extract_video_s3_keys_string_content():
+ """Test _extract_video_s3_keys with string content (not list)."""
+ session = {"history": [{"type": "assistant", "content": "This is a text response"}]}
+ result = _extract_video_s3_keys(session)
+ assert result == []
+
+
+# Video URL Generation Tests
+@patch("session.lambda_functions.s3_client")
+def test_generate_presigned_video_url_success(mock_s3_client):
+ """Test _generate_presigned_video_url with success."""
+ mock_s3_client.generate_presigned_url.return_value = "https://presigned-video-url.com"
+
+ result = _generate_presigned_video_url("videos/test-video.mp4")
+ assert result == "https://presigned-video-url.com"
+
+ mock_s3_client.generate_presigned_url.assert_called_once_with(
+ "get_object",
+ Params={
+ "Bucket": "bucket",
+ "Key": "videos/test-video.mp4",
+ "ResponseContentType": "video/mp4",
+ "ResponseCacheControl": "no-cache",
+ "ResponseContentDisposition": "inline",
+ },
+ )
+
+
+# Video Processing Tests
+def test_process_video_success():
+ """Test _process_video with success."""
+ msg = {"video_url": {"s3_key": "videos/test.mp4"}}
+ key = "videos/test.mp4"
+
+ with patch("session.lambda_functions._generate_presigned_video_url") as mock_generate:
+ mock_generate.return_value = "https://presigned-video-url.com"
+
+ _process_video((msg, key))
+
+ assert msg["video_url"]["url"] == "https://presigned-video-url.com"
+ mock_generate.assert_called_once_with("videos/test.mp4")
+
+
+def test_process_video_exception():
+ """Test _process_video with exception."""
+ msg = {"video_url": {"s3_key": "videos/test.mp4"}}
+ key = "videos/test.mp4"
+
+ with patch("session.lambda_functions._generate_presigned_video_url") as mock_generate:
+ mock_generate.side_effect = Exception("S3 error")
+
+ # Should not raise exception, just print error
+ _process_video((msg, key))
+
+ # URL should not be set
+ assert "url" not in msg["video_url"]
+
+
+# Map Session Tests
+def test_map_session_complete_data():
+ """Test _map_session with complete session data."""
+ session = {
+ "sessionId": "test-session-123",
+ "name": "Test Session",
+ "history": [{"type": "human", "content": "Hello"}],
+ "startTime": "2024-01-01T00:00:00",
+ "createTime": "2024-01-01T00:00:00",
+ "lastUpdated": "2024-01-02T00:00:00",
+ "is_encrypted": False,
+ }
+
+ result = _map_session(session, "test-user")
+
+ # Result is a SessionSummary model - use property access
+ assert result.sessionId == "test-session-123"
+ assert result.name == "Test Session"
+ assert result.firstHumanMessage == "Hello"
+ assert result.startTime == "2024-01-01T00:00:00"
+ assert result.createTime == "2024-01-01T00:00:00"
+ assert result.lastUpdated == "2024-01-02T00:00:00"
+ assert result.isEncrypted is False
+
+
+def test_map_session_missing_fields():
+ """Test _map_session with missing optional fields."""
+ session = {"sessionId": "test-session", "history": []}
+
+ result = _map_session(session, "test-user")
+
+ # Result is a SessionSummary model - use property access
+ assert result.sessionId == "test-session"
+ assert result.name is None
+ assert result.firstHumanMessage == ""
+ assert result.startTime is None
+ assert result.createTime is None
+ assert result.lastUpdated is None
+ assert result.isEncrypted is False
+
+
+def test_map_session_fallback_to_start_time():
+ """Test _map_session falls back to startTime for lastUpdated."""
+ session = {
+ "sessionId": "test-session",
+ "startTime": "2024-01-01T00:00:00",
+ "history": [],
+ # No lastUpdated field
+ }
+
+ result = _map_session(session, "test-user")
+
+ # Result is a SessionSummary model - use property access
+ assert result.lastUpdated == "2024-01-01T00:00:00"
+
+
+def test_map_session_encrypted():
+ """Test _map_session with encrypted session."""
+ session = {
+ "sessionId": "test-session",
+ "is_encrypted": True,
+ "encrypted_history": "encrypted_data",
+ }
+
+ with patch("session.lambda_functions.decrypt_session_fields") as mock_decrypt:
+ mock_decrypt.return_value = {
+ "sessionId": "test-session",
+ "history": [{"type": "human", "content": "Decrypted message"}],
+ }
+
+ result = _map_session(session, "test-user")
+
+ # Result is a SessionSummary model - use property access
+ assert result.isEncrypted is True
+ assert result.firstHumanMessage == "Decrypted message"
+
+
+# Delete Session with Video Cleanup Tests
+@patch("session.lambda_functions.s3_client")
+@patch("session.lambda_functions.s3_resource")
+@patch("session.lambda_functions.table")
+def test_delete_user_session_with_video_cleanup(mock_table, mock_s3_resource, mock_s3_client):
+ """Test _delete_user_session with successful video cleanup."""
+ # Mock session with videos
+ session_with_videos = {
+ "sessionId": "test-session",
+ "userId": "test-user",
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/v1.mp4", "s3_key": "videos/v1.mp4"},
+ },
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/v2.mp4", "s3_key": "videos/v2.mp4"},
+ },
+ ],
+ }
+ ],
+ }
+
+ mock_table.get_item.return_value = {"Item": session_with_videos}
+ mock_bucket = MagicMock()
+ mock_s3_resource.Bucket.return_value = mock_bucket
+
+ result = _delete_user_session("test-session", "test-user")
+
+ # Result is a DeleteResponse model - use property access
+ assert result.deleted is True
+ mock_table.delete_item.assert_called_once()
+ mock_bucket.objects.filter.assert_called_once_with(Prefix="images/test-session")
+
+ # Verify video deletion calls
+ assert mock_s3_client.delete_object.call_count == 2
+ mock_s3_client.delete_object.assert_any_call(Bucket="bucket", Key="videos/v1.mp4")
+ mock_s3_client.delete_object.assert_any_call(Bucket="bucket", Key="videos/v2.mp4")
+
+
+@patch("session.lambda_functions.decrypt_session_fields")
+@patch("session.lambda_functions.s3_client")
+@patch("session.lambda_functions.s3_resource")
+@patch("session.lambda_functions.table")
+def test_delete_user_session_encrypted_with_videos(mock_table, mock_s3_resource, mock_s3_client, mock_decrypt):
+ """Test _delete_user_session with encrypted session containing videos."""
+ # Mock encrypted session
+ encrypted_session = {
+ "sessionId": "test-session",
+ "userId": "test-user",
+ "is_encrypted": True,
+ "encrypted_history": "encrypted_data",
+ }
+
+ # Mock decrypted session with videos
+ decrypted_session = {
+ "sessionId": "test-session",
+ "userId": "test-user",
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {
+ "type": "video_url",
+ "video_url": {"url": "https://example.com/v1.mp4", "s3_key": "videos/v1.mp4"},
+ },
+ ],
+ }
+ ],
+ }
+
+ mock_table.get_item.return_value = {"Item": encrypted_session}
+ mock_decrypt.return_value = decrypted_session
+ mock_bucket = MagicMock()
+ mock_s3_resource.Bucket.return_value = mock_bucket
+
+ result = _delete_user_session("test-session", "test-user")
+
+ # Result is a DeleteResponse model - use property access
+ assert result.deleted is True
+ mock_decrypt.assert_called_once_with(encrypted_session, "test-user", "test-session")
+ mock_s3_client.delete_object.assert_called_once_with(Bucket="bucket", Key="videos/v1.mp4")
+
+
+@patch("session.lambda_functions.decrypt_session_fields")
+@patch("session.lambda_functions.s3_client")
+@patch("session.lambda_functions.s3_resource")
+@patch("session.lambda_functions.table")
+def test_delete_user_session_encrypted_decryption_fails(mock_table, mock_s3_resource, mock_s3_client, mock_decrypt):
+ """Test _delete_user_session when decryption fails - should still delete session."""
+ from utilities.session_encryption import SessionEncryptionError
+
+ encrypted_session = {
+ "sessionId": "test-session",
+ "userId": "test-user",
+ "is_encrypted": True,
+ "encrypted_history": "encrypted_data",
+ }
+
+ mock_table.get_item.return_value = {"Item": encrypted_session}
+ mock_decrypt.side_effect = SessionEncryptionError("Decryption failed")
+ mock_bucket = MagicMock()
+ mock_s3_resource.Bucket.return_value = mock_bucket
+
+ result = _delete_user_session("test-session", "test-user")
+
+ # Result is a DeleteResponse model - use property access
+ # Should still succeed with deletion, just no video cleanup
+ assert result.deleted is True
+ mock_table.delete_item.assert_called_once()
+ # No video deletion because decryption failed
+ mock_s3_client.delete_object.assert_not_called()
+
+
+@patch("session.lambda_functions.s3_client")
+@patch("session.lambda_functions.s3_resource")
+@patch("session.lambda_functions.table")
+def test_delete_user_session_video_deletion_error(mock_table, mock_s3_resource, mock_s3_client):
+ """Test _delete_user_session when video deletion fails - should continue."""
+ from botocore.exceptions import ClientError
+
+ session_with_videos = {
+ "sessionId": "test-session",
+ "userId": "test-user",
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {"type": "video_url", "video_url": {"s3_key": "videos/v1.mp4"}},
+ {"type": "video_url", "video_url": {"s3_key": "videos/v2.mp4"}},
+ ],
+ }
+ ],
+ }
+
+ mock_table.get_item.return_value = {"Item": session_with_videos}
+ mock_bucket = MagicMock()
+ mock_s3_resource.Bucket.return_value = mock_bucket
+
+ # First video deletion fails, second succeeds
+ mock_s3_client.delete_object.side_effect = [
+ ClientError(error_response={"Error": {"Code": "AccessDenied"}}, operation_name="DeleteObject"),
+ None,
+ ]
+
+ result = _delete_user_session("test-session", "test-user")
+
+ # Result is a DeleteResponse model - use property access
+ # Should still succeed - video deletion errors are logged but don't fail the operation
+ assert result.deleted is True
+ assert mock_s3_client.delete_object.call_count == 2
+
+
+# Get Session with Video Processing Tests
+def test_get_session_with_videos(dynamodb_table, lambda_context):
+ """Test get_session processes video URLs correctly."""
+ session_with_video = {
+ "sessionId": "test-session",
+ "userId": "test-user",
+ "history": [
+ {
+ "type": "assistant",
+ "content": [
+ {"type": "video_url", "video_url": {"s3_key": "videos/test.mp4"}},
+ ],
+ }
+ ],
+ }
+ dynamodb_table.put_item(Item=session_with_video)
+
+ event = {
+ "requestContext": {"authorizer": {"claims": {"username": "test-user"}}},
+ "pathParameters": {"sessionId": "test-session"},
+ }
+
+ with patch("session.lambda_functions._process_video") as mock_process_video:
+ response = get_session(event, lambda_context)
+ assert response["statusCode"] == 200
+ # Video processing should be called
+ mock_process_video.assert_called()
+
+
+# Attach Image Tests
+def test_attach_image_to_session_https_url(lambda_context):
+ """Test attach_image_to_session with already-https URL (should not upload)."""
+ event = {
+ "pathParameters": {"sessionId": "test-session"},
+ "body": json.dumps(
+ {"message": {"type": "image_url", "image_url": {"url": "https://already-uploaded.com/image.png"}}}
+ ),
+ }
+
+ with patch("session.lambda_functions.s3_client") as mock_s3:
+ response = attach_image_to_session(event, lambda_context)
+ assert response["statusCode"] == 200
+ body = response["body"]
+ # Should return the message unchanged, S3 should not be called
+ assert body["image_url"]["url"] == "https://already-uploaded.com/image.png"
+ mock_s3.put_object.assert_not_called()
+
+
+def test_attach_image_to_session_missing_session_id(lambda_context):
+ """Test attach_image_to_session with missing session ID."""
+ mock_common.get_session_id.side_effect = ValueError("Missing sessionId")
+
+ event = {"body": json.dumps({"message": {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}})}
+
+ response = attach_image_to_session(event, lambda_context)
+ assert response["statusCode"] == 400
+ body = json.loads(response["body"])
+ assert "Missing sessionId" in body["error"]
+
+ # Reset the mock
+ mock_common.get_session_id.side_effect = None
+ mock_common.get_session_id.return_value = "test-session"
+
+
+# Put Session API Token Auth Tests
+@patch("session.lambda_functions.get_user_context")
+@patch("session.lambda_functions.sqs_client")
+def test_put_session_api_token_skips_metrics(
+ mock_sqs_client, mock_get_user_context, dynamodb_table, config_table, sample_session, lambda_context
+):
+ """Test put_session with API token auth type skips SQS metrics."""
+ os.environ["USAGE_METRICS_QUEUE_NAME"] = "test-metrics-queue"
+ mock_get_user_context.return_value = ("test-user", False, ["group1"])
+
+ event = {
+ "requestContext": {"authorizer": {"claims": {"username": "test-user"}, "authType": "api_token"}},
+ "pathParameters": {"sessionId": "test-session"},
+ "body": json.dumps({"messages": sample_session["history"], "configuration": sample_session["configuration"]}),
+ }
+
+ with patch("session.lambda_functions.table", dynamodb_table):
+ response = put_session(event, lambda_context)
+ assert response["statusCode"] == 200
+ # SQS should not be called for API token users
+ mock_sqs_client.send_message.assert_not_called()
+
+
+# List Sessions with Encrypted Sessions Tests
+def test_list_sessions_with_encrypted_sessions(dynamodb_table, lambda_context):
+ """Test list_sessions handles encrypted sessions correctly."""
+ encrypted_session = {
+ "sessionId": "encrypted-session",
+ "userId": "test-user",
+ "is_encrypted": True,
+ "encrypted_history": "encrypted_data",
+ "startTime": "2024-01-01T00:00:00",
+ }
+ dynamodb_table.put_item(Item=encrypted_session)
+
+ event = {"requestContext": {"authorizer": {"claims": {"username": "test-user"}}}}
+
+ with patch("session.lambda_functions.decrypt_session_fields") as mock_decrypt:
+ mock_decrypt.return_value = {
+ "sessionId": "encrypted-session",
+ "history": [{"type": "human", "content": "Decrypted message"}],
+ }
+
+ response = list_sessions(event, lambda_context)
+ assert response["statusCode"] == 200
+ body = json.loads(response["body"])
+ assert len(body) == 1
+ # Validate response matches SessionSummary model
+ session_summary = SessionSummary.model_validate(body[0])
+ assert session_summary.sessionId == "encrypted-session"
+ assert session_summary.isEncrypted is True
+
+
+# Find First Human Message with List Content and Empty Text
+def test_find_first_human_message_list_content_empty_text():
+ """Test _find_first_human_message with list content containing empty text items."""
+ session = {
+ "sessionId": "test-session",
+ "is_encrypted": False,
+ "history": [
+ {"type": "human", "content": [{"text": ""}, {"text": "Actual message"}]},
+ ],
+ }
+
+ result = _find_first_human_message(session, "test-user")
+ assert result == "Actual message"
+
+
+def test_find_first_human_message_list_content_no_text_key():
+ """Test _find_first_human_message with list content items missing text key."""
+ session = {
+ "sessionId": "test-session",
+ "is_encrypted": False,
+ "history": [
+ {"type": "human", "content": [{"image": "data:image/png"}, {"text": "Message after image"}]},
+ ],
+ }
+
+ result = _find_first_human_message(session, "test-user")
+ assert result == "Message after image"
diff --git a/test/lambda/test_update_model_state_machine.py b/test/lambda/test_update_model_state_machine.py
index 56c997bd9..970121ef7 100644
--- a/test/lambda/test_update_model_state_machine.py
+++ b/test/lambda/test_update_model_state_machine.py
@@ -95,6 +95,9 @@
"status": "PRIMARY",
"rolloutState": "COMPLETED",
"taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2",
+ "runningCount": 1,
+ "desiredCount": 1,
+ "pendingCount": 0,
}
],
}
@@ -609,6 +612,9 @@ def test_ecs_update_and_polling(model_table, sample_model, lambda_context):
"status": "PRIMARY",
"rolloutState": "IN_PROGRESS",
"taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2",
+ "runningCount": 0,
+ "desiredCount": 1,
+ "pendingCount": 1,
}
]
}
@@ -632,6 +638,9 @@ def test_ecs_update_and_polling(model_table, sample_model, lambda_context):
"status": "PRIMARY",
"rolloutState": "COMPLETED",
"taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2",
+ "runningCount": 1,
+ "desiredCount": 1,
+ "pendingCount": 0,
}
],
}
diff --git a/lib/serve/mcp-workbench/tests/__init__.py b/test/mcp-workbench/__init__.py
similarity index 100%
rename from lib/serve/mcp-workbench/tests/__init__.py
rename to test/mcp-workbench/__init__.py
diff --git a/lib/serve/mcp-workbench/tests/conftest.py b/test/mcp-workbench/conftest.py
similarity index 81%
rename from lib/serve/mcp-workbench/tests/conftest.py
rename to test/mcp-workbench/conftest.py
index 3d8de2cfd..297b925d0 100644
--- a/lib/serve/mcp-workbench/tests/conftest.py
+++ b/test/mcp-workbench/conftest.py
@@ -15,9 +15,10 @@
"""Pytest configuration and shared fixtures."""
import asyncio
+import sys
import tempfile
+from collections.abc import Generator
from pathlib import Path
-from typing import Generator
import pytest
from mcpworkbench.config.models import CORSConfig, ServerConfig
@@ -25,15 +26,32 @@
from mcpworkbench.core.tool_registry import ToolRegistry
-@pytest.fixture
-def temp_tools_dir() -> Generator[Path, None, None]:
+@pytest.fixture(scope="function", autouse=True)
+def isolate_modules():
+ """Isolate sys.modules for each test to prevent cross-test contamination."""
+ # Get all mcpworkbench_tools modules before test
+ tools_modules_before = {k for k in sys.modules.keys() if k.startswith("mcpworkbench_tools")}
+
+ yield
+
+ # Clean up any mcpworkbench_tools modules added during the test
+ tools_modules_after = {k for k in sys.modules.keys() if k.startswith("mcpworkbench_tools")}
+ new_modules = tools_modules_after - tools_modules_before
+
+ for module_name in new_modules:
+ if module_name in sys.modules:
+ del sys.modules[module_name]
+
+
+@pytest.fixture(scope="function")
+def temp_tools_dir() -> Generator[Path]:
"""Create a temporary directory for test tools."""
with tempfile.TemporaryDirectory() as temp_dir:
tools_dir = Path(temp_dir)
yield tools_dir
-@pytest.fixture
+@pytest.fixture(scope="function")
def sample_function_tool_content() -> str:
"""Sample function-based tool content."""
return """
@@ -42,13 +60,6 @@ def sample_function_tool_content() -> str:
@mcp_tool(
name="echo_test",
description="Echo back the input text for testing",
- parameters={
- "type": "object",
- "properties": {
- "message": {"type": "string", "description": "Message to echo"}
- },
- "required": ["message"]
- }
)
def echo_message(message: str):
return {"echoed": message, "length": len(message)}
@@ -56,21 +67,13 @@ def echo_message(message: str):
@mcp_tool(
name="add_test",
description="Add two numbers together for testing",
- parameters={
- "type": "object",
- "properties": {
- "a": {"type": "number", "description": "First number"},
- "b": {"type": "number", "description": "Second number"}
- },
- "required": ["a", "b"]
- }
)
async def add_numbers(a: float, b: float):
return {"a": a, "b": b, "sum": a + b}
"""
-@pytest.fixture
+@pytest.fixture(scope="function")
def sample_class_tool_content() -> str:
"""Sample class-based tool content."""
return '''
@@ -115,7 +118,7 @@ async def execute(self, **kwargs):
'''
-@pytest.fixture
+@pytest.fixture(scope="function")
def populated_tools_dir(
temp_tools_dir: Path, sample_function_tool_content: str, sample_class_tool_content: str
) -> Path:
@@ -129,7 +132,7 @@ def populated_tools_dir(
return temp_tools_dir
-@pytest.fixture
+@pytest.fixture(scope="function")
def sample_config() -> ServerConfig:
"""Sample server configuration for testing."""
return ServerConfig(
@@ -143,13 +146,13 @@ def sample_config() -> ServerConfig:
)
-@pytest.fixture
+@pytest.fixture(scope="function")
def tool_discovery(populated_tools_dir: Path) -> ToolDiscovery:
"""Create a tool discovery instance with populated tools directory."""
return ToolDiscovery(str(populated_tools_dir))
-@pytest.fixture
+@pytest.fixture(scope="function")
def tool_registry() -> ToolRegistry:
"""Create a fresh tool registry instance."""
return ToolRegistry()
diff --git a/lib/serve/mcp-workbench/tests/test_adapters.py b/test/mcp-workbench/test_adapters.py
similarity index 97%
rename from lib/serve/mcp-workbench/tests/test_adapters.py
rename to test/mcp-workbench/test_adapters.py
index e1bca1ba3..fbadeb29e 100644
--- a/lib/serve/mcp-workbench/tests/test_adapters.py
+++ b/test/mcp-workbench/test_adapters.py
@@ -39,11 +39,6 @@ def get_parameters(self):
@mcp_tool(
name="test_function_tool",
description="A test function tool",
- parameters={
- "type": "object",
- "properties": {"value": {"type": "number", "description": "Value to process"}},
- "required": ["value"],
- },
)
def mock_function(value: float):
"""Mock function for function adapter testing."""
@@ -265,14 +260,13 @@ def test_adapter_properties(self):
module_name="test_module",
class_name="MockBaseTool",
tool_instance=tool_instance,
- parameters={"test": "param"},
)
adapter = create_adapter(tool_info)
assert adapter.name == "test_base_tool"
assert adapter.description == "A test base tool"
- assert adapter.parameters == {"test": "param"}
+ assert adapter.tool_info == tool_info
def create_adapter_with_invalid_type():
diff --git a/test/mcp-workbench/test_auth.py b/test/mcp-workbench/test_auth.py
new file mode 100644
index 000000000..02a842027
--- /dev/null
+++ b/test/mcp-workbench/test_auth.py
@@ -0,0 +1,379 @@
+# 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.
+
+"""Unit tests for MCP Workbench authentication."""
+
+import os
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import jwt
+
+# Set up environment before imports
+os.environ["AWS_REGION"] = "us-east-1"
+os.environ["TOKEN_TABLE_NAME"] = "test-tokens"
+os.environ["MANAGEMENT_KEY_NAME"] = "test-management-key"
+os.environ["AUTHORITY"] = "https://test-authority.com"
+os.environ["CLIENT_ID"] = "test-client-id"
+os.environ["USE_AUTH"] = "true"
+
+# Import the auth module
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib/serve/mcp-workbench/src"))
+
+from mcpworkbench.server.auth import (
+ ApiTokenAuthorizer,
+ get_authorization_token,
+ get_jwks_client,
+ get_oidc_metadata,
+ id_token_is_valid,
+ is_idp_used,
+ is_user_in_group,
+ ManagementTokenAuthorizer,
+)
+
+
+def test_is_idp_used_true():
+ """Test is_idp_used returns True when USE_AUTH is true."""
+ with patch.dict(os.environ, {"USE_AUTH": "true"}):
+ assert is_idp_used() is True
+
+
+def test_is_idp_used_false():
+ """Test is_idp_used returns False when USE_AUTH is false."""
+ with patch.dict(os.environ, {"USE_AUTH": "false"}):
+ assert is_idp_used() is False
+
+
+def test_get_authorization_token_with_bearer():
+ """Test extracting Bearer token from Authorization header."""
+ headers = {"Authorization": "Bearer test-token-123"}
+ token = get_authorization_token(headers)
+ assert token == "test-token-123"
+
+
+def test_get_authorization_token_without_bearer():
+ """Test extracting token without Bearer prefix."""
+ headers = {"Authorization": "test-token-123"}
+ token = get_authorization_token(headers)
+ assert token == "test-token-123"
+
+
+def test_get_authorization_token_lowercase_header():
+ """Test extracting token from lowercase header."""
+ headers = {"authorization": "Bearer test-token-123"}
+ token = get_authorization_token(headers)
+ assert token == "test-token-123"
+
+
+def test_get_authorization_token_custom_header():
+ """Test extracting token from custom header name."""
+ headers = {"Api-Key": "Bearer test-token-123"}
+ token = get_authorization_token(headers, "Api-Key")
+ assert token == "test-token-123"
+
+
+def test_get_authorization_token_missing():
+ """Test extracting token when header is missing."""
+ headers = {}
+ token = get_authorization_token(headers)
+ assert token == ""
+
+
+@patch("mcpworkbench.server.auth.requests.get")
+def test_get_oidc_metadata(mock_get):
+ """Test getting OIDC metadata."""
+ mock_response = Mock()
+ mock_response.json.return_value = {
+ "jwks_uri": "https://test-authority.com/.well-known/jwks.json",
+ "issuer": "https://test-authority.com",
+ }
+ mock_get.return_value = mock_response
+
+ metadata = get_oidc_metadata()
+
+ assert metadata["jwks_uri"] == "https://test-authority.com/.well-known/jwks.json"
+ mock_get.assert_called_once()
+
+
+@patch("mcpworkbench.server.auth.get_oidc_metadata")
+@patch("mcpworkbench.server.auth.jwt.PyJWKClient")
+def test_get_jwks_client(mock_jwk_client, mock_get_metadata):
+ """Test getting JWKS client."""
+ mock_get_metadata.return_value = {"jwks_uri": "https://test-authority.com/.well-known/jwks.json"}
+
+ client = get_jwks_client()
+
+ mock_jwk_client.assert_called_once()
+ assert client is not None
+
+
+def test_is_user_in_group_simple():
+ """Test checking if user is in group with simple property."""
+ jwt_data = {"groups": ["admin", "users"]}
+
+ assert is_user_in_group(jwt_data, "admin", "groups") is True
+ assert is_user_in_group(jwt_data, "superadmin", "groups") is False
+
+
+def test_is_user_in_group_nested():
+ """Test checking if user is in group with nested property."""
+ jwt_data = {"cognito": {"groups": ["admin", "users"]}}
+
+ assert is_user_in_group(jwt_data, "admin", "cognito.groups") is True
+ assert is_user_in_group(jwt_data, "superadmin", "cognito.groups") is False
+
+
+def test_is_user_in_group_missing_property():
+ """Test checking if user is in group when property is missing."""
+ jwt_data = {"other": "value"}
+
+ assert is_user_in_group(jwt_data, "admin", "groups") is False
+
+
+@patch("mcpworkbench.server.auth.jwt.decode")
+def test_id_token_is_valid_success(mock_decode):
+ """Test validating a valid ID token."""
+ mock_jwks_client = Mock()
+ mock_signing_key = Mock()
+ mock_signing_key.key = "test-key"
+ mock_jwks_client.get_signing_key_from_jwt.return_value = mock_signing_key
+
+ mock_decode.return_value = {
+ "sub": "user123",
+ "email": "user@example.com",
+ }
+
+ result = id_token_is_valid(
+ "test-token",
+ "test-client-id",
+ "https://test-authority.com",
+ mock_jwks_client,
+ )
+
+ assert result is not None
+ assert result["sub"] == "user123"
+
+
+@patch("mcpworkbench.server.auth.jwt.decode")
+def test_id_token_is_valid_expired(mock_decode):
+ """Test validating an expired ID token."""
+ mock_jwks_client = Mock()
+ mock_signing_key = Mock()
+ mock_signing_key.key = "test-key"
+ mock_jwks_client.get_signing_key_from_jwt.return_value = mock_signing_key
+
+ mock_decode.side_effect = jwt.exceptions.ExpiredSignatureError()
+
+ result = id_token_is_valid(
+ "test-token",
+ "test-client-id",
+ "https://test-authority.com",
+ mock_jwks_client,
+ )
+
+ assert result is None
+
+
+@patch("mcpworkbench.server.auth.jwt.decode")
+def test_id_token_is_valid_decode_error(mock_decode):
+ """Test validating an invalid ID token."""
+ mock_jwks_client = Mock()
+ mock_signing_key = Mock()
+ mock_signing_key.key = "test-key"
+ mock_jwks_client.get_signing_key_from_jwt.return_value = mock_signing_key
+
+ mock_decode.side_effect = jwt.exceptions.DecodeError()
+
+ result = id_token_is_valid(
+ "test-token",
+ "test-client-id",
+ "https://test-authority.com",
+ mock_jwks_client,
+ )
+
+ assert result is None
+
+
+class TestApiTokenAuthorizer:
+ """Tests for ApiTokenAuthorizer class."""
+
+ @patch("mcpworkbench.server.auth.boto3.resource")
+ def test_init(self, mock_boto_resource):
+ """Test ApiTokenAuthorizer initialization."""
+ mock_table = Mock()
+ mock_ddb = Mock()
+ mock_ddb.Table.return_value = mock_table
+ mock_boto_resource.return_value = mock_ddb
+
+ authorizer = ApiTokenAuthorizer()
+
+ assert authorizer._token_table == mock_table
+ mock_boto_resource.assert_called_once_with("dynamodb", region_name="us-east-1")
+
+ @patch("mcpworkbench.server.auth.boto3.resource")
+ def test_is_valid_api_token_valid(self, mock_boto_resource):
+ """Test validating a valid API token."""
+ mock_table = Mock()
+ mock_ddb = Mock()
+ mock_ddb.Table.return_value = mock_table
+ mock_boto_resource.return_value = mock_ddb
+
+ # Token expires in the future
+ future_time = int((datetime.now() + timedelta(days=1)).timestamp())
+ mock_table.get_item.return_value = {
+ "Item": {
+ "token": "test-token",
+ "tokenExpiration": future_time,
+ }
+ }
+
+ authorizer = ApiTokenAuthorizer()
+ headers = {"Authorization": "Bearer test-token"}
+
+ assert authorizer.is_valid_api_token(headers) is True
+
+ @patch("mcpworkbench.server.auth.boto3.resource")
+ def test_is_valid_api_token_expired(self, mock_boto_resource):
+ """Test validating an expired API token."""
+ mock_table = Mock()
+ mock_ddb = Mock()
+ mock_ddb.Table.return_value = mock_table
+ mock_boto_resource.return_value = mock_ddb
+
+ # Token expired in the past
+ past_time = int((datetime.now() - timedelta(days=1)).timestamp())
+ mock_table.get_item.return_value = {
+ "Item": {
+ "token": "test-token",
+ "tokenExpiration": past_time,
+ }
+ }
+
+ authorizer = ApiTokenAuthorizer()
+ headers = {"Authorization": "Bearer test-token"}
+
+ assert authorizer.is_valid_api_token(headers) is False
+
+ @patch("mcpworkbench.server.auth.boto3.resource")
+ def test_is_valid_api_token_not_found(self, mock_boto_resource):
+ """Test validating a non-existent API token."""
+ mock_table = Mock()
+ mock_ddb = Mock()
+ mock_ddb.Table.return_value = mock_table
+ mock_boto_resource.return_value = mock_ddb
+
+ mock_table.get_item.return_value = {}
+
+ authorizer = ApiTokenAuthorizer()
+ headers = {"Authorization": "Bearer test-token"}
+
+ assert authorizer.is_valid_api_token(headers) is False
+
+
+class TestManagementTokenAuthorizer:
+ """Tests for ManagementTokenAuthorizer class."""
+
+ @patch("mcpworkbench.server.auth.boto3.client")
+ def test_init(self, mock_boto_client):
+ """Test ManagementTokenAuthorizer initialization."""
+ mock_sm = Mock()
+ mock_boto_client.return_value = mock_sm
+
+ authorizer = ManagementTokenAuthorizer()
+
+ assert authorizer._secrets_manager == mock_sm
+ mock_boto_client.assert_called_once_with("secretsmanager", region_name="us-east-1")
+
+ @patch("mcpworkbench.server.auth.boto3.client")
+ @patch("mcpworkbench.server.auth.time")
+ def test_is_valid_api_token_valid(self, mock_time, mock_boto_client):
+ """Test validating a valid management token."""
+ mock_sm = Mock()
+ mock_boto_client.return_value = mock_sm
+
+ mock_sm.get_secret_value.return_value = {"SecretString": "management-token-123"}
+ # Mock time() function call to return a value that triggers refresh
+ mock_time.return_value = 10000
+
+ authorizer = ManagementTokenAuthorizer()
+ authorizer._last_run = 0 # Force refresh
+
+ headers = {"Authorization": "Bearer management-token-123"}
+
+ assert authorizer.is_valid_api_token(headers) is True
+
+ @patch("mcpworkbench.server.auth.boto3.client")
+ @patch("mcpworkbench.server.auth.time")
+ def test_is_valid_api_token_invalid(self, mock_time, mock_boto_client):
+ """Test validating an invalid management token."""
+ mock_sm = Mock()
+ mock_boto_client.return_value = mock_sm
+
+ mock_sm.get_secret_value.return_value = {"SecretString": "management-token-123"}
+ mock_time.return_value = 1000
+
+ authorizer = ManagementTokenAuthorizer()
+ authorizer._last_run = 0 # Force refresh
+
+ headers = {"Authorization": "Bearer wrong-token"}
+
+ assert authorizer.is_valid_api_token(headers) is False
+
+ @patch("mcpworkbench.server.auth.boto3.client")
+ @patch("mcpworkbench.server.auth.time")
+ def test_refresh_tokens_with_previous(self, mock_time, mock_boto_client):
+ """Test refreshing tokens with previous version."""
+ mock_sm = Mock()
+ mock_boto_client.return_value = mock_sm
+
+ def get_secret_side_effect(*args, **kwargs):
+ if kwargs.get("VersionStage") == "AWSCURRENT":
+ return {"SecretString": "current-token"}
+ elif kwargs.get("VersionStage") == "AWSPREVIOUS":
+ return {"SecretString": "previous-token"}
+
+ mock_sm.get_secret_value.side_effect = get_secret_side_effect
+ mock_time.return_value = 5000
+
+ authorizer = ManagementTokenAuthorizer()
+ authorizer._last_run = 0 # Force refresh
+
+ headers = {"Authorization": "Bearer previous-token"}
+
+ assert authorizer.is_valid_api_token(headers) is True
+
+ @patch("mcpworkbench.server.auth.boto3.client")
+ @patch("mcpworkbench.server.auth.time")
+ def test_refresh_tokens_no_previous(self, mock_time, mock_boto_client):
+ """Test refreshing tokens without previous version."""
+ mock_sm = Mock()
+ mock_boto_client.return_value = mock_sm
+
+ def get_secret_side_effect(*args, **kwargs):
+ if kwargs.get("VersionStage") == "AWSCURRENT":
+ return {"SecretString": "current-token"}
+ elif kwargs.get("VersionStage") == "AWSPREVIOUS":
+ raise Exception("No previous version")
+
+ mock_sm.get_secret_value.side_effect = get_secret_side_effect
+ mock_time.return_value = 5000
+
+ authorizer = ManagementTokenAuthorizer()
+ authorizer._last_run = 0 # Force refresh
+
+ headers = {"Authorization": "Bearer current-token"}
+
+ assert authorizer.is_valid_api_token(headers) is True
diff --git a/test/mcp-workbench/test_cli.py b/test/mcp-workbench/test_cli.py
new file mode 100644
index 000000000..4aa52376a
--- /dev/null
+++ b/test/mcp-workbench/test_cli.py
@@ -0,0 +1,252 @@
+# 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.
+
+"""Unit tests for MCP Workbench CLI."""
+
+import sys
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+import yaml
+from click.testing import CliRunner
+
+# Import the CLI module
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib/serve/mcp-workbench/src"))
+
+from mcpworkbench.cli import load_config_from_file, main, merge_config
+
+
+@pytest.fixture
+def temp_config_file():
+ """Create a temporary config file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
+ config = {
+ "tools_dir": "/tmp/tools",
+ "host": "localhost",
+ "port": 8080,
+ }
+ yaml.dump(config, f)
+ yield Path(f.name)
+ Path(f.name).unlink(missing_ok=True)
+
+
+@pytest.fixture
+def temp_tools_dir():
+ """Create a temporary tools directory."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+
+def test_load_config_from_file_success(temp_config_file):
+ """Test loading configuration from a valid YAML file."""
+ config = load_config_from_file(str(temp_config_file))
+ assert config["tools_dir"] == "/tmp/tools"
+ assert config["host"] == "localhost"
+ assert config["port"] == 8080
+
+
+def test_load_config_from_file_not_found():
+ """Test loading configuration from non-existent file."""
+ with pytest.raises(SystemExit) as exc_info:
+ load_config_from_file("/nonexistent/config.yaml")
+ assert exc_info.value.code == 1
+
+
+def test_load_config_from_file_invalid_yaml(tmp_path):
+ """Test loading configuration from invalid YAML file."""
+ invalid_file = tmp_path / "invalid.yaml"
+ invalid_file.write_text("invalid: yaml: content: [")
+
+ with pytest.raises(SystemExit) as exc_info:
+ load_config_from_file(str(invalid_file))
+ assert exc_info.value.code == 1
+
+
+def test_merge_config():
+ """Test merging file config with CLI overrides."""
+ file_config = {
+ "tools_dir": "/tmp/tools",
+ "host": "localhost",
+ "port": 8080,
+ }
+
+ cli_overrides = {
+ "host": "0.0.0.0",
+ "port": "9000",
+ "exit_route": "/exit",
+ }
+
+ merged = merge_config(file_config, cli_overrides)
+
+ assert merged["tools_dir"] == "/tmp/tools"
+ assert merged["host"] == "0.0.0.0"
+ assert merged["port"] == "9000"
+ assert merged["exit_route"] == "/exit"
+
+
+def test_merge_config_none_values():
+ """Test that None values in CLI overrides don't override file config."""
+ file_config = {"host": "localhost", "port": 8080}
+ cli_overrides = {"host": None, "port": "9000"}
+
+ merged = merge_config(file_config, cli_overrides)
+
+ assert merged["host"] == "localhost"
+ assert merged["port"] == "9000"
+
+
+def test_main_missing_tools_dir():
+ """Test CLI fails when tools_dir is not specified."""
+ runner = CliRunner()
+ result = runner.invoke(main, [])
+
+ # Should exit with error code
+ assert result.exit_code == 1
+
+
+def test_main_with_config_file(temp_config_file, temp_tools_dir):
+ """Test CLI with config file."""
+ runner = CliRunner()
+
+ # Update config to use temp tools dir
+ config = {"tools_dir": str(temp_tools_dir), "host": "localhost", "port": 8080}
+ with open(temp_config_file, "w") as f:
+ yaml.dump(config, f)
+
+ with patch("mcpworkbench.cli.MCPWorkbenchServer") as mock_server:
+ mock_server_instance = MagicMock()
+ mock_server.return_value = mock_server_instance
+
+ result = runner.invoke(main, ["--config", str(temp_config_file)])
+
+ # Should attempt to start server
+ assert mock_server_instance.run.called or result.exit_code == 0
+
+
+def test_main_with_cli_args(temp_tools_dir):
+ """Test CLI with command line arguments."""
+ runner = CliRunner()
+
+ with patch("mcpworkbench.cli.MCPWorkbenchServer") as mock_server:
+ mock_server_instance = MagicMock()
+ mock_server.return_value = mock_server_instance
+
+ runner.invoke(
+ main,
+ [
+ "--tools-dir",
+ str(temp_tools_dir),
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "9000",
+ "--verbose",
+ ],
+ )
+
+ mock_server_instance.run.assert_called_once()
+
+
+def test_main_cors_origins_parsing(temp_tools_dir):
+ """Test CORS origins parsing."""
+ runner = CliRunner()
+
+ with patch("mcpworkbench.cli.MCPWorkbenchServer") as mock_server:
+ with patch("mcpworkbench.cli.ServerConfig") as mock_config:
+ mock_server_instance = MagicMock()
+ mock_server.return_value = mock_server_instance
+
+ runner.invoke(
+ main,
+ [
+ "--tools-dir",
+ str(temp_tools_dir),
+ "--cors-origins",
+ "http://localhost:3000,http://localhost:8080",
+ ],
+ )
+
+ # Verify ServerConfig was called with parsed origins
+ call_args = mock_config.from_dict.call_args
+ assert call_args is not None
+
+
+def test_main_debug_logging(temp_tools_dir):
+ """Test debug logging flag."""
+ runner = CliRunner()
+
+ with patch("mcpworkbench.cli.MCPWorkbenchServer") as mock_server:
+ mock_server_instance = MagicMock()
+ mock_server.return_value = mock_server_instance
+
+ runner.invoke(
+ main,
+ ["--tools-dir", str(temp_tools_dir), "--debug"],
+ )
+
+ # Should set debug logging level
+ import logging
+
+ assert logging.getLogger().level == logging.DEBUG
+
+
+def test_main_keyboard_interrupt(temp_tools_dir):
+ """Test handling of keyboard interrupt."""
+ runner = CliRunner()
+
+ with patch("mcpworkbench.cli.MCPWorkbenchServer") as mock_server:
+ mock_server_instance = MagicMock()
+ mock_server_instance.run.side_effect = KeyboardInterrupt()
+ mock_server.return_value = mock_server_instance
+
+ result = runner.invoke(
+ main,
+ ["--tools-dir", str(temp_tools_dir)],
+ )
+
+ assert result.exit_code == 0
+
+
+def test_main_server_error(temp_tools_dir):
+ """Test handling of server startup error."""
+ runner = CliRunner()
+
+ with patch("mcpworkbench.cli.MCPWorkbenchServer") as mock_server:
+ mock_server.side_effect = Exception("Server failed to start")
+
+ result = runner.invoke(
+ main,
+ ["--tools-dir", str(temp_tools_dir)],
+ )
+
+ # Should exit with error
+ assert result.exit_code == 1
+
+
+def test_main_invalid_config(temp_tools_dir):
+ """Test handling of invalid configuration."""
+ runner = CliRunner()
+
+ with patch("mcpworkbench.cli.ServerConfig") as mock_config:
+ mock_config.from_dict.side_effect = ValueError("Invalid port")
+
+ result = runner.invoke(
+ main,
+ ["--tools-dir", str(temp_tools_dir)],
+ )
+
+ # Should exit with error
+ assert result.exit_code == 1
diff --git a/lib/serve/mcp-workbench/tests/test_core.py b/test/mcp-workbench/test_core.py
similarity index 94%
rename from lib/serve/mcp-workbench/tests/test_core.py
rename to test/mcp-workbench/test_core.py
index 5cdf71841..cca9b66e1 100644
--- a/lib/serve/mcp-workbench/tests/test_core.py
+++ b/test/mcp-workbench/test_core.py
@@ -53,7 +53,7 @@ class TestAnnotations:
def test_mcp_tool_decorator(self):
"""Test the @mcp_tool decorator."""
- @mcp_tool(name="test_func", description="A test function", parameters={"param1": "value1"})
+ @mcp_tool(name="test_func", description="A test function")
def test_function():
return "test"
@@ -61,7 +61,6 @@ def test_function():
metadata = get_tool_metadata(test_function)
assert metadata["name"] == "test_func"
assert metadata["description"] == "A test function"
- assert metadata["parameters"] == {"param1": "value1"}
def test_mcp_tool_without_parameters(self):
"""Test @mcp_tool decorator without parameters."""
@@ -70,8 +69,9 @@ def test_mcp_tool_without_parameters(self):
def simple_function():
return "simple"
+ assert is_mcp_tool(simple_function)
metadata = get_tool_metadata(simple_function)
- assert metadata["parameters"] == {}
+ assert metadata["name"] == "simple_func"
def test_is_mcp_tool_false(self):
"""Test is_mcp_tool returns False for regular functions."""
@@ -109,12 +109,12 @@ def test_discover_tools(self, tool_discovery: ToolDiscovery):
tools = tool_discovery.discover_tools()
# Should find both function and class-based tools
- assert len(tools) == 3 # echo_test, add_test, greeting_test
+ assert len(tools) == 3, f"Expected 3 tools, found {len(tools)}: {[t.name for t in tools]}"
tool_names = [tool.name for tool in tools]
- assert "echo_test" in tool_names
- assert "add_test" in tool_names
- assert "greeting_test" in tool_names
+ assert "echo_test" in tool_names, "echo_test not found in discovered tools"
+ assert "add_test" in tool_names, "add_test not found in discovered tools"
+ assert "greeting_test" in tool_names, "greeting_test not found in discovered tools"
# Check tool types
function_tools = [t for t in tools if t.tool_type == ToolType.FUNCTION_BASED]
diff --git a/lib/serve/mcp-workbench/tests/test_integration.py b/test/mcp-workbench/test_integration.py
similarity index 100%
rename from lib/serve/mcp-workbench/tests/test_integration.py
rename to test/mcp-workbench/test_integration.py
diff --git a/lib/serve/mcp-workbench/tests/test_manual.py b/test/mcp-workbench/test_manual.py
similarity index 95%
rename from lib/serve/mcp-workbench/tests/test_manual.py
rename to test/mcp-workbench/test_manual.py
index 1575eef13..bbf380345 100644
--- a/lib/serve/mcp-workbench/tests/test_manual.py
+++ b/test/mcp-workbench/test_manual.py
@@ -39,13 +39,6 @@ def create_test_tools(tools_dir: Path):
@mcp_tool(
name="echo",
description="Echo back the input text",
- parameters={
- "type": "object",
- "properties": {
- "message": {"type": "string", "description": "Message to echo"}
- },
- "required": ["message"]
- }
)
def echo_message(message: str):
return {"echoed": message, "length": len(message)}
@@ -53,14 +46,6 @@ def echo_message(message: str):
@mcp_tool(
name="add_numbers",
description="Add two numbers together",
- parameters={
- "type": "object",
- "properties": {
- "a": {"type": "number", "description": "First number"},
- "b": {"type": "number", "description": "Second number"}
- },
- "required": ["a", "b"]
- }
)
async def add_numbers(a: float, b: float):
return {"a": a, "b": b, "sum": a + b}
diff --git a/test/mcp-workbench/test_middleware.py b/test/mcp-workbench/test_middleware.py
new file mode 100644
index 000000000..f7786283f
--- /dev/null
+++ b/test/mcp-workbench/test_middleware.py
@@ -0,0 +1,192 @@
+# 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.
+
+"""Unit tests for MCP Workbench middleware."""
+
+import sys
+from pathlib import Path
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+
+# Import the middleware module
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib/serve/mcp-workbench/src"))
+
+from mcpworkbench.config.models import CORSConfig
+from mcpworkbench.server.middleware import CORSMiddleware, ExitRouteMiddleware, RescanMiddleware
+
+
+@pytest.fixture
+def mock_app():
+ """Create a mock ASGI app."""
+ return Mock()
+
+
+@pytest.fixture
+def mock_request():
+ """Create a mock request."""
+ request = Mock()
+ request.url = Mock()
+ request.url.path = "/test"
+ return request
+
+
+@pytest.fixture
+def mock_call_next():
+ """Create a mock call_next function."""
+
+ async def call_next(request):
+ return Mock(status_code=200)
+
+ return call_next
+
+
+@pytest.fixture
+def cors_config():
+ """Create a CORS configuration."""
+ return CORSConfig(
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+ allow_credentials=False,
+ expose_headers=[],
+ max_age=600,
+ )
+
+
+def test_cors_middleware_init(mock_app, cors_config):
+ """Test CORSMiddleware initialization."""
+ middleware = CORSMiddleware(mock_app, cors_config)
+ assert middleware is not None
+
+
+@pytest.mark.asyncio
+async def test_exit_route_middleware_exit_path(mock_app, mock_request):
+ """Test ExitRouteMiddleware handles exit path."""
+ middleware = ExitRouteMiddleware(mock_app, "/exit")
+ mock_request.url.path = "/exit"
+
+ with patch.object(middleware, "_delayed_exit", new_callable=AsyncMock):
+ response = await middleware.dispatch(mock_request, AsyncMock())
+
+ assert response.status_code == 200
+ body = response.body.decode()
+ assert "shutting down" in body.lower()
+
+
+@pytest.mark.asyncio
+async def test_exit_route_middleware_normal_path(mock_app, mock_request, mock_call_next):
+ """Test ExitRouteMiddleware passes through normal requests."""
+ middleware = ExitRouteMiddleware(mock_app, "/exit")
+ mock_request.url.path = "/other"
+
+ response = await middleware.dispatch(mock_request, mock_call_next)
+
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_exit_route_middleware_trailing_slash(mock_app, mock_request):
+ """Test ExitRouteMiddleware handles trailing slashes."""
+ middleware = ExitRouteMiddleware(mock_app, "/exit/")
+ mock_request.url.path = "/exit"
+
+ with patch.object(middleware, "_delayed_exit", new_callable=AsyncMock):
+ response = await middleware.dispatch(mock_request, AsyncMock())
+
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_rescan_middleware_rescan_path(mock_app, mock_request):
+ """Test RescanMiddleware handles rescan path."""
+ mock_tool_discovery = Mock()
+ mock_tool_registry = Mock()
+
+ # Create mock rescan result
+ mock_rescan_result = Mock()
+ mock_rescan_result.tools_added = ["tool1"]
+ mock_rescan_result.tools_updated = ["tool2"]
+ mock_rescan_result.tools_removed = []
+ mock_rescan_result.total_tools = 2
+ mock_rescan_result.errors = []
+
+ mock_tool_discovery.rescan_tools.return_value = mock_rescan_result
+ mock_tool_discovery.discover_tools.return_value = []
+
+ middleware = RescanMiddleware(mock_app, "/rescan", mock_tool_discovery, mock_tool_registry)
+ mock_request.url.path = "/rescan"
+
+ response = await middleware.dispatch(mock_request, AsyncMock())
+
+ assert response.status_code == 200
+ body = response.body.decode()
+ assert "success" in body
+
+
+@pytest.mark.asyncio
+async def test_rescan_middleware_normal_path(mock_app, mock_request, mock_call_next):
+ """Test RescanMiddleware passes through normal requests."""
+ mock_tool_discovery = Mock()
+ mock_tool_registry = Mock()
+
+ middleware = RescanMiddleware(mock_app, "/rescan", mock_tool_discovery, mock_tool_registry)
+ mock_request.url.path = "/other"
+
+ response = await middleware.dispatch(mock_request, mock_call_next)
+
+ assert response.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_rescan_middleware_error(mock_app, mock_request):
+ """Test RescanMiddleware handles errors during rescan."""
+ mock_tool_discovery = Mock()
+ mock_tool_registry = Mock()
+
+ mock_tool_discovery.rescan_tools.side_effect = Exception("Rescan failed")
+
+ middleware = RescanMiddleware(mock_app, "/rescan", mock_tool_discovery, mock_tool_registry)
+ mock_request.url.path = "/rescan"
+
+ response = await middleware.dispatch(mock_request, AsyncMock())
+
+ assert response.status_code == 500
+ body = response.body.decode()
+ assert "error" in body.lower()
+
+
+@pytest.mark.asyncio
+async def test_rescan_middleware_updates_registry(mock_app, mock_request):
+ """Test RescanMiddleware updates tool registry after rescan."""
+ mock_tool_discovery = Mock()
+ mock_tool_registry = Mock()
+
+ mock_rescan_result = Mock()
+ mock_rescan_result.tools_added = []
+ mock_rescan_result.tools_updated = []
+ mock_rescan_result.tools_removed = []
+ mock_rescan_result.total_tools = 0
+ mock_rescan_result.errors = []
+
+ mock_tool_discovery.rescan_tools.return_value = mock_rescan_result
+ mock_tool_discovery.discover_tools.return_value = []
+
+ middleware = RescanMiddleware(mock_app, "/rescan", mock_tool_discovery, mock_tool_registry)
+ mock_request.url.path = "/rescan"
+
+ await middleware.dispatch(mock_request, AsyncMock())
+
+ # Verify registry was updated
+ mock_tool_registry.update_registry.assert_called_once()
diff --git a/test/python/integration-setup-test.py b/test/python/integration-setup-test.py
index cb5b6faaf..7e8f480e8 100644
--- a/test/python/integration-setup-test.py
+++ b/test/python/integration-setup-test.py
@@ -20,13 +20,16 @@
- A self-hosted model
- A PGVector repository
- An OpenSearch repository
+- A Bedrock Knowledge Base with S3 data source
+- A Bedrock Knowledge Base repository
"""
import argparse
+import json
import os
import sys
import time
-from typing import Any, Dict
+from typing import Any
import boto3
@@ -36,24 +39,31 @@
from lisapy.api import LisaApi
from lisapy.types import BedrockModelRequest, ModelRequest
-DEFAULT_EMBEDDING_MODEL_ID = "e5-embed"
+DEFAULT_EMBEDDING_MODEL_ID = "qwen3-embed-06b"
RAG_PIPELINE_BUCKET = "lisa-rag-pipeline"
+BEDROCK_KB_S3_BUCKET = "bk-s3-test"
-def get_management_key(deployment_name: str, deployment_stage: str) -> str:
+def get_management_key(deployment_name: str, deployment_stage: str, region: str | None = None) -> str:
"""Retrieve management key from AWS Secrets Manager.
Args:
deployment_name: The LISA deployment name
+ deployment_stage: The LISA deployment stage
+ region: AWS region where the secret is stored
Returns:
str: The management API key
"""
secret_name = f"{deployment_name}-management-key"
print(f" Looking for secret: {secret_name}")
+ if region:
+ print(f" Using region: {region}")
try:
- secrets_client = boto3.client("secretsmanager")
+ secrets_client = (
+ boto3.client("secretsmanager", region_name=region) if region else boto3.client("secretsmanager")
+ )
response = secrets_client.get_secret_value(SecretId=secret_name)
# Secret is stored as a plain string, not JSON
api_key = response["SecretString"]
@@ -94,14 +104,14 @@ def create_api_token(deployment_name: str, api_key: str) -> str:
raise
-def setup_authentication(deployment_name: str, deployment_stage: str) -> Dict[str, str]:
+def setup_authentication(deployment_name: str, deployment_stage: str) -> dict[str, str]:
"""Set up authentication for LISA API calls.
Args:
deployment_name: The LISA deployment name
Returns:
- Dict[str, str]: Authentication headers
+ dict[str, str]: Authentication headers
"""
print(f"🔑 Setting up authentication for deployment: {deployment_name}")
@@ -203,7 +213,7 @@ def create_bedrock_model(
model_type: str = "textgen",
features: any = None,
skip_create: bool = False,
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""Create a Bedrock model configuration."""
# Skip creation if flag is set
@@ -217,7 +227,12 @@ def create_bedrock_model(
return {"modelId": model_id}
if features is None:
- features = [{"name": "summarization", "overview": ""}, {"name": "imageInput", "overview": ""}]
+ features = [
+ {"name": "summarization", "overview": ""},
+ {"name": "imageInput", "overview": ""},
+ {"name": "reasoning", "overview": ""},
+ {"name": "toolCalls", "overview": ""},
+ ]
print(f"\n🚀 Creating Bedrock model '{model_id}'...")
@@ -259,7 +274,7 @@ def create_self_hosted_embedded_model(
model_name: str,
base_image: str = "ghcr.io/huggingface/text-embeddings-inference:latest",
skip_create: bool = False,
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""Create a self-hosted embedded model configuration."""
# Skip creation if flag is set
@@ -280,32 +295,39 @@ def create_self_hosted_embedded_model(
"minCapacity": 1,
"maxCapacity": 1,
"cooldown": 420,
- "defaultInstanceWarmup": 180,
+ "defaultInstanceWarmup": 300, # Embedding models load faster
"metricConfig": {
"albMetricName": "RequestCountPerTarget",
"targetValue": 30,
"duration": 60,
- "estimatedInstanceWarmup": 330,
+ "estimatedInstanceWarmup": 300,
},
},
"containerConfig": {
- "image": {"baseImage": base_image, "type": "asset"},
+ "image": {"baseImage": base_image, "type": "asset" if "huggingface" in base_image.lower() else "ecr"},
"sharedMemorySize": 2048,
"healthCheckConfig": {
"command": ["CMD-SHELL", "exit 0"],
"interval": 10,
- "startPeriod": 30,
+ "startPeriod": 300, # 10 minutes to allow for model loading
"timeout": 5,
"retries": 3,
},
"environment": {
- "MAX_TOTAL_TOKENS": "32768",
- "MAX_INPUT_LENGTH": "16384",
- "MAX_BATCH_TOKENS": "8192",
- "MAX_CONCURRENT_REQUESTS": "512",
- "MAX_CLIENT_BATCH_SIZE": "1024",
- "TEI_POOLING": "mean",
- "AUTO_TRUNCATE": "true",
+ # TEI Performance Configuration for g5.xlarge (1x A10G GPU, 24GB VRAM)
+ # Based on: https://huggingface.co/docs/text-embeddings-inference/cli_arguments
+ # Batching - CRITICAL for GPU utilization and throughput
+ # MAX_BATCH_TOKENS should be as high as possible until GPU is compute-bound
+ "MAX_BATCH_TOKENS": "16384", # Max tokens per batch (default: 16384)
+ # Concurrency - control backpressure
+ "MAX_CONCURRENT_REQUESTS": "512", # Max concurrent requests (default: 512)
+ "MAX_CLIENT_BATCH_SIZE": "256", # Max inputs per client request (default: 32)
+ # Pooling method for embeddings
+ "POOLING": "mean", # Options: cls, mean, splade, last-token
+ # Input handling
+ "AUTO_TRUNCATE": "true", # Automatically truncate long inputs
+ # Precision - use float16 for faster inference on GPU
+ "DTYPE": "float16",
},
},
"inferenceContainer": "tei",
@@ -348,9 +370,14 @@ def create_self_hosted_model(
lisa_client: LisaApi,
model_id: str,
model_name: str,
- base_image: str = "vllm/vllm-openai:latest",
+ base_image: str = "public.ecr.aws/deep-learning-containers/vllm:0.13-gpu-py312",
skip_create: bool = False,
-) -> Dict[str, Any]:
+ instance_type: str = "g5.xlarge",
+ environment: dict | None = None,
+ blockDeviceVolumeSize: int = 50,
+ memoryReservation: int | None = None,
+ sharedMemorySize: int = 2048,
+) -> dict[str, Any]:
"""Create a self-hosted model configuration."""
# Skip creation if flag is set
@@ -370,43 +397,56 @@ def create_self_hosted_model(
if not instances:
raise Exception("No EC2 instances available for self-hosted model")
- # Use the first available instance type that supports GPU workloads
- gpu_instances = [inst for inst in instances if "g5" in inst.lower() or "p3" in inst.lower() or "p4" in inst.lower()]
- instance_type = gpu_instances[0] if gpu_instances else instances[0]
+ if not environment:
+ environment = {
+ # vLLM Performance Configuration for g5.xlarge (1x A10G GPU, 24GB VRAM)
+ # These environment variables are read natively by vLLM
+ # See: https://docs.vllm.ai/en/latest/configuration/env_vars/
+ # Context length - maximum sequence length the model can handle
+ "VLLM_MAX_MODEL_LEN": "16384",
+ # GPU memory utilization - use 90% of GPU VRAM for model/KV cache
+ "VLLM_GPU_MEMORY_UTILIZATION": "0.90",
+ # Batching - max tokens processed per iteration (affects throughput)
+ "VLLM_MAX_NUM_BATCHED_TOKENS": "8192",
+ # Concurrency - max number of sequences processed in parallel
+ "VLLM_MAX_NUM_SEQS": "128",
+ # Performance optimizations
+ "VLLM_ENABLE_PREFIX_CACHING": "true", # Cache common prefixes for faster inference
+ "VLLM_ENABLE_CHUNKED_PREFILL": "true", # Better memory efficiency during prefill
+ # Precision - let vLLM auto-detect based on model config
+ "VLLM_DTYPE": "auto",
+ }
+
print(f" Using instance type: {instance_type}")
self_hosted_model_request: ModelRequest = {
"autoScalingConfig": {
- "blockDeviceVolumeSize": 50,
+ "blockDeviceVolumeSize": blockDeviceVolumeSize,
"minCapacity": 1,
"maxCapacity": 1,
"cooldown": 420,
- "defaultInstanceWarmup": 180,
+ "defaultInstanceWarmup": 300, # Match model loading time
"metricConfig": {
"albMetricName": "RequestCountPerTarget",
"targetValue": 30,
"duration": 60,
- "estimatedInstanceWarmup": 330,
+ "estimatedInstanceWarmup": 300, # Match model loading time
},
},
"containerConfig": {
- "image": {"baseImage": base_image, "type": "asset"},
- "sharedMemorySize": 2048,
+ "image": {"baseImage": base_image, "type": "asset" if "huggingface" in base_image.lower() else "ecr"},
+ "sharedMemorySize": sharedMemorySize,
"healthCheckConfig": {
"command": ["CMD-SHELL", "exit 0"],
"interval": 10,
- "startPeriod": 30,
+ "startPeriod": 300, # 10 minutes to allow for model loading
"timeout": 5,
"retries": 3,
},
- "environment": {
- "MAX_TOTAL_TOKENS": "32768",
- "MAX_INPUT_LENGTH": "16384",
- "MAX_BATCH_TOKENS": "8192",
- "MAX_CONCURRENT_REQUESTS": "128",
- },
+ "environment": environment,
+ **({"memoryReservation": memoryReservation} if memoryReservation is not None else {}),
},
"inferenceContainer": "vllm",
- "instanceType": "g5.xlarge",
+ "instanceType": instance_type,
"loadBalancerConfig": {
"healthCheckConfig": {
"path": "/health",
@@ -418,10 +458,15 @@ def create_self_hosted_model(
},
"modelId": model_id,
"modelName": model_name,
- "modelDescription": None,
+ "modelDescription": f"Self-hosted model for {model_name}",
"modelType": "textgen",
"streaming": True,
- "features": [{"name": "summarization", "overview": ""}, {"name": "imageInput", "overview": ""}],
+ "features": [
+ {"name": "summarization", "overview": ""},
+ {"name": "imageInput", "overview": ""},
+ {"name": "reasoning", "overview": ""},
+ {"name": "toolCalls", "overview": ""},
+ ],
"allowedGroups": None,
}
@@ -443,7 +488,7 @@ def create_self_hosted_model(
def create_pgvector_repository(
lisa_client: LisaApi, embedding_model_id: str = None, skip_create: bool = False
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""Create a PGVector repository."""
repository_id = "pgv-rag"
@@ -496,7 +541,7 @@ def create_pgvector_repository(
def create_opensearch_repository(
lisa_client: LisaApi, embedding_model_id: str = None, skip_create: bool = False
-) -> Dict[str, Any]:
+) -> dict[str, Any]:
"""Create an OpenSearch repository."""
repository_id = "os-rag"
@@ -554,25 +599,604 @@ def create_opensearch_repository(
raise
-def cleanup_resources(lisa_client: LisaApi, created_resources: Dict[str, list]):
- """Clean up created resources."""
- print("\n🧹 Cleaning up created resources...")
+def create_bedrock_kb_repository(
+ lisa_client: LisaApi,
+ knowledge_base_id: str,
+ data_source_id: str,
+ data_source_name: str,
+ s3_bucket: str,
+ embedding_model_id: str = None,
+ skip_create: bool = False,
+) -> dict[str, Any]:
+ """Create a Bedrock Knowledge Base repository using LISA SDK.
+
+ Args:
+ lisa_client: LISA API client
+ knowledge_base_id: The Bedrock Knowledge Base ID to connect to
+ data_source_id: The data source ID within the Knowledge Base
+ data_source_name: The name of the data source
+ s3_bucket: The S3 bucket used by the data source
+ embedding_model_id: Optional embedding model ID
+ skip_create: Skip creation if True
+
+ Returns:
+ dict containing repositoryId
+ """
+ repository_id = "bedrock-kb-rag"
+
+ # Skip creation if flag is set
+ if skip_create:
+ print(f"\n⏭️ Skipping creation of Bedrock KB repository '{repository_id}' (skip_create=True)")
+ return {"repositoryId": repository_id}
+
+ # Check if repository already exists
+ if repository_exists(lisa_client, repository_id):
+ print(f"\n⏭️ Bedrock KB repository '{repository_id}' already exists, skipping creation")
+ return {"repositoryId": repository_id}
+
+ print(f"\n🚀 Creating Bedrock KB repository '{repository_id}' for Knowledge Base '{knowledge_base_id}'...")
+
+ try:
+ rag_config = {
+ "repositoryId": repository_id,
+ "embeddingModelId": embedding_model_id or DEFAULT_EMBEDDING_MODEL_ID,
+ "type": "bedrock_knowledge_base",
+ "allowedGroups": [],
+ "bedrockKnowledgeBaseConfig": {
+ "knowledgeBaseId": knowledge_base_id,
+ "dataSources": [
+ {
+ "id": data_source_id,
+ "name": data_source_name,
+ "s3Uri": f"s3://{s3_bucket}/",
+ }
+ ],
+ },
+ }
+
+ result = lisa_client.create_bedrock_kb_repository(rag_config)
+ print(f"✓ Bedrock KB repository created: {result}")
+
+ # Handle case where response doesn't contain repositoryId
+ if result is None or not isinstance(result, dict):
+ result = {"repositoryId": rag_config["repositoryId"]}
+ elif "repositoryId" not in result:
+ result["repositoryId"] = rag_config["repositoryId"]
+
+ return result
+ except Exception as e:
+ print(f"✗ Failed to create Bedrock KB repository: {e}")
+ raise
+
+
+def create_bedrock_knowledge_base(
+ deployment_name: str,
+ region: str,
+ kb_name: str = "bedrock-kb-e2e-test",
+ s3_bucket_name: str = BEDROCK_KB_S3_BUCKET,
+ embedding_model_arn: str = None,
+ skip_create: bool = False,
+) -> dict[str, Any]:
+ """Create a Bedrock Knowledge Base with S3 data source.
+
+ Args:
+ deployment_name: The LISA deployment name
+ region: AWS region
+ kb_name: Name for the knowledge base
+ s3_bucket_name: Name of the S3 bucket to create and use as data source
+ embedding_model_arn: ARN of the embedding model (defaults to Titan Embed)
+ skip_create: Skip creation if True
+
+ Returns:
+ dict containing knowledgeBaseId, dataSourceId, and s3Bucket
+ """
+ if skip_create:
+ print(f"\n⏭️ Skipping creation of Bedrock Knowledge Base '{kb_name}' (skip_create=True)")
+ return {"knowledgeBaseId": f"{kb_name}-id", "dataSourceId": f"{kb_name}-ds-id"}
+
+ print(f"\n🚀 Setting up Bedrock Knowledge Base '{kb_name}'...")
+
+ try:
+ s3_client = boto3.client("s3", region_name=region)
+ sts_client = boto3.client("sts", region_name=region)
+ iam_client = boto3.client("iam", region_name=region)
+ aoss_client = boto3.client("opensearchserverless", region_name=region)
+ bedrock_agent_client = boto3.client("bedrock-agent", region_name=region)
+
+ # Get account ID
+ account_id = sts_client.get_caller_identity()["Account"]
+
+ # 1. Check if S3 bucket exists, create only if it doesn't
+ bucket_name = f"{deployment_name}-{s3_bucket_name}"
+ print(f" Checking S3 bucket: {bucket_name}")
+
+ try:
+ s3_client.head_bucket(Bucket=bucket_name)
+ print(f"✓ S3 bucket already exists: {bucket_name}")
+ except s3_client.exceptions.NoSuchBucket:
+ print(f" Creating S3 bucket: {bucket_name}")
+ try:
+ if region == "us-east-1":
+ s3_client.create_bucket(Bucket=bucket_name)
+ else:
+ s3_client.create_bucket(
+ Bucket=bucket_name, CreateBucketConfiguration={"LocationConstraint": region}
+ )
+ print(f"✓ S3 bucket created: {bucket_name}")
+ except Exception as e:
+ print(f"⚠️ S3 bucket creation issue: {e}")
+ except Exception as e:
+ print(f"⚠️ S3 bucket check issue: {e}")
+
+ # 2. Create IAM role for Bedrock Knowledge Base
+ role_name = f"{deployment_name}-BedrockKBRole"
+ print(f" Creating IAM role: {role_name}")
+
+ trust_policy = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Principal": {"Service": "bedrock.amazonaws.com"},
+ "Action": "sts:AssumeRole",
+ }
+ ],
+ }
+
+ try:
+ role_response = iam_client.create_role(
+ RoleName=role_name,
+ AssumeRolePolicyDocument=json.dumps(trust_policy),
+ Description=f"Role for Bedrock Knowledge Base - {deployment_name}",
+ )
+ role_arn = role_response["Role"]["Arn"]
+ print(f"✓ IAM role created: {role_arn}")
+
+ # Attach necessary policies
+ policy_document = {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": ["s3:GetObject", "s3:ListBucket"],
+ "Resource": [f"arn:aws:s3:::{bucket_name}", f"arn:aws:s3:::{bucket_name}/*"],
+ },
+ {
+ "Effect": "Allow",
+ "Action": ["bedrock:InvokeModel"],
+ "Resource": "*",
+ },
+ {
+ "Effect": "Allow",
+ "Action": ["aoss:APIAccessAll"],
+ "Resource": "*",
+ },
+ ],
+ }
+
+ iam_client.put_role_policy(
+ RoleName=role_name, PolicyName=f"{role_name}-Policy", PolicyDocument=json.dumps(policy_document)
+ )
+ print("✓ IAM policy attached to role")
+
+ # Wait for role to propagate
+ time.sleep(10)
+
+ except iam_client.exceptions.EntityAlreadyExistsException:
+ role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
+ print(f"✓ IAM role already exists: {role_arn}")
+
+ # 3. Create OpenSearch Serverless collection
+ collection_name = f"{deployment_name}-kb-collection"
+ print(f" Creating OpenSearch Serverless collection: {collection_name}")
+
+ try:
+ # First, create encryption security policy
+ encryption_policy_name = f"{deployment_name}-kb-encryption"
+ encryption_policy = {
+ "Rules": [
+ {
+ "ResourceType": "collection",
+ "Resource": [f"collection/{collection_name}"],
+ }
+ ],
+ "AWSOwnedKey": True,
+ }
+
+ try:
+ aoss_client.create_security_policy(
+ name=encryption_policy_name,
+ type="encryption",
+ policy=json.dumps(encryption_policy),
+ description=f"Encryption policy for {collection_name}",
+ )
+ print(f"✓ Created encryption security policy: {encryption_policy_name}")
+ except aoss_client.exceptions.ConflictException:
+ print(f"✓ Encryption security policy already exists: {encryption_policy_name}")
+
+ # Create network security policy (allow public access for testing)
+ network_policy_name = f"{deployment_name}-kb-network"
+ network_policy = [
+ {
+ "Rules": [
+ {
+ "ResourceType": "collection",
+ "Resource": [f"collection/{collection_name}"],
+ }
+ ],
+ "AllowFromPublic": True,
+ }
+ ]
+
+ try:
+ aoss_client.create_security_policy(
+ name=network_policy_name,
+ type="network",
+ policy=json.dumps(network_policy),
+ description=f"Network policy for {collection_name}",
+ )
+ print(f"✓ Created network security policy: {network_policy_name}")
+ except aoss_client.exceptions.ConflictException:
+ print(f"✓ Network security policy already exists: {network_policy_name}")
+
+ # Create data access policy
+ data_policy_name = f"{deployment_name}-kb-data-access"
+ data_policy = [
+ {
+ "Rules": [
+ {
+ "ResourceType": "collection",
+ "Resource": [f"collection/{collection_name}"],
+ "Permission": [
+ "aoss:CreateCollectionItems",
+ "aoss:DeleteCollectionItems",
+ "aoss:UpdateCollectionItems",
+ "aoss:DescribeCollectionItems",
+ ],
+ },
+ {
+ "ResourceType": "index",
+ "Resource": [f"index/{collection_name}/*"],
+ "Permission": [
+ "aoss:CreateIndex",
+ "aoss:DeleteIndex",
+ "aoss:UpdateIndex",
+ "aoss:DescribeIndex",
+ "aoss:ReadDocument",
+ "aoss:WriteDocument",
+ ],
+ },
+ ],
+ "Principal": [role_arn, f"arn:aws:iam::{account_id}:root"],
+ }
+ ]
+
+ try:
+ aoss_client.create_access_policy(
+ name=data_policy_name,
+ type="data",
+ policy=json.dumps(data_policy),
+ description=f"Data access policy for {collection_name}",
+ )
+ print(f"✓ Created data access policy: {data_policy_name}")
+ except aoss_client.exceptions.ConflictException:
+ print(f"✓ Data access policy already exists: {data_policy_name}")
+
+ # Now create the collection
+ collection_response = aoss_client.create_collection(
+ name=collection_name, type="VECTORSEARCH", description=f"Collection for {kb_name}"
+ )
+ collection_id = collection_response["createCollectionDetail"]["id"]
+ collection_arn = collection_response["createCollectionDetail"]["arn"]
+ print(f"✓ OpenSearch Serverless collection created: {collection_id}")
+
+ # Wait for collection to be active
+ print(" Waiting for collection to be active...")
+ max_wait = 60 # 5 minutes
+ for i in range(max_wait):
+ collection_status = aoss_client.batch_get_collection(ids=[collection_id])
+ if collection_status["collectionDetails"][0]["status"] == "ACTIVE":
+ print("✓ Collection is active")
+ break
+ time.sleep(5)
+
+ except Exception as e:
+ print(f"⚠️ OpenSearch Serverless collection issue: {e}")
+ # Try to find existing collection
+ collections = aoss_client.list_collections(collectionFilters={"name": collection_name})
+ if collections["collectionSummaries"]:
+ collection_id = collections["collectionSummaries"][0]["id"]
+ collection_arn = collections["collectionSummaries"][0]["arn"]
+ print(f"✓ Using existing collection: {collection_id}")
+ else:
+ raise
+
+ # Create the vector index in OpenSearch Serverless if it doesn't exist
+ index_name = f"{kb_name}-index"
+ print(f" Creating vector index '{index_name}' in collection...")
+
+ try:
+ # Get the collection endpoint
+ collection_details = aoss_client.batch_get_collection(ids=[collection_id])
+ collection_endpoint = collection_details["collectionDetails"][0]["collectionEndpoint"]
+
+ # Remove https:// prefix if present
+ if collection_endpoint.startswith("https://"):
+ collection_endpoint = collection_endpoint[8:]
+
+ # Create OpenSearch client for the collection
+ from opensearchpy import OpenSearch, RequestsHttpConnection
+ from requests_aws4auth import AWS4Auth
+
+ credentials = boto3.Session().get_credentials()
+ awsauth = AWS4Auth(
+ credentials.access_key,
+ credentials.secret_key,
+ region,
+ "aoss",
+ session_token=credentials.token,
+ )
+
+ os_client = OpenSearch(
+ hosts=[{"host": collection_endpoint, "port": 443}],
+ http_auth=awsauth,
+ use_ssl=True,
+ verify_certs=True,
+ connection_class=RequestsHttpConnection,
+ timeout=30,
+ )
+
+ # Check if index exists
+ if not os_client.indices.exists(index=index_name):
+ # Create index with vector field mapping
+ index_body = {
+ "settings": {"index.knn": True},
+ "mappings": {
+ "properties": {
+ "embedding": {
+ "type": "knn_vector",
+ "dimension": 1024, # Titan Embed v2 dimension
+ "method": {
+ "name": "hnsw",
+ "engine": "faiss",
+ "parameters": {"ef_construction": 512, "m": 16},
+ },
+ },
+ "text": {"type": "text"},
+ "metadata": {"type": "text"},
+ }
+ },
+ }
+
+ os_client.indices.create(index=index_name, body=index_body)
+ print(f"✓ Created vector index: {index_name}")
+ else:
+ print(f"✓ Vector index already exists: {index_name}")
+
+ except ImportError:
+ print("⚠️ opensearch-py not installed, skipping index creation")
+ print(" Install with: pip install opensearch-py requests-aws4auth")
+ except Exception as e:
+ print(f"⚠️ Could not create vector index: {e}")
+ print(" The Knowledge Base creation may fail if the index doesn't exist")
+
+ # 4. Set default embedding model if not provided
+ if not embedding_model_arn:
+ embedding_model_arn = f"arn:aws:bedrock:{region}::foundation-model/amazon.titan-embed-text-v2:0"
+
+ # 5. Check if Knowledge Base already exists
+ print(f" Checking if Knowledge Base '{kb_name}' exists...")
+ existing_kb_id = None
+ existing_ds_id = None
- # Clean up models
- for model_id in created_resources.get("models", []):
try:
- lisa_client.delete_model(model_id)
- print(f"✓ Deleted model: {model_id}")
+ # List all knowledge bases and check if one with our name exists
+ kb_list = bedrock_agent_client.list_knowledge_bases()
+ for kb in kb_list.get("knowledgeBaseSummaries", []):
+ if kb.get("name") == kb_name:
+ existing_kb_id = kb.get("knowledgeBaseId")
+ print(f"✓ Knowledge Base already exists: {existing_kb_id}")
+
+ # Get data sources for this KB
+ ds_list = bedrock_agent_client.list_data_sources(knowledgeBaseId=existing_kb_id)
+ if ds_list.get("dataSourceSummaries"):
+ existing_ds_id = ds_list["dataSourceSummaries"][0]["dataSourceId"]
+ print(f"✓ Data source already exists: {existing_ds_id}")
+ break
except Exception as e:
- print(f"✗ Failed to delete model {model_id}: {e}")
+ print(f" Could not check existing knowledge bases: {e}")
+
+ # If KB exists, return existing info
+ if existing_kb_id:
+ result = {
+ "knowledgeBaseId": existing_kb_id,
+ "dataSourceId": existing_ds_id,
+ "s3Bucket": bucket_name,
+ "collectionId": collection_id,
+ "roleArn": role_arn,
+ }
+ print("✓ Using existing Bedrock Knowledge Base")
+ return result
+
+ # 6. Create Knowledge Base with OpenSearch Serverless
+ print(f" Creating Knowledge Base with OpenSearch Serverless and embedding model: {embedding_model_arn}")
+
+ kb_response = bedrock_agent_client.create_knowledge_base(
+ name=kb_name,
+ description=f"Test Knowledge Base for LISA integration testing - {deployment_name}",
+ roleArn=role_arn,
+ knowledgeBaseConfiguration={
+ "type": "VECTOR",
+ "vectorKnowledgeBaseConfiguration": {
+ "embeddingModelArn": embedding_model_arn,
+ },
+ },
+ storageConfiguration={
+ "type": "OPENSEARCH_SERVERLESS",
+ "opensearchServerlessConfiguration": {
+ "collectionArn": collection_arn,
+ "vectorIndexName": f"{kb_name}-index",
+ "fieldMapping": {
+ "vectorField": "embedding",
+ "textField": "text",
+ "metadataField": "metadata",
+ },
+ },
+ },
+ )
+
+ knowledge_base_id = kb_response["knowledgeBase"]["knowledgeBaseId"]
+ print(f"✓ Knowledge Base created: {knowledge_base_id}")
+
+ # 7. Create S3 Data Source
+ print(f" Creating S3 data source for bucket: {bucket_name}")
+
+ ds_response = bedrock_agent_client.create_data_source(
+ knowledgeBaseId=knowledge_base_id,
+ name=f"{kb_name}-s3-source",
+ description=f"S3 data source for {kb_name}",
+ dataSourceConfiguration={
+ "type": "S3",
+ "s3Configuration": {
+ "bucketArn": f"arn:aws:s3:::{bucket_name}",
+ "inclusionPrefixes": ["documents/"],
+ },
+ },
+ )
+
+ data_source_id = ds_response["dataSource"]["dataSourceId"]
+ print(f"✓ Data source created: {data_source_id}")
+
+ result = {
+ "knowledgeBaseId": knowledge_base_id,
+ "dataSourceId": data_source_id,
+ "s3Bucket": bucket_name,
+ "collectionId": collection_id,
+ "roleArn": role_arn,
+ }
+
+ print(f"✓ Bedrock Knowledge Base setup complete: {result}")
+ return result
+
+ except Exception as e:
+ print(f"✗ Failed to create Bedrock Knowledge Base: {e}")
+ import traceback
+
+ traceback.print_exc()
+ raise
+
+
+def cleanup_all_models(lisa_client: LisaApi) -> None:
+ """Clean up all models by listing and deleting each one.
+
+ Args:
+ lisa_client: LISA API client
+ """
+ print("\n🧹 Cleaning up all models...")
+
+ try:
+ models = lisa_client.list_models()
+ if not models:
+ print(" No models found to delete")
+ return
+
+ print(f" Found {len(models)} models to delete")
+ for model in models:
+ model_id = model.get("modelId")
+ if model_id:
+ try:
+ lisa_client.delete_model(model_id)
+ print(f"✓ Deleted model: {model_id}")
+ except Exception as e:
+ print(f"✗ Failed to delete model {model_id}: {e}")
+ except Exception as e:
+ print(f"✗ Failed to list models for cleanup: {e}")
+
+
+def cleanup_all_repositories(lisa_client: LisaApi) -> None:
+ """Clean up all repositories by listing and deleting each one.
+
+ Args:
+ lisa_client: LISA API client
+ """
+ print("\n🧹 Cleaning up all repositories...")
- # Clean up repositories
- for repo_id in created_resources.get("repositories", []):
+ try:
+ repositories = lisa_client.list_repositories()
+ if not repositories:
+ print(" No repositories found to delete")
+ return
+
+ print(f" Found {len(repositories)} repositories to delete")
+ for repo in repositories:
+ repo_id = repo.get("repositoryId")
+ if repo_id:
+ try:
+ lisa_client.delete_repository(repo_id)
+ print(f"✓ Deleted repository: {repo_id}")
+ except Exception as e:
+ print(f"✗ Failed to delete repository {repo_id}: {e}")
+ except Exception as e:
+ print(f"✗ Failed to list repositories for cleanup: {e}")
+
+
+def cleanup_resources(lisa_client: LisaApi, created_resources: dict[str, list]):
+ """Clean up created resources including Bedrock Knowledge Bases.
+
+ Args:
+ lisa_client: LISA API client
+ created_resources: Dictionary containing lists of created resource IDs
+ """
+ print("\n🧹 Cleaning up resources...")
+
+ # Clean up all models
+ cleanup_all_models(lisa_client)
+
+ # Clean up all repositories
+ cleanup_all_repositories(lisa_client)
+
+ # Clean up Bedrock Knowledge Bases
+ for kb_info in created_resources.get("knowledge_bases", []):
try:
- lisa_client.delete_repository(repo_id)
- print(f"✓ Deleted repository: {repo_id}")
+ bedrock_agent_client = boto3.client("bedrock-agent")
+ s3_client = boto3.client("s3")
+
+ kb_id = kb_info.get("knowledgeBaseId")
+ s3_bucket = kb_info.get("s3Bucket")
+
+ # Delete data source first
+ if "dataSourceId" in kb_info:
+ try:
+ bedrock_agent_client.delete_data_source(knowledgeBaseId=kb_id, dataSourceId=kb_info["dataSourceId"])
+ print(f"✓ Deleted data source: {kb_info['dataSourceId']}")
+ except Exception as e:
+ print(f"✗ Failed to delete data source {kb_info['dataSourceId']}: {e}")
+
+ # Delete knowledge base
+ try:
+ bedrock_agent_client.delete_knowledge_base(knowledgeBaseId=kb_id)
+ print(f"✓ Deleted knowledge base: {kb_id}")
+ except Exception as e:
+ print(f"✗ Failed to delete knowledge base {kb_id}: {e}")
+
+ # Delete S3 bucket (empty it first)
+ if s3_bucket:
+ try:
+ # Delete all objects in bucket
+ paginator = s3_client.get_paginator("list_objects_v2")
+ for page in paginator.paginate(Bucket=s3_bucket):
+ if "Contents" in page:
+ objects = [{"Key": obj["Key"]} for obj in page["Contents"]]
+ s3_client.delete_objects(Bucket=s3_bucket, Delete={"Objects": objects})
+
+ # Delete bucket
+ s3_client.delete_bucket(Bucket=s3_bucket)
+ print(f"✓ Deleted S3 bucket: {s3_bucket}")
+ except Exception as e:
+ print(f"✗ Failed to delete S3 bucket {s3_bucket}: {e}")
+
except Exception as e:
- print(f"✗ Failed to delete repository {repo_id}: {e}")
+ print(f"✗ Failed to delete knowledge base {kb_info}: {e}")
def main():
@@ -583,7 +1207,7 @@ def main():
parser.add_argument("--deployment-name", required=True, help="LISA deployment name for authentication")
parser.add_argument("--deployment-stage", required=True, help="LISA deployment stage for authentication")
parser.add_argument("--deployment-prefix", required=True, help="LISA deployment prefix")
- parser.add_argument("--verify", default="false", help="Verify SSL certificates")
+ parser.add_argument("--verify", default="true", help="Verify SSL certificates (default: true)")
parser.add_argument("--profile", help="AWS profile to use")
parser.add_argument("--cleanup", action="store_true", help="Clean up resources (delete models and repositories)")
parser.add_argument(
@@ -609,38 +1233,32 @@ def main():
auth_headers = setup_authentication(args.deployment_name, args.deployment_stage)
+ # Get account ID and region for self-hosted model ECR images
+ sts_client = boto3.client("sts")
+ account_id = sts_client.get_caller_identity()["Account"]
+ region = os.environ.get("AWS_REGION", "us-east-1")
+ print(f"Account ID: {account_id}")
+ print(f"Region: {region}")
+
# Initialize LISA client with authentication
lisa_client = LisaApi(url=args.api, verify=verify_ssl, headers=auth_headers)
- created_resources = {"models": [], "repositories": []}
-
- # If cleanup-only mode, skip all creation and just populate resource IDs
- if args.cleanup and args.skip_create:
- print("\n🧹 Cleanup-only mode: Collecting resource IDs for deletion...")
-
- # Define all resource IDs that would be created
- created_resources["models"] = [
- "nova-lite",
- "nova-canvas",
- "haiku-45",
- "sonnet-45",
- "titan-embed",
- "mistral-7b-instruct-03",
- "llama-32-1b-instruct",
- "gpt-oss-20b",
- DEFAULT_EMBEDDING_MODEL_ID,
- "qwen3-embed-06b",
- "baai-embed-15",
- ]
- created_resources["repositories"] = [
- "pgv-rag",
- "os-rag",
- ]
+ created_resources = {"models": [], "repositories": [], "knowledge_bases": []}
- print(f" Models to delete: {created_resources['models']}")
- print(f" Repositories to delete: {created_resources['repositories']}")
+ # If cleanup mode, skip all model and repository creation and perform cleanup
+ if args.cleanup:
+ print("\n🧹 Cleanup mode: Skipping resource creation and performing cleanup...")
+
+ # Populate knowledge base info for cleanup
+ created_resources["knowledge_bases"] = [
+ {
+ "knowledgeBaseId": "bedrock-kb-e2e-test-id",
+ "dataSourceId": "bedrock-kb-e2e-test-ds-id",
+ "s3Bucket": f"{args.deployment_name}-{BEDROCK_KB_S3_BUCKET}",
+ }
+ ]
- # Skip to cleanup
+ # Perform cleanup (will list and delete all models and repositories)
cleanup_resources(lisa_client, created_resources)
print("\n🧹 Cleanup completed!")
print("\n✅ Integration setup test completed successfully!")
@@ -656,7 +1274,7 @@ def main():
print("⚠️ No embedding models found, repositories will be created without default embedding model")
models = []
- # # 1. Create Bedrock model
+ # 1. Create Bedrock models
models.extend(
[
create_bedrock_model(
@@ -690,39 +1308,93 @@ def main():
[],
skip_create=args.skip_create,
),
- create_bedrock_model(
- lisa_client,
- "nova-canvas",
- "bedrock/amazon.nova-canvas-v1:0",
- "imagegen",
- [],
- skip_create=args.skip_create,
- ),
]
)
- # # 2. Create self-hosted model
+ # 2. Create self-hosted model
models.extend(
[
create_self_hosted_model(
lisa_client,
"mistral-7b-instruct-03",
"mistralai/Mistral-7B-Instruct-v0.3",
+ base_image=f"{account_id}.dkr.ecr.{region}.amazonaws.com/lisa-vllm:latest",
skip_create=args.skip_create,
),
create_self_hosted_model(
lisa_client,
"llama-32-1b-instruct",
"meta-llama/Llama-3.2-1B-Instruct",
+ base_image=f"{account_id}.dkr.ecr.{region}.amazonaws.com/lisa-vllm:latest",
+ skip_create=args.skip_create,
+ ),
+ create_self_hosted_model(
+ lisa_client,
+ "gpt-oss-20b",
+ "openai/gpt-oss-20b",
+ base_image=f"{account_id}.dkr.ecr.{region}.amazonaws.com/lisa-vllm:latest",
+ skip_create=args.skip_create,
+ ),
+ create_self_hosted_model(
+ lisa_client=lisa_client,
+ model_id="gpt-oss-120b",
+ model_name="openai/gpt-oss-120b",
skip_create=args.skip_create,
+ base_image=f"{account_id}.dkr.ecr.{region}.amazonaws.com/lisa-vllm:gptoss",
+ instance_type="g6.48xlarge",
+ blockDeviceVolumeSize=300,
+ sharedMemorySize=65536,
+ memoryReservation=740000,
+ environment={
+ "VLLM_ATTENTION_BACKEND": "TRITON_ATTN_VLLM_V1",
+ "VLLM_TENSOR_PARALLEL_SIZE": "8",
+ "VLLM_ASYNC_SCHEDULING": "true",
+ "VLLM_MAX_PARALLEL_LOADING_WORKERS": "8",
+ "VLLM_USE_TQDM_ON_LOAD": "true",
+ "THREADS": "4",
+ "VLLM_MAX_MODEL_LEN": "4096",
+ "VLLM_GPU_MEMORY_UTILIZATION": "0.90",
+ "VLLM_MAX_NUM_BATCHED_TOKENS": "8192",
+ "VLLM_MAX_NUM_SEQS": "128",
+ "VLLM_MAX_CONCURRENT_REQUESTS": "32",
+ "VLLM_ENABLE_PREFIX_CACHING": "true",
+ "VLLM_ENABLE_CHUNKED_PREFILL": "true",
+ "VLLM_DTYPE": "auto",
+ },
),
+ # Llama-4-Scout 4-bit quantized - MoE model (109B total, 17B active)
+ # For 1M context: use p5.48xlarge (8x H100, 640GB VRAM)
+ # For 32K context: use g6.12xlarge (4x L4, 89GB VRAM) - configured below
+ # Llama-4-Scout 4-bit on g5.48xlarge (8x A10G, 192GB VRAM) - 128K context
create_self_hosted_model(
- lisa_client, "gpt-oss-20b", "openai/gpt-oss-20b", skip_create=args.skip_create
+ lisa_client=lisa_client,
+ model_id="llama4-scout-17b-4bit-128k",
+ model_name="unsloth/Llama-4-Scout-17B-16E-Instruct-unsloth-bnb-4bit",
+ skip_create=args.skip_create,
+ base_image=f"{account_id}.dkr.ecr.{region}.amazonaws.com/lisa-vllm:latest",
+ instance_type="g5.48xlarge", # 8x A10G (192GB) for 128K context
+ blockDeviceVolumeSize=300,
+ sharedMemorySize=32768,
+ memoryReservation=700000,
+ environment={
+ # Llama-4-Scout MoE optimizations for g5.48xlarge (128K context)
+ # 8x A10G = 192GB VRAM total
+ "VLLM_MAX_MODEL_LEN": "131072", # 128K token context (max for 192GB)
+ "VLLM_TENSOR_PARALLEL_SIZE": "8", # Distribute across 8 A10Gs
+ "VLLM_GPU_MEMORY_UTILIZATION": "0.92", # High but safe utilization
+ "VLLM_MAX_NUM_BATCHED_TOKENS": "16384", # Moderate batch size
+ "VLLM_MAX_NUM_SEQS": "64", # Concurrent sequences
+ "VLLM_ENABLE_PREFIX_CACHING": "true", # Cache common prefixes
+ "VLLM_ENABLE_CHUNKED_PREFILL": "true", # Better memory efficiency
+ "VLLM_DTYPE": "auto", # Let vLLM detect from model config
+ "VLLM_ATTENTION_BACKEND": "FLASH_ATTN", # Flash attention for long context
+ "VLLM_ENFORCE_EAGER": "false", # Allow CUDA graphs
+ },
),
]
)
- # # 3. Create self-hosted embedded model
+ # 3. Create self-hosted embedded model
models.extend(
[
create_self_hosted_embedded_model(
@@ -753,6 +1425,28 @@ def main():
for repo in repos:
created_resources["repositories"].append(repo["repositoryId"])
+ # 6. Create Bedrock Knowledge Base with S3 data source
+ kb_result = create_bedrock_knowledge_base(
+ deployment_name=args.deployment_name,
+ region=os.environ.get("AWS_REGION", "us-east-1"),
+ kb_name="bedrock-kb-e2e-test",
+ skip_create=args.skip_create,
+ )
+ created_resources["knowledge_bases"].append(kb_result)
+
+ # 7. Create Bedrock KB repository using the Knowledge Base
+ if kb_result.get("knowledgeBaseId") and kb_result.get("dataSourceId"):
+ bedrock_kb_repo = create_bedrock_kb_repository(
+ lisa_client=lisa_client,
+ knowledge_base_id=kb_result["knowledgeBaseId"],
+ data_source_id=kb_result["dataSourceId"],
+ data_source_name="bedrock-kb-e2e-test-s3-source",
+ s3_bucket=kb_result["s3Bucket"],
+ embedding_model_id=embedding_model_id,
+ skip_create=args.skip_create,
+ )
+ created_resources["repositories"].append(bedrock_kb_repo["repositoryId"])
+
if not args.skip_create:
print("\n✅ All resources created successfully!")
else:
@@ -760,6 +1454,7 @@ def main():
print("Resources:")
print(f" Models: {created_resources['models']}")
print(f" Repositories: {created_resources['repositories']}")
+ print(f" Knowledge Bases: {[kb.get('knowledgeBaseId') for kb in created_resources['knowledge_bases']]}")
# Wait for resources to be ready if requested
if args.wait:
@@ -786,13 +1481,8 @@ def main():
else:
print("\n⚠️ Some resources may not be ready yet")
- # Clean up if requested
- if args.cleanup:
- cleanup_resources(lisa_client, created_resources)
- print("\n🧹 Cleanup completed!")
- else:
- print("\n💡 To clean up resources later, run this script with --cleanup flag")
- print(" Or manually delete the resources through the LISA UI")
+ print("\n💡 To clean up resources later, run this script with --cleanup flag")
+ print(" Or manually delete the resources through the LISA UI")
print("\n✅ Integration setup test completed successfully!")
return 0
diff --git a/test/python/integration-setup-test.sh b/test/python/integration-setup-test.sh
index 2e466ec39..7aced6216 100755
--- a/test/python/integration-setup-test.sh
+++ b/test/python/integration-setup-test.sh
@@ -2,7 +2,7 @@
# Integration setup test script that deploys resources to LISA
# Uses the existing authentication setup from integration-test.sh
-PROJECT_DIR="$(pwd)"
+PROJECT_DIR="$(pwd)/../../"
# Read config values with defaults for missing fields
PROFILE=$(cat ${PROJECT_DIR}/config-custom.yaml | yq -r '.profile // "default"')
@@ -82,7 +82,7 @@ while [[ $# -gt 0 ]]; do
done
if [ -z $VERIFY ]; then
- VERIFY=false
+ VERIFY=true
fi
echo "Using settings: PROFILE-${PROFILE}, DEPLOYMENT_NAME-${DEPLOYMENT_NAME}, APP_NAME-${APP_NAME}, DEPLOYMENT_STAGE-${DEPLOYMENT_STAGE}, REGION-${REGION}, VERIFY-${VERIFY}, API_URL-${API_URL}, ALB_URL-${ALB_URL}"
diff --git a/test/python/integration-test.sh b/test/python/integration-test.sh
index 5e7427d5f..a5152645c 100755
--- a/test/python/integration-test.sh
+++ b/test/python/integration-test.sh
@@ -1,7 +1,7 @@
#!/bin/bash
# Runs the lisa-sdk pytest as an integration test
-PROJECT_DIR="$(pwd)"
+PROJECT_DIR="$(pwd)/../../"
PROFILE=$(cat ${PROJECT_DIR}/config-custom.yaml | yq -r .profile)
REGION=$(cat ${PROJECT_DIR}/config-custom.yaml | yq -r .region)
DEPLOYMENT_NAME=$(cat ${PROJECT_DIR}/config-custom.yaml | yq -r .deploymentName)
diff --git a/test/rest-api/README.md b/test/rest-api/README.md
new file mode 100644
index 000000000..058eea73c
--- /dev/null
+++ b/test/rest-api/README.md
@@ -0,0 +1,215 @@
+# REST API Unit Tests
+
+This directory contains comprehensive unit tests for the LISA REST API (`lib/serve/rest-api/src`).
+
+## Test Structure
+
+```
+test/rest-api/
+├── conftest.py # Shared fixtures and test configuration
+├── test_utils.py # Tests for utility modules (cache, decorators, resources)
+├── test_auth.py # Tests for authentication and authorization
+├── test_request_utils.py # Tests for request validation and processing
+├── test_guardrails.py # Tests for guardrails functionality
+├── test_metrics.py # Tests for metrics collection
+├── test_routes.py # Tests for API routes and endpoints
+└── README.md # This file
+```
+
+## Current Test Coverage
+
+### Utils Module (11 tests) ✅
+- **Cache Manager**: Set/get cache, persistence, updates
+- **Singleton Decorator**: Single instance creation, state preservation
+- **Resources**: ModelType and RestApiResource enums
+
+### Auth Module (22 tests) ✅
+- **AuthHeaders**: Enum values and methods
+- **Token Extraction**: Authorization and Api-Key headers
+- **Group Membership**: Simple and nested JWT properties
+- **JWT Group Extraction**: Various property paths and edge cases
+- **User Context**: API users, JWT users, group membership
+
+### Request Utils Module (5 tests) ✅
+- **Model Validation**: Registered models, unsupported models
+- **Stream Exception Handling**: Normal operation, error formatting
+
+Note: Full request_utils testing requires lisa_serve.registry which has external dependencies (text_generation, etc.) not available in the test environment.
+
+### Guardrails Module (18 tests) ✅
+- **Model Guardrails**: Retrieval, empty results, error handling
+- **Applicable Guardrails**: Public/group-specific, deletion markers
+- **Violation Detection**: Error message parsing
+- **Response Extraction**: Guardrail response text
+- **Streaming Responses**: Format, chunks, completion markers
+- **JSON Responses**: Structure, status codes, metadata
+
+### Metrics Module (12 tests) ✅
+- **Message Extraction**: Simple/array content, RAG context, tool calls
+- **Metrics Publishing**: Success, error handling, queue configuration, session IDs
+
+### Routes Module (7 tests) ✅
+- **Health Check**: Logic validation for success, missing vars, exceptions
+- **Router/Middleware/Lifespan/Passthrough**: Placeholder tests (full testing requires complete app with aiobotocore, text_generation, etc.)
+
+Note: Full routes/middleware/lifespan testing requires the complete FastAPI application with all dependencies (aiobotocore, text_generation, etc.) which are not available in the unit test environment. These are covered by integration tests.
+
+**Total: 81 passing unit tests**
+
+These tests provide comprehensive coverage of the core business logic in the REST API while avoiding dependencies on external packages (text_generation, aiobotocore, etc.) that are not available in the test environment.
+
+## Running Tests
+
+### Run All REST API Tests
+
+```bash
+pytest test/rest-api -v
+```
+
+### Run Specific Test File
+
+```bash
+pytest test/rest-api/test_auth.py -v
+pytest test/rest-api/test_guardrails.py -v
+pytest test/rest-api/test_metrics.py -v
+```
+
+### Run with Coverage
+
+```bash
+pytest test/rest-api --cov=lib/serve/rest-api/src --cov-report=html
+```
+
+### Run Specific Test Class
+
+```bash
+pytest test/rest-api/test_auth.py::TestApiTokenAuthorizer -v
+```
+
+### Run via Make
+
+```bash
+make test-rest-api
+```
+
+## Test Approach
+
+These tests follow the principle of **high-level, isolated testing**:
+
+1. **Fully Isolated** - No external dependencies (AWS, databases, etc.)
+2. **Mocked Dependencies** - All AWS services and external calls are mocked
+3. **Fast Execution** - All tests run in ~1 second
+4. **Focused Testing** - Tests focus on business logic, not implementation details
+5. **Comprehensive Coverage** - Tests cover success paths, error cases, and edge cases
+
+## Module Import Challenges
+
+The REST API uses relative imports (e.g., `from .utils import ...`) which makes direct testing challenging. Our approach:
+
+1. **Add src to path**: Tests add `lib/serve/rest-api/src` to `sys.path`
+2. **Import modules directly**: Import from module names (e.g., `from utils import ...`)
+3. **Test public interfaces**: Focus on testing exported functions and classes
+
+## Test Categories
+
+### Authentication Tests (`test_auth.py`)
+- Token validation (API tokens, management tokens, JWT)
+- Group membership and access control
+- User context extraction
+- Authorization for different user types
+
+### Request Utilities Tests (`test_request_utils.py`)
+- Model validation against registered models
+- Model and validator retrieval from cache/registry
+- Request preparation and validation
+- Stream exception handling
+
+### Guardrails Tests (`test_guardrails.py`)
+- Guardrail retrieval from DynamoDB
+- Determining applicable guardrails based on user groups
+- Violation detection and response extraction
+- Streaming and JSON response formatting
+
+### Metrics Tests (`test_metrics.py`)
+- Message extraction for metrics calculation
+- Metrics event publishing to SQS
+- RAG context and tool call detection
+- Error handling and queue configuration
+
+### Routes Tests (`test_routes.py`)
+- Health check endpoint
+- Router configuration with/without auth
+- Middleware functionality (request IDs, CORS, errors)
+- Application lifespan and model loading
+- LiteLLM passthrough endpoint
+
+## Fixtures
+
+The `conftest.py` file provides shared fixtures:
+
+- `mock_env_vars`: Environment variables for testing
+- `mock_request`: Mock FastAPI Request object
+- `mock_jwt_data`: Mock JWT data for regular users
+- `mock_admin_jwt_data`: Mock JWT data for admin users
+- `mock_token_info`: Mock API token info from DynamoDB
+- `mock_admin_token_info`: Mock admin API token info
+- `mock_boto3_client`: Mock boto3 clients (DynamoDB, Secrets Manager, SSM, SQS)
+- `mock_guardrails`: Mock guardrails data
+- `mock_registered_models`: Mock registered models cache
+- `simple_fastapi_app`: Simple FastAPI app for testing
+- `test_client`: TestClient for FastAPI app
+
+## Dependencies
+
+The REST API tests require:
+
+- `fastapi` - Web framework
+- `pytest` - Testing framework
+- `pytest-asyncio` - Async test support
+- `pydantic` - Data validation
+
+These are already included in the main project dependencies.
+
+## CI/CD Integration
+
+These tests are included in:
+
+- `make test` - Run all unit tests
+- `make test-coverage` - Run with coverage reporting
+- `make test-rest-api` - Run only REST API tests
+
+The tests are fast and have no external dependencies, making them ideal for CI/CD pipelines.
+
+## Coverage Summary
+
+The test suite provides comprehensive coverage of:
+
+✅ Authentication and authorization logic
+✅ Request validation and processing
+✅ Guardrails application and violation handling
+✅ Metrics collection and publishing
+✅ API routes and health checks
+✅ Middleware functionality
+✅ Error handling and edge cases
+
+## Future Enhancements
+
+Potential improvements to the test suite:
+
+- [ ] Add tests for handler modules (embeddings, generation, models)
+- [ ] Add tests for RDS authentication utilities
+- [ ] Add tests for LiteLLM config generation
+- [ ] Add integration tests with real FastAPI TestClient
+- [ ] Add performance/load tests
+- [ ] Add tests for WebSocket endpoints (if any)
+
+## Contributing
+
+When adding new tests:
+
+1. Follow the existing test structure and naming conventions
+2. Use descriptive test names that explain what is being tested
+3. Mock all external dependencies (AWS services, HTTP requests, etc.)
+4. Test both success and failure scenarios
+5. Add docstrings to test classes and methods
+6. Update this README with new test counts and categories
diff --git a/test/rest-api/__init__.py b/test/rest-api/__init__.py
new file mode 100644
index 000000000..4139ae4d0
--- /dev/null
+++ b/test/rest-api/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/test/rest-api/conftest.py b/test/rest-api/conftest.py
new file mode 100644
index 000000000..4e6f3646f
--- /dev/null
+++ b/test/rest-api/conftest.py
@@ -0,0 +1,213 @@
+# 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.
+
+"""Fixtures for REST API unit tests."""
+
+from unittest.mock import MagicMock, Mock
+
+import pytest
+from fastapi import FastAPI, Request
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture
+def mock_env_vars(monkeypatch):
+ """Set up mock environment variables for testing."""
+ env_vars = {
+ "AWS_REGION": "us-east-1",
+ "LOG_LEVEL": "INFO",
+ "USE_AUTH": "false",
+ "TOKEN_TABLE_NAME": "test-token-table",
+ "MANAGEMENT_KEY_NAME": "test-management-key",
+ "CLIENT_ID": "test-client-id",
+ "AUTHORITY": "https://test-authority.com",
+ "ADMIN_GROUP": "admin",
+ "USER_GROUP": "users",
+ "JWT_GROUPS_PROP": "cognito:groups",
+ "REGISTERED_MODELS_PS_NAME": "/test/models",
+ "GUARDRAILS_TABLE_NAME": "test-guardrails-table",
+ "USAGE_METRICS_QUEUE_URL": "",
+ "LITELLM_KEY": "test-litellm-key",
+ }
+ for key, value in env_vars.items():
+ monkeypatch.setenv(key, value)
+ return env_vars
+
+
+@pytest.fixture
+def mock_request():
+ """Create a mock FastAPI Request object."""
+ request = Mock(spec=Request)
+ request.headers = {}
+ request.method = "GET"
+ request.url = Mock()
+ request.url.path = "/test"
+
+ # Use a simple object for state instead of Mock to allow attribute deletion
+ class State:
+ pass
+
+ request.state = State()
+ return request
+
+
+@pytest.fixture
+def mock_jwt_data() -> dict:
+ """Mock JWT data for testing."""
+ return {
+ "sub": "user-123",
+ "username": "testuser",
+ "cognito:groups": ["users", "developers"],
+ "email": "test@example.com",
+ "exp": 9999999999, # Far future
+ "iat": 1000000000,
+ "iss": "https://test-authority.com",
+ "aud": "test-client-id",
+ }
+
+
+@pytest.fixture
+def mock_admin_jwt_data() -> dict:
+ """Mock JWT data for admin user."""
+ return {
+ "sub": "admin-123",
+ "username": "adminuser",
+ "cognito:groups": ["admin", "users"],
+ "email": "admin@example.com",
+ "exp": 9999999999,
+ "iat": 1000000000,
+ "iss": "https://test-authority.com",
+ "aud": "test-client-id",
+ }
+
+
+@pytest.fixture
+def mock_token_info() -> dict:
+ """Mock API token info from DynamoDB."""
+ return {
+ "token": "hashed-token-value",
+ "tokenUUID": "token-uuid-123",
+ "tokenExpiration": 9999999999, # Far future
+ "username": "api-user",
+ "groups": ["users"],
+ }
+
+
+@pytest.fixture
+def mock_admin_token_info() -> dict:
+ """Mock admin API token info from DynamoDB."""
+ return {
+ "token": "hashed-admin-token",
+ "tokenUUID": "admin-token-uuid",
+ "tokenExpiration": 9999999999,
+ "username": "api-admin",
+ "groups": ["admin", "users"],
+ }
+
+
+@pytest.fixture
+def simple_fastapi_app():
+ """Create a simple FastAPI app for testing."""
+ app = FastAPI()
+
+ @app.get("/test")
+ async def test_endpoint():
+ return {"message": "test"}
+
+ @app.get("/health")
+ async def health():
+ return {"status": "OK"}
+
+ return app
+
+
+@pytest.fixture
+def test_client(simple_fastapi_app):
+ """Create a test client for the FastAPI app."""
+ return TestClient(simple_fastapi_app)
+
+
+@pytest.fixture
+def mock_boto3_client(monkeypatch):
+ """Mock boto3 clients."""
+ mock_ddb_table = MagicMock()
+ mock_secrets_client = MagicMock()
+ mock_ssm_client = MagicMock()
+ mock_sqs_client = MagicMock()
+
+ def mock_resource(service_name, **kwargs):
+ if service_name == "dynamodb":
+ mock_resource = MagicMock()
+ mock_resource.Table.return_value = mock_ddb_table
+ return mock_resource
+ return MagicMock()
+
+ def mock_client(service_name, **kwargs):
+ if service_name == "secretsmanager":
+ return mock_secrets_client
+ elif service_name == "ssm":
+ return mock_ssm_client
+ elif service_name == "sqs":
+ return mock_sqs_client
+ return MagicMock()
+
+ monkeypatch.setattr("boto3.resource", mock_resource)
+ monkeypatch.setattr("boto3.client", mock_client)
+
+ return {
+ "dynamodb_table": mock_ddb_table,
+ "secrets_manager": mock_secrets_client,
+ "ssm": mock_ssm_client,
+ "sqs": mock_sqs_client,
+ }
+
+
+@pytest.fixture
+def mock_guardrails():
+ """Mock guardrails data."""
+ return [
+ {
+ "guardrailName": "content-filter",
+ "modelId": "test-model",
+ "allowedGroups": ["users"],
+ "markedForDeletion": False,
+ },
+ {
+ "guardrailName": "pii-filter",
+ "modelId": "test-model",
+ "allowedGroups": [],
+ "markedForDeletion": False,
+ },
+ ]
+
+
+@pytest.fixture
+def mock_registered_models():
+ """Mock registered models cache."""
+ return {
+ "textgen": {"ecs.textgen.tgi": ["test-model", "other-model"]},
+ "embedding": {"ecs.embedding.tei": ["embedding-model"]},
+ "embeddings": {"ecs.embedding.tei": ["embedding-model"]},
+ "generate": {"ecs.textgen.tgi": ["test-model", "other-model"]},
+ "generateStream": {"ecs.textgen.tgi": ["test-model"]},
+ "metadata": {
+ "ecs.textgen.tgi.test-model": {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ "modelType": "textgen",
+ "modelKwargs": {},
+ }
+ },
+ "endpointUrls": {"ecs.textgen.tgi.test-model": "http://test-endpoint"},
+ }
diff --git a/test/rest-api/test_auth.py b/test/rest-api/test_auth.py
new file mode 100644
index 000000000..2dda76dd4
--- /dev/null
+++ b/test/rest-api/test_auth.py
@@ -0,0 +1,248 @@
+# 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.
+
+"""Unit tests for REST API authentication."""
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class TestAuthHeaders:
+ """Test suite for AuthHeaders enum."""
+
+ def test_auth_headers_values(self):
+ """Test AuthHeaders enum values."""
+ # Import inside test to avoid module-level import issues
+ from auth import AuthHeaders
+
+ assert AuthHeaders.AUTHORIZATION == "Authorization"
+ assert AuthHeaders.API_KEY == "Api-Key"
+
+ def test_auth_headers_values_method(self):
+ """Test AuthHeaders.values() returns all header names."""
+ from auth import AuthHeaders
+
+ values = AuthHeaders.values()
+ assert "Authorization" in values
+ assert "Api-Key" in values
+ assert len(values) == 2
+
+
+class TestGetAuthorizationToken:
+ """Test suite for get_authorization_token function."""
+
+ def test_get_token_from_authorization_header(self):
+ """Test extracting token from Authorization header."""
+ from auth import get_authorization_token
+
+ headers = {"Authorization": "Bearer test-token-123"}
+ token = get_authorization_token(headers)
+ assert token == "test-token-123"
+
+ def test_get_token_without_bearer_prefix(self):
+ """Test extracting token without Bearer prefix."""
+ from auth import get_authorization_token
+
+ headers = {"Authorization": "test-token-123"}
+ token = get_authorization_token(headers)
+ assert token == "test-token-123"
+
+ def test_get_token_from_lowercase_header(self):
+ """Test extracting token from lowercase header."""
+ from auth import get_authorization_token
+
+ headers = {"authorization": "Bearer test-token-123"}
+ token = get_authorization_token(headers)
+ assert token == "test-token-123"
+
+ def test_get_token_from_api_key_header(self):
+ """Test extracting token from Api-Key header."""
+ from auth import AuthHeaders, get_authorization_token
+
+ headers = {"Api-Key": "Bearer test-token-123"}
+ token = get_authorization_token(headers, AuthHeaders.API_KEY)
+ assert token == "test-token-123"
+
+ def test_get_token_missing_header(self):
+ """Test extracting token when header is missing."""
+ from auth import get_authorization_token
+
+ headers = {}
+ token = get_authorization_token(headers)
+ assert token == ""
+
+
+class TestIsUserInGroup:
+ """Test suite for is_user_in_group function."""
+
+ def test_user_in_group_simple(self):
+ """Test user is in group with simple property path."""
+ from auth import is_user_in_group
+
+ jwt_data = {"groups": ["admin", "users"]}
+ assert is_user_in_group(jwt_data, "admin", "groups")
+ assert is_user_in_group(jwt_data, "users", "groups")
+
+ def test_user_not_in_group(self):
+ """Test user is not in group."""
+ from auth import is_user_in_group
+
+ jwt_data = {"groups": ["users"]}
+ assert not is_user_in_group(jwt_data, "admin", "groups")
+
+ def test_user_in_group_nested_property(self):
+ """Test user is in group with nested property path."""
+ from auth import is_user_in_group
+
+ jwt_data = {"cognito": {"groups": ["admin", "users"]}}
+ assert is_user_in_group(jwt_data, "admin", "cognito.groups")
+
+ def test_user_in_group_missing_property(self):
+ """Test user group check with missing property."""
+ from auth import is_user_in_group
+
+ jwt_data = {"other": "value"}
+ assert not is_user_in_group(jwt_data, "admin", "groups")
+
+ def test_user_in_group_partial_path(self):
+ """Test user group check with partial property path."""
+ from auth import is_user_in_group
+
+ jwt_data = {"cognito": {"other": "value"}}
+ assert not is_user_in_group(jwt_data, "admin", "cognito.groups")
+
+
+class TestExtractUserGroupsFromJwt:
+ """Test suite for extract_user_groups_from_jwt function."""
+
+ def test_extract_groups_simple_path(self, mock_env_vars):
+ """Test extracting groups with simple property path."""
+ from auth import extract_user_groups_from_jwt
+
+ jwt_data = {"groups": ["admin", "users", "developers"]}
+ mock_env_vars["JWT_GROUPS_PROP"] = "groups"
+
+ with patch.dict("os.environ", mock_env_vars):
+ groups = extract_user_groups_from_jwt(jwt_data)
+
+ assert groups == ["admin", "users", "developers"]
+
+ def test_extract_groups_nested_path(self, mock_env_vars):
+ """Test extracting groups with nested property path."""
+ from auth import extract_user_groups_from_jwt
+
+ jwt_data = {"cognito": {"groups": ["admin", "users"]}}
+ mock_env_vars["JWT_GROUPS_PROP"] = "cognito.groups"
+
+ with patch.dict("os.environ", mock_env_vars):
+ groups = extract_user_groups_from_jwt(jwt_data)
+
+ assert groups == ["admin", "users"]
+
+ def test_extract_groups_none_jwt_data(self, mock_env_vars):
+ """Test extracting groups with None JWT data (API token user)."""
+ from auth import extract_user_groups_from_jwt
+
+ with patch.dict("os.environ", mock_env_vars):
+ groups = extract_user_groups_from_jwt(None)
+
+ assert groups == []
+
+ def test_extract_groups_missing_property(self, mock_env_vars):
+ """Test extracting groups when property doesn't exist."""
+ from auth import extract_user_groups_from_jwt
+
+ jwt_data = {"other": "value"}
+ mock_env_vars["JWT_GROUPS_PROP"] = "groups"
+
+ with patch.dict("os.environ", mock_env_vars):
+ groups = extract_user_groups_from_jwt(jwt_data)
+
+ assert groups == []
+
+ def test_extract_groups_not_a_list(self, mock_env_vars):
+ """Test extracting groups when property is not a list."""
+ from auth import extract_user_groups_from_jwt
+
+ jwt_data = {"groups": "admin"}
+ mock_env_vars["JWT_GROUPS_PROP"] = "groups"
+
+ with patch.dict("os.environ", mock_env_vars):
+ groups = extract_user_groups_from_jwt(jwt_data)
+
+ assert groups == []
+
+
+class TestUserContextHelpers:
+ """Test suite for user context helper functions."""
+
+ def test_is_api_user_true(self, mock_request, mock_token_info):
+ """Test is_api_user returns True for API token user."""
+ from auth import is_api_user
+
+ mock_request.state.api_token_info = mock_token_info
+ assert is_api_user(mock_request) is True
+
+ def test_is_api_user_false(self, mock_request):
+ """Test is_api_user returns False for non-API token user."""
+ from auth import is_api_user
+
+ # Ensure api_token_info attribute doesn't exist
+ delattr(mock_request.state, "api_token_info") if hasattr(mock_request.state, "api_token_info") else None
+ assert is_api_user(mock_request) is False
+
+ def test_get_user_context_api_user(self, mock_request, mock_token_info):
+ """Test get_user_context for API token user."""
+ from auth import get_user_context
+
+ mock_request.state.api_token_info = mock_token_info
+
+ username, groups = get_user_context(mock_request)
+
+ assert username == "api-user"
+ assert groups == ["users"]
+
+ def test_get_user_context_jwt_user(self, mock_request):
+ """Test get_user_context for JWT user."""
+ from auth import get_user_context
+
+ # Ensure api_token_info doesn't exist
+ if hasattr(mock_request.state, "api_token_info"):
+ delattr(mock_request.state, "api_token_info")
+
+ mock_request.state.username = "jwt-user"
+ mock_request.state.groups = ["admin", "users"]
+
+ username, groups = get_user_context(mock_request)
+
+ assert username == "jwt-user"
+ assert groups == ["admin", "users"]
+
+ def test_get_user_context_unknown_user(self, mock_request):
+ """Test get_user_context for unknown user."""
+ from auth import get_user_context
+
+ # Ensure api_token_info doesn't exist
+ if hasattr(mock_request.state, "api_token_info"):
+ delattr(mock_request.state, "api_token_info")
+
+ username, groups = get_user_context(mock_request)
+
+ assert username == "unknown"
+ assert groups == []
diff --git a/test/rest-api/test_generate_litellm_config.py b/test/rest-api/test_generate_litellm_config.py
new file mode 100644
index 000000000..8fd282c10
--- /dev/null
+++ b/test/rest-api/test_generate_litellm_config.py
@@ -0,0 +1,148 @@
+# 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.
+
+"""Unit tests for LiteLLM config generation."""
+
+import json
+import os
+import sys
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+import yaml
+
+# Set up environment
+os.environ["AWS_REGION"] = "us-east-1"
+os.environ["REGISTERED_MODELS_PS_NAME"] = "/test/models"
+os.environ["LITELLM_DB_INFO_PS_NAME"] = "/test/db"
+
+# Add REST API to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib/serve/rest-api/src/utils"))
+
+from generate_litellm_config import _build_model_config, _is_embedding_model, get_database_credentials
+
+
+@pytest.fixture
+def temp_config_file():
+ """Create a temporary config file."""
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
+ config = {"db_key": "test-master-key", "model_list": []}
+ yaml.dump(config, f)
+ yield Path(f.name)
+ Path(f.name).unlink(missing_ok=True)
+
+
+def test_get_database_credentials_with_secret():
+ """Test getting database credentials from Secrets Manager."""
+ db_params = {
+ "username": "testuser",
+ "passwordSecretId": "test-secret-id",
+ "dbHost": "db.example.com",
+ "dbPort": "5432",
+ "dbName": "testdb",
+ }
+
+ with patch("generate_litellm_config.boto3.client") as mock_boto:
+ mock_secrets = MagicMock()
+ mock_secrets.get_secret_value.return_value = {"SecretString": json.dumps({"password": "test-password-123"})}
+ mock_boto.return_value = mock_secrets
+
+ username, password = get_database_credentials(db_params)
+
+ assert username == "testuser"
+ assert password == "test-password-123"
+ mock_secrets.get_secret_value.assert_called_once_with(SecretId="test-secret-id")
+
+
+def test_get_database_credentials_secret_not_found():
+ """Test error handling when secret is not found."""
+ db_params = {
+ "username": "testuser",
+ "passwordSecretId": "missing-secret-id",
+ "dbHost": "db.example.com",
+ "dbPort": "5432",
+ "dbName": "testdb",
+ }
+
+ with patch("generate_litellm_config.boto3.client") as mock_boto:
+ mock_secrets = MagicMock()
+ mock_secrets.exceptions.ResourceNotFoundException = Exception
+ mock_secrets.get_secret_value.side_effect = Exception("Secret not found")
+ mock_boto.return_value = mock_secrets
+
+ with pytest.raises(Exception):
+ get_database_credentials(db_params)
+
+
+class TestIsEmbeddingModel:
+ """Tests for _is_embedding_model helper function."""
+
+ def test_embedding_in_model_name(self):
+ """Test detection when 'embed' is in modelName."""
+ model = {"modelName": "qwen3-embed-06b", "modelId": "my-model"}
+ assert _is_embedding_model(model) is True
+
+ def test_embedding_in_model_id(self):
+ """Test detection when 'embed' is in modelId."""
+ model = {"modelName": "some-model", "modelId": "text-embedding-model"}
+ assert _is_embedding_model(model) is True
+
+ def test_embedding_case_insensitive(self):
+ """Test that detection is case-insensitive."""
+ model = {"modelName": "EMBEDDING-MODEL", "modelId": "test"}
+ assert _is_embedding_model(model) is True
+
+ def test_non_embedding_model(self):
+ """Test that non-embedding models return False."""
+ model = {"modelName": "llama-3-70b", "modelId": "my-llama"}
+ assert _is_embedding_model(model) is False
+
+ def test_missing_fields(self):
+ """Test handling of missing fields."""
+ model = {}
+ assert _is_embedding_model(model) is False
+
+
+class TestBuildModelConfig:
+ """Tests for _build_model_config helper function."""
+
+ def test_regular_model_config(self):
+ """Test config generation for a regular (non-embedding) model."""
+ model = {
+ "modelId": "my-llama",
+ "modelName": "llama-3-70b",
+ "endpointUrl": "http://localhost:8000",
+ }
+ config = _build_model_config(model)
+
+ assert config["model_name"] == "my-llama"
+ assert config["litellm_params"]["model"] == "openai/llama-3-70b"
+ assert config["litellm_params"]["api_base"] == "http://localhost:8000/v1"
+ assert "additional_drop_params" not in config["litellm_params"]
+
+ def test_embedding_model_config(self):
+ """Test config generation for an embedding model uses hosted_vllm provider."""
+ model = {
+ "modelId": "qwen3-embed-06b",
+ "modelName": "qwen3-embed-06b",
+ "endpointUrl": "http://localhost:8001",
+ }
+ config = _build_model_config(model)
+
+ assert config["model_name"] == "qwen3-embed-06b"
+ assert config["litellm_params"]["model"] == "hosted_vllm/qwen3-embed-06b"
+ assert config["litellm_params"]["api_base"] == "http://localhost:8001/v1"
+ assert config["litellm_params"]["drop_params"] is True
diff --git a/test/rest-api/test_guardrails.py b/test/rest-api/test_guardrails.py
new file mode 100644
index 000000000..d6b3a543b
--- /dev/null
+++ b/test/rest-api/test_guardrails.py
@@ -0,0 +1,369 @@
+# 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.
+
+"""Unit tests for REST API guardrails utilities."""
+
+import json
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+from utils.guardrails import (
+ create_guardrail_json_response,
+ create_guardrail_streaming_response,
+ extract_guardrail_response,
+ get_applicable_guardrails,
+ get_model_guardrails,
+ is_guardrail_violation,
+)
+
+
+class TestGetModelGuardrails:
+ """Test suite for get_model_guardrails function."""
+
+ @pytest.mark.asyncio
+ async def test_get_guardrails_success(self, mock_env_vars):
+ """Test successful retrieval of model guardrails."""
+ mock_guardrails = [
+ {
+ "guardrailName": "content-filter",
+ "modelId": "test-model",
+ "allowedGroups": ["users"],
+ },
+ {
+ "guardrailName": "pii-filter",
+ "modelId": "test-model",
+ "allowedGroups": [],
+ },
+ ]
+
+ mock_table = MagicMock()
+ mock_table.query.return_value = {"Items": mock_guardrails}
+
+ mock_dynamodb = MagicMock()
+ mock_dynamodb.Table.return_value = mock_table
+
+ with patch.dict("os.environ", mock_env_vars), patch("boto3.resource", return_value=mock_dynamodb):
+
+ result = await get_model_guardrails("test-model")
+
+ assert result == mock_guardrails
+ mock_table.query.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_guardrails_empty(self, mock_env_vars):
+ """Test retrieval when no guardrails exist."""
+ mock_table = MagicMock()
+ mock_table.query.return_value = {"Items": []}
+
+ mock_dynamodb = MagicMock()
+ mock_dynamodb.Table.return_value = mock_table
+
+ with patch.dict("os.environ", mock_env_vars), patch("boto3.resource", return_value=mock_dynamodb):
+
+ result = await get_model_guardrails("test-model")
+
+ assert result == []
+
+ @pytest.mark.asyncio
+ async def test_get_guardrails_error(self, mock_env_vars):
+ """Test error handling during guardrail retrieval."""
+ mock_table = MagicMock()
+ mock_table.query.side_effect = Exception("DynamoDB error")
+
+ mock_dynamodb = MagicMock()
+ mock_dynamodb.Table.return_value = mock_table
+
+ with patch.dict("os.environ", mock_env_vars), patch("boto3.resource", return_value=mock_dynamodb):
+
+ result = await get_model_guardrails("test-model")
+
+ assert result == []
+
+
+class TestGetApplicableGuardrails:
+ """Test suite for get_applicable_guardrails function."""
+
+ def test_public_guardrail_applies_to_all(self):
+ """Test that public guardrails (no allowed_groups) apply to all users."""
+ user_groups = ["users"]
+ guardrails = [
+ {
+ "guardrailName": "public-filter",
+ "allowedGroups": [],
+ "markedForDeletion": False,
+ }
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ assert result == ["public-filter-test-model"]
+
+ def test_group_specific_guardrail_applies(self):
+ """Test that group-specific guardrails apply to matching users."""
+ user_groups = ["admin", "users"]
+ guardrails = [
+ {
+ "guardrailName": "admin-filter",
+ "allowedGroups": ["admin"],
+ "markedForDeletion": False,
+ }
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ assert result == ["admin-filter-test-model"]
+
+ def test_group_specific_guardrail_does_not_apply(self):
+ """Test that group-specific guardrails don't apply to non-matching users."""
+ user_groups = ["users"]
+ guardrails = [
+ {
+ "guardrailName": "admin-filter",
+ "allowedGroups": ["admin"],
+ "markedForDeletion": False,
+ }
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ assert result == []
+
+ def test_multiple_guardrails_mixed(self):
+ """Test multiple guardrails with mixed applicability."""
+ user_groups = ["users", "developers"]
+ guardrails = [
+ {
+ "guardrailName": "public-filter",
+ "allowedGroups": [],
+ "markedForDeletion": False,
+ },
+ {
+ "guardrailName": "dev-filter",
+ "allowedGroups": ["developers"],
+ "markedForDeletion": False,
+ },
+ {
+ "guardrailName": "admin-filter",
+ "allowedGroups": ["admin"],
+ "markedForDeletion": False,
+ },
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ assert len(result) == 2
+ assert "public-filter-test-model" in result
+ assert "dev-filter-test-model" in result
+ assert "admin-filter-test-model" not in result
+
+ def test_marked_for_deletion_excluded(self):
+ """Test that guardrails marked for deletion are excluded."""
+ user_groups = ["users"]
+ guardrails = [
+ {
+ "guardrailName": "active-filter",
+ "allowedGroups": [],
+ "markedForDeletion": False,
+ },
+ {
+ "guardrailName": "deleted-filter",
+ "allowedGroups": [],
+ "markedForDeletion": True,
+ },
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ assert result == ["active-filter-test-model"]
+
+ def test_missing_guardrail_name(self):
+ """Test handling of guardrails without guardrailName."""
+ user_groups = ["users"]
+ guardrails = [
+ {
+ "allowedGroups": [],
+ "markedForDeletion": False,
+ }
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ assert result == []
+
+ def test_empty_user_groups(self):
+ """Test with user having no groups."""
+ user_groups = []
+ guardrails = [
+ {
+ "guardrailName": "public-filter",
+ "allowedGroups": [],
+ "markedForDeletion": False,
+ },
+ {
+ "guardrailName": "group-filter",
+ "allowedGroups": ["users"],
+ "markedForDeletion": False,
+ },
+ ]
+
+ result = get_applicable_guardrails(user_groups, guardrails, "test-model")
+
+ # Only public guardrail should apply
+ assert result == ["public-filter-test-model"]
+
+
+class TestIsGuardrailViolation:
+ """Test suite for is_guardrail_violation function."""
+
+ def test_is_violation_true(self):
+ """Test detection of guardrail violation message."""
+ error_msg = "Violated guardrail policy: content filter triggered"
+ assert is_guardrail_violation(error_msg) is True
+
+ def test_is_violation_false(self):
+ """Test non-violation error message."""
+ error_msg = "Model not found"
+ assert is_guardrail_violation(error_msg) is False
+
+ def test_is_violation_empty_string(self):
+ """Test with empty error message."""
+ assert is_guardrail_violation("") is False
+
+
+class TestExtractGuardrailResponse:
+ """Test suite for extract_guardrail_response function."""
+
+ def test_extract_response_success(self):
+ """Test successful extraction of guardrail response."""
+ error_msg = "Error: 'bedrock_guardrail_response': 'Content blocked due to policy violation'"
+ result = extract_guardrail_response(error_msg)
+ assert result == "Content blocked due to policy violation"
+
+ def test_extract_response_not_found(self):
+ """Test extraction when response not in message."""
+ error_msg = "Some other error message"
+ result = extract_guardrail_response(error_msg)
+ assert result is None
+
+ def test_extract_response_empty_string(self):
+ """Test extraction with empty error message."""
+ result = extract_guardrail_response("")
+ assert result is None
+
+ def test_extract_response_complex_message(self):
+ """Test extraction from complex error message."""
+ error_msg = (
+ "LiteLLM error: Violated guardrail policy. "
+ "'bedrock_guardrail_response': 'I cannot assist with that request' "
+ "Additional context here"
+ )
+ result = extract_guardrail_response(error_msg)
+ assert result == "I cannot assist with that request"
+
+
+class TestCreateGuardrailStreamingResponse:
+ """Test suite for create_guardrail_streaming_response function."""
+
+ def test_streaming_response_format(self):
+ """Test format of streaming guardrail response."""
+ guardrail_response = "Content blocked"
+ model_id = "test-model"
+ created = 1234567890
+
+ chunks = list(create_guardrail_streaming_response(guardrail_response, model_id, created))
+
+ assert len(chunks) == 3
+
+ # First chunk with content
+ first_chunk = json.loads(chunks[0].replace("data: ", "").strip())
+ assert first_chunk["model"] == model_id
+ assert first_chunk["created"] == created
+ assert first_chunk["choices"][0]["delta"]["content"] == guardrail_response
+ assert first_chunk["lisa_guardrail_triggered"] is True
+
+ # Second chunk with finish_reason
+ second_chunk = json.loads(chunks[1].replace("data: ", "").strip())
+ assert second_chunk["choices"][0]["finish_reason"] == "stop"
+
+ # Final [DONE] marker
+ assert chunks[2] == "data: [DONE]\n\n"
+
+ def test_streaming_response_default_created(self):
+ """Test streaming response with default created timestamp."""
+ chunks = list(create_guardrail_streaming_response("Blocked", "model", 0))
+
+ first_chunk = json.loads(chunks[0].replace("data: ", "").strip())
+ assert first_chunk["created"] == 0
+
+
+class TestCreateGuardrailJsonResponse:
+ """Test suite for create_guardrail_json_response function."""
+
+ def test_json_response_format(self):
+ """Test format of JSON guardrail response."""
+ guardrail_response = "Content blocked"
+ model_id = "test-model"
+ created = 1234567890
+
+ response = create_guardrail_json_response(guardrail_response, model_id, created)
+
+ assert response.status_code == 200
+ response_data = json.loads(response.body)
+
+ assert response_data["model"] == model_id
+ assert response_data["created"] == created
+ assert response_data["choices"][0]["message"]["content"] == guardrail_response
+ assert response_data["choices"][0]["finish_reason"] == "stop"
+ assert response_data["lisa_guardrail_triggered"] is True
+ assert response_data["usage"]["total_tokens"] == 0
+
+ def test_json_response_default_created(self):
+ """Test JSON response with default created timestamp."""
+ response = create_guardrail_json_response("Blocked", "model", 0)
+ response_data = json.loads(response.body)
+
+ assert response_data["created"] == 0
+
+ def test_json_response_structure(self):
+ """Test complete structure of JSON response."""
+ response = create_guardrail_json_response("Test response", "test-model", 123)
+ response_data = json.loads(response.body)
+
+ # Check all required fields
+ assert "id" in response_data
+ assert "object" in response_data
+ assert "created" in response_data
+ assert "model" in response_data
+ assert "choices" in response_data
+ assert "usage" in response_data
+ assert "lisa_guardrail_triggered" in response_data
+
+ # Check choices structure
+ assert len(response_data["choices"]) == 1
+ choice = response_data["choices"][0]
+ assert "index" in choice
+ assert "message" in choice
+ assert "finish_reason" in choice
+
+ # Check message structure
+ message = choice["message"]
+ assert message["role"] == "assistant"
+ assert message["content"] == "Test response"
diff --git a/test/rest-api/test_handlers.py b/test/rest-api/test_handlers.py
new file mode 100644
index 000000000..c9b77fa3f
--- /dev/null
+++ b/test/rest-api/test_handlers.py
@@ -0,0 +1,551 @@
+# 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.
+
+"""Tests for REST API handlers with dependency injection."""
+import sys
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# Add the REST API source to the path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class MockRegistry:
+ """Mock registry for testing."""
+
+ def get_assets(self, provider: str):
+ """Mock get_assets method."""
+ return {
+ "adapter": MagicMock(return_value=MagicMock()),
+ "validator": MagicMock,
+ }
+
+
+@pytest.fixture
+def mock_registry():
+ """Fixture for mock registry."""
+ return MockRegistry()
+
+
+@pytest.fixture
+def mock_registered_models_cache():
+ """Fixture for mock registered models cache."""
+ from utils.resources import ModelType, RestApiResource
+
+ return {
+ ModelType.TEXTGEN: {"test-provider": ["test-model"]},
+ ModelType.EMBEDDING: {"test-provider": ["test-embedding-model"]},
+ RestApiResource.EMBEDDINGS: {"test-provider": ["test-embedding-model"]},
+ RestApiResource.GENERATE: {"test-provider": ["test-model"]},
+ RestApiResource.GENERATE_STREAM: {"test-provider": ["test-model"]},
+ "metadata": {},
+ "endpointUrls": {
+ "test-provider.test-model": "http://test-endpoint",
+ "test-provider.test-embedding-model": "http://test-embedding-endpoint",
+ },
+ }
+
+
+class TestGenerationHandlers:
+ """Tests for generation handlers."""
+
+ @pytest.mark.asyncio
+ async def test_handle_generate_success(self, mock_registry, mock_registered_models_cache):
+ """Test successful generation."""
+ from handlers.generation import handle_generate
+
+ # Mock the cache and model
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ # Create mock model with generate method
+ mock_model = MagicMock()
+ mock_response = MagicMock()
+ mock_response.dict.return_value = {"generated_text": "test response"}
+ mock_model.generate = AsyncMock(return_value=mock_response)
+
+ # Mock the adapter to return our mock model
+ mock_registry.get_assets = MagicMock(
+ return_value={
+ "adapter": MagicMock(return_value=mock_model),
+ "validator": MagicMock(return_value=MagicMock(dict=MagicMock(return_value={}))),
+ }
+ )
+
+ request_data = {
+ "provider": "test-provider",
+ "modelName": "test-model",
+ "text": "test prompt",
+ "modelKwargs": {},
+ }
+
+ result = await handle_generate(request_data, registry=mock_registry)
+
+ assert result == {"generated_text": "test response"}
+ mock_model.generate.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_handle_generate_error(self, mock_registry, mock_registered_models_cache):
+ """Test generation with error."""
+ from handlers.generation import handle_generate
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ # Create mock model that raises an exception
+ mock_model = MagicMock()
+ mock_model.generate = AsyncMock(side_effect=RuntimeError("Model error"))
+
+ mock_registry.get_assets = MagicMock(
+ return_value={
+ "adapter": MagicMock(return_value=mock_model),
+ "validator": MagicMock(return_value=MagicMock(dict=MagicMock(return_value={}))),
+ }
+ )
+
+ request_data = {
+ "provider": "test-provider",
+ "modelName": "test-model",
+ "text": "test prompt",
+ "modelKwargs": {},
+ }
+
+ with pytest.raises(RuntimeError, match="Model error"):
+ await handle_generate(request_data, registry=mock_registry)
+
+ @pytest.mark.asyncio
+ async def test_handle_generate_stream_success(self, mock_registry, mock_registered_models_cache):
+ """Test successful streaming generation."""
+ from handlers.generation import handle_generate_stream
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ # Create mock model with generate_stream method
+ mock_model = MagicMock()
+
+ async def mock_stream(*args, **kwargs):
+ mock_response = MagicMock()
+ mock_response.dict.return_value = {"text": "chunk1"}
+ yield mock_response
+ mock_response = MagicMock()
+ mock_response.dict.return_value = {"text": "chunk2"}
+ yield mock_response
+
+ mock_model.generate_stream = mock_stream
+
+ mock_registry.get_assets = MagicMock(
+ return_value={
+ "adapter": MagicMock(return_value=mock_model),
+ "validator": MagicMock(return_value=MagicMock(dict=MagicMock(return_value={}))),
+ }
+ )
+
+ request_data = {
+ "provider": "test-provider",
+ "modelName": "test-model",
+ "text": "test prompt",
+ "modelKwargs": {},
+ }
+
+ chunks = []
+ async for chunk in handle_generate_stream(request_data, registry=mock_registry):
+ chunks.append(chunk)
+
+ assert len(chunks) == 2
+ assert 'data:{"text": "chunk1"}' in chunks[0]
+ assert 'data:{"text": "chunk2"}' in chunks[1]
+
+ @pytest.mark.asyncio
+ async def test_handle_openai_generate_stream_chat(self, mock_registry, mock_registered_models_cache):
+ """Test OpenAI chat completions streaming."""
+ from handlers.generation import handle_openai_generate_stream
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ # Create mock model
+ mock_model = MagicMock()
+
+ async def mock_stream(*args, **kwargs):
+ mock_response = MagicMock()
+ mock_response.dict.return_value = {"choices": [{"delta": {"content": "test"}}]}
+ yield mock_response
+
+ mock_model.openai_generate_stream = mock_stream
+
+ mock_registry.get_assets = MagicMock(
+ return_value={
+ "adapter": MagicMock(return_value=mock_model),
+ "validator": MagicMock(return_value=MagicMock(dict=MagicMock(return_value={}))),
+ }
+ )
+
+ request_data = {
+ "model": "test-model (test-provider)",
+ "messages": [{"role": "user", "content": "Hello"}],
+ "stream": True,
+ }
+
+ chunks = []
+ async for chunk in handle_openai_generate_stream(
+ request_data, is_text_completion=False, registry=mock_registry
+ ):
+ chunks.append(chunk)
+
+ assert len(chunks) >= 1
+
+ @pytest.mark.asyncio
+ async def test_handle_openai_generate_stream_text_completion(self, mock_registry, mock_registered_models_cache):
+ """Test OpenAI text completions streaming."""
+ from handlers.generation import handle_openai_generate_stream
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ # Create mock model
+ mock_model = MagicMock()
+
+ async def mock_stream(*args, **kwargs):
+ mock_response = MagicMock()
+ mock_response.dict.return_value = {"choices": [{"text": "test"}]}
+ yield mock_response
+
+ mock_model.openai_generate_stream = mock_stream
+
+ mock_registry.get_assets = MagicMock(
+ return_value={
+ "adapter": MagicMock(return_value=mock_model),
+ "validator": MagicMock(return_value=MagicMock(dict=MagicMock(return_value={}))),
+ }
+ )
+
+ request_data = {
+ "model": "test-model (test-provider)",
+ "prompt": "Hello",
+ "stream": True,
+ }
+
+ chunks = []
+ async for chunk in handle_openai_generate_stream(
+ request_data, is_text_completion=True, registry=mock_registry
+ ):
+ chunks.append(chunk)
+
+ # Should have at least the response chunk and [DONE]
+ assert len(chunks) >= 2
+ assert "data: [DONE]" in chunks[-1]
+
+ @pytest.mark.asyncio
+ async def test_backward_compatibility_aliases(self):
+ """Test backward compatibility aliases."""
+ from handlers.generation import parse_model_provider_names, render_context
+ from services.text_processing import parse_model_provider_from_string, render_context_from_messages
+
+ # These should be the same functions
+ assert render_context == render_context_from_messages
+ assert parse_model_provider_names == parse_model_provider_from_string
+
+
+class TestEmbeddingHandlers:
+ """Tests for embedding handlers."""
+
+ @pytest.mark.asyncio
+ async def test_handle_embeddings_success(self, mock_registry, mock_registered_models_cache):
+ """Test successful embeddings."""
+ from handlers.embeddings import handle_embeddings
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ # Create mock model with embed_query method
+ mock_model = MagicMock()
+ mock_response = MagicMock()
+ mock_response.dict.return_value = {"embeddings": [0.1, 0.2, 0.3]}
+ mock_model.embed_query = AsyncMock(return_value=mock_response)
+
+ mock_registry.get_assets = MagicMock(
+ return_value={
+ "adapter": MagicMock(return_value=mock_model),
+ "validator": MagicMock(return_value=MagicMock(dict=MagicMock(return_value={}))),
+ }
+ )
+
+ request_data = {
+ "provider": "test-provider",
+ "modelName": "test-embedding-model",
+ "text": "test text",
+ "modelKwargs": {},
+ }
+
+ result = await handle_embeddings(request_data, registry=mock_registry)
+
+ assert result == {"embeddings": [0.1, 0.2, 0.3]}
+ mock_model.embed_query.assert_called_once()
+
+
+class TestModelsHandlers:
+ """Tests for models handlers."""
+
+ @pytest.mark.asyncio
+ async def test_handle_list_models(self):
+ """Test listing models."""
+ from handlers.models import handle_list_models
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1", "model2"]},
+ ModelType.EMBEDDING: {"provider2": ["embed1"]},
+ }
+
+ mock_service = ModelService(mock_cache)
+
+ result = await handle_list_models([ModelType.TEXTGEN], model_service=mock_service)
+
+ assert ModelType.TEXTGEN in result
+ assert "provider1" in result[ModelType.TEXTGEN]
+ assert result[ModelType.TEXTGEN]["provider1"] == ["model1", "model2"]
+
+ @pytest.mark.asyncio
+ async def test_handle_list_models_default_service(self):
+ """Test listing models with default service (no injection)."""
+ from handlers.models import handle_list_models
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1"]},
+ }
+
+ with patch("handlers.models.get_registered_models_cache", return_value=mock_cache):
+ result = await handle_list_models([ModelType.TEXTGEN])
+
+ assert ModelType.TEXTGEN in result
+ assert "provider1" in result[ModelType.TEXTGEN]
+
+ @pytest.mark.asyncio
+ async def test_handle_openai_list_models(self):
+ """Test OpenAI-compatible model listing."""
+ from handlers.models import handle_openai_list_models
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1"], "provider2": ["model2"]},
+ ModelType.EMBEDDING: {"provider3": ["embed1"]},
+ }
+
+ mock_service = ModelService(mock_cache)
+
+ result = await handle_openai_list_models(model_service=mock_service)
+
+ assert "object" in result
+ assert result["object"] == "list"
+ assert "data" in result
+ assert len(result["data"]) == 2 # Only textgen models
+
+ @pytest.mark.asyncio
+ async def test_handle_describe_model_success(self):
+ """Test describing a specific model."""
+ from handlers.models import handle_describe_model
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1"]},
+ "metadata": {"provider1.model1": {"name": "model1", "type": "textgen", "description": "Test model"}},
+ }
+
+ mock_service = ModelService(mock_cache)
+
+ result = await handle_describe_model("provider1", "model1", model_service=mock_service)
+
+ assert result["name"] == "model1"
+ assert result["type"] == "textgen"
+
+ @pytest.mark.asyncio
+ async def test_handle_describe_model_not_found(self):
+ """Test describing a model that doesn't exist."""
+ from fastapi import HTTPException
+ from handlers.models import handle_describe_model
+ from services.model_service import ModelService
+
+ mock_cache = {
+ "metadata": {},
+ }
+
+ mock_service = ModelService(mock_cache)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await handle_describe_model("unknown", "unknown-model", model_service=mock_service)
+
+ assert exc_info.value.status_code == 404
+
+ @pytest.mark.asyncio
+ async def test_handle_describe_models(self):
+ """Test describing multiple models."""
+ from handlers.models import handle_describe_models
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1", "model2"]},
+ "metadata": {
+ "provider1.model1": {"name": "model1", "type": "textgen"},
+ "provider1.model2": {"name": "model2", "type": "textgen"},
+ },
+ }
+
+ mock_service = ModelService(mock_cache)
+
+ result = await handle_describe_models([ModelType.TEXTGEN], model_service=mock_service)
+
+ assert "textgen" in result
+ assert "provider1" in result["textgen"]
+ assert len(result["textgen"]["provider1"]) == 2
+ assert result["textgen"]["provider1"][0]["name"] == "model1"
+ assert result["textgen"]["provider1"][1]["name"] == "model2"
+
+
+class TestRequestUtils:
+ """Tests for request utility functions."""
+
+ @pytest.mark.asyncio
+ async def test_validate_model_success(self, mock_registered_models_cache):
+ """Test successful model validation."""
+ from utils.request_utils import validate_model
+ from utils.resources import RestApiResource
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ request_data = {"provider": "test-provider", "modelName": "test-model"}
+
+ # Should not raise
+ await validate_model(request_data, RestApiResource.GENERATE)
+
+ @pytest.mark.asyncio
+ async def test_validate_model_not_found(self, mock_registered_models_cache):
+ """Test model validation with model not found."""
+ from utils.request_utils import validate_model
+ from utils.resources import RestApiResource
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ request_data = {"provider": "test-provider", "modelName": "nonexistent-model"}
+
+ with pytest.raises(ValueError, match="Provider does not support model"):
+ await validate_model(request_data, RestApiResource.GENERATE)
+
+ @pytest.mark.asyncio
+ async def test_get_model_and_validator_from_cache(self, mock_registry):
+ """Test getting model and validator from cache."""
+ from utils.request_utils import get_model_and_validator
+
+ mock_model = MagicMock()
+ mock_validator = MagicMock()
+ cached_assets = (mock_model, mock_validator)
+
+ with patch("utils.request_utils.get_model_assets", return_value=cached_assets):
+ request_data = {"provider": "test-provider", "modelName": "test-model"}
+
+ model, validator = await get_model_and_validator(request_data, registry=mock_registry)
+
+ assert model == mock_model
+ assert validator == mock_validator
+
+ @pytest.mark.asyncio
+ async def test_get_model_and_validator_from_registry(self, mock_registry, mock_registered_models_cache):
+ """Test getting model and validator from registry."""
+ from utils.request_utils import get_model_and_validator
+
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.cache_model_assets") as mock_cache:
+ mock_model = MagicMock()
+ mock_validator = MagicMock()
+
+ mock_registry.get_assets = MagicMock(
+ return_value={"adapter": MagicMock(return_value=mock_model), "validator": mock_validator}
+ )
+
+ request_data = {"provider": "test-provider", "modelName": "test-model"}
+
+ model, validator = await get_model_and_validator(request_data, registry=mock_registry)
+
+ assert model == mock_model
+ assert validator == mock_validator
+ mock_cache.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_validate_and_prepare_llm_request(self, mock_registry, mock_registered_models_cache):
+ """Test validate and prepare LLM request."""
+ from utils.request_utils import validate_and_prepare_llm_request
+ from utils.resources import RestApiResource
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ mock_model = MagicMock()
+ mock_validator_instance = MagicMock()
+ mock_validator_instance.dict.return_value = {"param": "value"}
+ mock_validator = MagicMock(return_value=mock_validator_instance)
+
+ mock_registry.get_assets = MagicMock(
+ return_value={"adapter": MagicMock(return_value=mock_model), "validator": mock_validator}
+ )
+
+ request_data = {
+ "provider": "test-provider",
+ "modelName": "test-model",
+ "text": "test text",
+ "modelKwargs": {"param": "value"},
+ }
+
+ model, model_kwargs, text = await validate_and_prepare_llm_request(
+ request_data, RestApiResource.GENERATE, registry=mock_registry
+ )
+
+ assert model == mock_model
+ assert model_kwargs == {"param": "value"}
+ assert text == "test text"
+
+ @pytest.mark.asyncio
+ async def test_validate_and_prepare_llm_request_missing_text(self, mock_registry, mock_registered_models_cache):
+ """Test validate and prepare LLM request with missing text."""
+ from utils.request_utils import validate_and_prepare_llm_request
+ from utils.resources import RestApiResource
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_registered_models_cache):
+ with patch("utils.request_utils.get_model_assets", return_value=None):
+ with patch("utils.request_utils.cache_model_assets"):
+ mock_model = MagicMock()
+ mock_validator_instance = MagicMock()
+ mock_validator_instance.dict.return_value = {}
+ mock_validator = MagicMock(return_value=mock_validator_instance)
+
+ mock_registry.get_assets = MagicMock(
+ return_value={"adapter": MagicMock(return_value=mock_model), "validator": mock_validator}
+ )
+
+ request_data = {
+ "provider": "test-provider",
+ "modelName": "test-model",
+ "modelKwargs": {},
+ }
+
+ with pytest.raises(ValueError, match="Missing required field: text"):
+ await validate_and_prepare_llm_request(
+ request_data, RestApiResource.GENERATE, registry=mock_registry
+ )
diff --git a/test/rest-api/test_input_validation.py b/test/rest-api/test_input_validation.py
new file mode 100644
index 000000000..913bf4081
--- /dev/null
+++ b/test/rest-api/test_input_validation.py
@@ -0,0 +1,153 @@
+# 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.
+
+"""Unit tests for input validation middleware."""
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from middleware.input_validation import contains_null_bytes, validate_input_middleware
+
+
+class TestContainsNullBytes:
+ """Test contains_null_bytes function."""
+
+ def test_detects_null_byte(self) -> None:
+ """Test that null bytes are detected."""
+ assert contains_null_bytes("test\x00data") is True
+ assert contains_null_bytes("\x00") is True
+ assert contains_null_bytes("prefix\x00suffix") is True
+
+ def test_no_null_bytes(self) -> None:
+ """Test that clean strings return False."""
+ assert contains_null_bytes("normal string") is False
+ assert contains_null_bytes("") is False
+ assert contains_null_bytes("special chars: !@#$%^&*()") is False
+
+
+class TestInputValidationMiddleware:
+ """Test input validation middleware."""
+
+ @pytest.fixture
+ def app(self) -> FastAPI:
+ """Create a test FastAPI app with validation middleware."""
+ test_app = FastAPI()
+
+ @test_app.middleware("http")
+ async def validation_middleware(request, call_next): # type: ignore[no-untyped-def]
+ return await validate_input_middleware(request, call_next)
+
+ @test_app.get("/test")
+ async def test_endpoint() -> dict[str, str]:
+ return {"message": "success"}
+
+ @test_app.post("/test")
+ async def test_post_endpoint(data: dict) -> dict[str, str | dict]: # type: ignore[type-arg]
+ return {"message": "success", "data": data}
+
+ @test_app.get("/test/{item_id}")
+ async def test_path_param(item_id: str) -> dict[str, str]:
+ return {"item_id": item_id}
+
+ return test_app
+
+ @pytest.fixture
+ def client(self, app: FastAPI) -> TestClient:
+ """Create a test client."""
+ return TestClient(app)
+
+ def test_valid_get_request(self, client: TestClient) -> None:
+ """Test that valid GET requests pass through."""
+ response = client.get("/test")
+ assert response.status_code == 200
+ assert response.json() == {"message": "success"}
+
+ def test_valid_post_request(self, client: TestClient) -> None:
+ """Test that valid POST requests pass through."""
+ response = client.post("/test", json={"key": "value"})
+ assert response.status_code == 200
+
+ def test_invalid_http_method(self, client: TestClient) -> None:
+ """Test that invalid HTTP methods are rejected."""
+ # FastAPI/TestClient doesn't allow truly invalid methods,
+ # but we can test that only valid methods work
+ response = client.get("/test")
+ assert response.status_code == 200
+
+ def test_null_byte_in_path(self, client: TestClient) -> None:
+ """Test that null bytes in path are rejected by HTTP client."""
+ # Note: HTTP clients (httpx/TestClient) reject null bytes in URLs
+ # before they reach our middleware. This is expected behavior.
+ # The HTTP client provides the first line of defense.
+ import httpx
+
+ with pytest.raises(httpx.InvalidURL):
+ client.get("/test\x00malicious")
+
+ def test_null_byte_in_path_parameter(self, client: TestClient) -> None:
+ """Test that null bytes in path parameters are rejected by HTTP client."""
+ # Note: HTTP clients (httpx/TestClient) reject null bytes in URLs
+ # before they reach our middleware. This is expected behavior.
+ import httpx
+
+ with pytest.raises(httpx.InvalidURL):
+ client.get("/test/item\x00malicious")
+
+ def test_null_byte_in_query_parameter(self, client: TestClient) -> None:
+ """Test that null bytes in query parameters are rejected by HTTP client."""
+ # Note: HTTP clients (httpx/TestClient) reject null bytes in URLs
+ # before they reach our middleware. This is expected behavior.
+ import httpx
+
+ with pytest.raises(httpx.InvalidURL):
+ client.get("/test?param=value\x00malicious")
+
+ def test_null_byte_in_request_body(self, client: TestClient) -> None:
+ """Test that null bytes in request body are rejected."""
+ # Send raw body with null byte
+ response = client.post(
+ "/test",
+ content=b'{"key": "value\x00malicious"}',
+ headers={"Content-Type": "application/json"},
+ )
+ assert response.status_code == 400
+ assert "Invalid characters" in response.json()["message"]
+
+ def test_oversized_request_body(self, client: TestClient) -> None:
+ """Test that oversized request bodies are rejected."""
+ # Create a body larger than 10MB (default limit)
+ large_data = {"data": "x" * (10 * 1024 * 1024 + 1)}
+ response = client.post("/test", json=large_data)
+ assert response.status_code == 413
+ assert "Payload Too Large" in response.json()["error"]
+
+ def test_valid_query_parameters(self, client: TestClient) -> None:
+ """Test that valid query parameters pass through."""
+ response = client.get("/test?param1=value1¶m2=value2")
+ assert response.status_code == 200
+
+ def test_valid_path_parameters(self, client: TestClient) -> None:
+ """Test that valid path parameters pass through."""
+ response = client.get("/test/123")
+ assert response.status_code == 200
+ assert response.json()["item_id"] == "123"
+
+ def test_empty_request_body(self, client: TestClient) -> None:
+ """Test that empty request bodies are handled correctly."""
+ response = client.post("/test", json={})
+ assert response.status_code == 200
+
+ def test_special_characters_allowed(self, client: TestClient) -> None:
+ """Test that special characters (non-null) are allowed."""
+ response = client.get("/test?param=value!@#$%^&*()")
+ assert response.status_code == 200
diff --git a/test/rest-api/test_metrics.py b/test/rest-api/test_metrics.py
new file mode 100644
index 000000000..a467aa323
--- /dev/null
+++ b/test/rest-api/test_metrics.py
@@ -0,0 +1,271 @@
+# 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.
+
+"""Unit tests for REST API metrics utilities."""
+
+import json
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+# Mock AWS_REGION before importing metrics
+import os
+
+os.environ.setdefault("AWS_REGION", "us-east-1")
+
+from utils.metrics import extract_messages_for_metrics, publish_metrics_event
+
+
+class TestExtractMessagesForMetrics:
+ """Test suite for extract_messages_for_metrics function."""
+
+ def test_extract_simple_messages(self):
+ """Test extraction of simple string messages."""
+ params = {
+ "messages": [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi there"},
+ {"role": "system", "content": "You are helpful"},
+ ]
+ }
+
+ result = extract_messages_for_metrics(params)
+
+ assert len(result) == 3
+ assert result[0]["type"] == "human"
+ assert result[0]["content"] == "Hello"
+ assert result[1]["type"] == "ai"
+ assert result[1]["content"] == "Hi there"
+ assert result[2]["type"] == "system"
+ assert result[2]["content"] == "You are helpful"
+
+ def test_extract_array_content_messages(self):
+ """Test extraction of messages with array content."""
+ params = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "First part"},
+ {"type": "text", "text": "Second part"},
+ ],
+ }
+ ]
+ }
+
+ result = extract_messages_for_metrics(params)
+
+ assert len(result) == 1
+ assert result[0]["type"] == "human"
+ # Content should be preserved as array
+ assert isinstance(result[0]["content"], list)
+
+ def test_extract_messages_with_rag_context(self):
+ """Test detection of RAG context in messages."""
+ params = {"messages": [{"role": "user", "content": "Question about File context: document.pdf"}]}
+
+ result = extract_messages_for_metrics(params)
+
+ assert len(result) == 1
+ assert result[0]["metadata"].get("ragContext") is True
+
+ def test_extract_messages_with_tool_calls(self):
+ """Test extraction of messages with tool calls."""
+ params = {
+ "messages": [
+ {
+ "role": "assistant",
+ "content": "Let me check that",
+ "tool_calls": [
+ {"id": "call_123", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}
+ ],
+ }
+ ]
+ }
+
+ result = extract_messages_for_metrics(params)
+
+ assert len(result) == 1
+ assert "toolCalls" in result[0]
+ assert len(result[0]["toolCalls"]) == 1
+ assert result[0]["toolCalls"][0]["id"] == "call_123"
+
+ def test_extract_empty_messages(self):
+ """Test extraction with no messages."""
+ params = {"messages": []}
+
+ result = extract_messages_for_metrics(params)
+
+ assert result == []
+
+ def test_extract_missing_messages_key(self):
+ """Test extraction when messages key is missing."""
+ params = {}
+
+ result = extract_messages_for_metrics(params)
+
+ assert result == []
+
+ def test_extract_unknown_role(self):
+ """Test extraction with unknown role."""
+ params = {"messages": [{"role": "custom_role", "content": "Test"}]}
+
+ result = extract_messages_for_metrics(params)
+
+ assert len(result) == 1
+ assert result[0]["type"] == "custom_role"
+
+ def test_extract_mixed_content_types(self):
+ """Test extraction with mixed content types."""
+ params = {
+ "messages": [
+ {"role": "user", "content": "Simple string"},
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "Array content"},
+ {"type": "image", "url": "http://example.com/image.jpg"},
+ ],
+ },
+ ]
+ }
+
+ result = extract_messages_for_metrics(params)
+
+ assert len(result) == 2
+ assert isinstance(result[0]["content"], str)
+ assert isinstance(result[1]["content"], list)
+
+
+class TestPublishMetricsEvent:
+ """Test suite for publish_metrics_event function."""
+
+ def test_publish_metrics_success(self, mock_env_vars, mock_request):
+ """Test successful metrics publishing."""
+ mock_env_vars["USAGE_METRICS_QUEUE_URL"] = "https://sqs.us-east-1.amazonaws.com/123456789/metrics"
+
+ params = {"messages": [{"role": "user", "content": "Hello"}]}
+
+ mock_request.state.username = "test-user"
+ mock_request.state.groups = ["users"]
+
+ mock_sqs = MagicMock()
+
+ with patch.dict("os.environ", mock_env_vars), patch("utils.metrics.sqs_client", mock_sqs), patch(
+ "utils.metrics.get_user_context", return_value=("test-user", ["users"])
+ ):
+
+ publish_metrics_event(mock_request, params, 200)
+
+ mock_sqs.send_message.assert_called_once()
+ call_args = mock_sqs.send_message.call_args
+
+ assert call_args[1]["QueueUrl"] == mock_env_vars["USAGE_METRICS_QUEUE_URL"]
+
+ # Verify message body structure
+ message_body = json.loads(call_args[1]["MessageBody"])
+ assert message_body["userId"] == "test-user"
+ assert message_body["userGroups"] == ["users"]
+ assert "sessionId" in message_body
+ assert "messages" in message_body
+ assert "timestamp" in message_body
+
+ def test_publish_metrics_non_200_status(self, mock_env_vars, mock_request):
+ """Test metrics not published for non-200 status."""
+ mock_env_vars["USAGE_METRICS_QUEUE_URL"] = "https://sqs.us-east-1.amazonaws.com/123456789/metrics"
+
+ params = {"messages": []}
+ mock_sqs = MagicMock()
+
+ with patch.dict("os.environ", mock_env_vars), patch("utils.metrics.sqs_client", mock_sqs):
+
+ publish_metrics_event(mock_request, params, 400)
+
+ mock_sqs.send_message.assert_not_called()
+
+ def test_publish_metrics_no_queue_url(self, mock_env_vars, mock_request):
+ """Test metrics not published when queue URL not configured."""
+ mock_env_vars.pop("USAGE_METRICS_QUEUE_URL", None)
+
+ params = {"messages": []}
+ mock_sqs = MagicMock()
+
+ with patch.dict("os.environ", mock_env_vars, clear=True), patch("utils.metrics.sqs_client", mock_sqs):
+
+ publish_metrics_event(mock_request, params, 200)
+
+ mock_sqs.send_message.assert_not_called()
+
+ def test_publish_metrics_error_handling(self, mock_env_vars, mock_request):
+ """Test error handling during metrics publishing."""
+ mock_env_vars["USAGE_METRICS_QUEUE_URL"] = "https://sqs.us-east-1.amazonaws.com/123456789/metrics"
+
+ params = {"messages": []}
+ mock_sqs = MagicMock()
+ mock_sqs.send_message.side_effect = Exception("SQS error")
+
+ with patch.dict("os.environ", mock_env_vars), patch("utils.metrics.sqs_client", mock_sqs), patch(
+ "utils.metrics.get_user_context", return_value=("test-user", [])
+ ):
+
+ # Should not raise exception
+ publish_metrics_event(mock_request, params, 200)
+
+ def test_publish_metrics_session_id_format(self, mock_env_vars, mock_request):
+ """Test session ID format for API users."""
+ mock_env_vars["USAGE_METRICS_QUEUE_URL"] = "https://sqs.us-east-1.amazonaws.com/123456789/metrics"
+
+ params = {"messages": []}
+ mock_sqs = MagicMock()
+
+ with patch.dict("os.environ", mock_env_vars), patch("utils.metrics.sqs_client", mock_sqs), patch(
+ "utils.metrics.get_user_context", return_value=("api-user", [])
+ ):
+
+ publish_metrics_event(mock_request, params, 200)
+
+ call_args = mock_sqs.send_message.call_args
+ message_body = json.loads(call_args[1]["MessageBody"])
+
+ # Session ID should start with "api-"
+ assert message_body["sessionId"].startswith("api-")
+
+ def test_publish_metrics_with_complex_messages(self, mock_env_vars, mock_request):
+ """Test metrics publishing with complex message structure."""
+ mock_env_vars["USAGE_METRICS_QUEUE_URL"] = "https://sqs.us-east-1.amazonaws.com/123456789/metrics"
+
+ params = {
+ "messages": [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi", "tool_calls": [{"id": "call_1", "type": "function"}]},
+ ]
+ }
+
+ mock_sqs = MagicMock()
+
+ with patch.dict("os.environ", mock_env_vars), patch("utils.metrics.sqs_client", mock_sqs), patch(
+ "utils.metrics.get_user_context", return_value=("user", ["users"])
+ ):
+
+ publish_metrics_event(mock_request, params, 200)
+
+ call_args = mock_sqs.send_message.call_args
+ message_body = json.loads(call_args[1]["MessageBody"])
+
+ assert len(message_body["messages"]) == 2
+ assert "toolCalls" in message_body["messages"][1]
diff --git a/test/rest-api/test_middleware.py b/test/rest-api/test_middleware.py
new file mode 100644
index 000000000..1f9f56bc6
--- /dev/null
+++ b/test/rest-api/test_middleware.py
@@ -0,0 +1,117 @@
+# 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.
+
+"""Tests for request middleware."""
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# Add the REST API source to the path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class TestRequestMiddleware:
+ """Tests for request processing middleware."""
+
+ @pytest.mark.asyncio
+ async def test_process_request_success(self):
+ """Test successful request processing."""
+ from fastapi import Response
+ from middleware.request_middleware import process_request_middleware
+
+ # Create mock request and real response
+ mock_request = MagicMock()
+ mock_request.url.path = "/test"
+
+ mock_response = Response(content="test", status_code=200)
+
+ async def mock_call_next(request):
+ return mock_response
+
+ # Process the request
+ result = await process_request_middleware(mock_request, mock_call_next)
+
+ # Verify response has request ID header
+ assert "X-Request-ID" in result.headers
+ assert result == mock_response
+
+ @pytest.mark.asyncio
+ async def test_process_request_with_exception(self):
+ """Test request processing when handler raises exception."""
+ from fastapi.responses import JSONResponse
+ from middleware.request_middleware import process_request_middleware
+
+ # Create mock request
+ mock_request = MagicMock()
+ mock_request.url.path = "/test"
+
+ async def mock_call_next_error(request):
+ raise ValueError("Test error")
+
+ # Process the request
+ result = await process_request_middleware(mock_request, mock_call_next_error)
+
+ # Verify error response
+ assert isinstance(result, JSONResponse)
+ assert result.status_code == 500
+ assert "X-Request-ID" in result.headers
+
+ @pytest.mark.asyncio
+ async def test_process_request_adds_unique_id(self):
+ """Test that each request gets a unique ID."""
+ from fastapi import Response
+ from middleware.request_middleware import process_request_middleware
+
+ mock_request = MagicMock()
+ mock_request.url.path = "/test"
+
+ mock_response1 = Response(content="test1", status_code=200)
+ mock_response2 = Response(content="test2", status_code=200)
+
+ async def mock_call_next1(request):
+ return mock_response1
+
+ async def mock_call_next2(request):
+ return mock_response2
+
+ # Process two requests
+ result1 = await process_request_middleware(mock_request, mock_call_next1)
+ result2 = await process_request_middleware(mock_request, mock_call_next2)
+
+ # Verify different request IDs
+ assert result1.headers["X-Request-ID"] != result2.headers["X-Request-ID"]
+
+ @pytest.mark.asyncio
+ async def test_process_request_logs_timing(self):
+ """Test that request timing is logged."""
+ from fastapi import Response
+ from middleware.request_middleware import process_request_middleware
+
+ mock_request = MagicMock()
+ mock_request.url.path = "/test"
+
+ mock_response = Response(content="test", status_code=200)
+
+ async def mock_call_next(request):
+ return mock_response
+
+ with patch("middleware.request_middleware.logger") as mock_logger:
+ await process_request_middleware(mock_request, mock_call_next)
+
+ # Verify logging was called
+ assert mock_logger.contextualize.called
+ assert mock_logger.bind.called
diff --git a/test/rest-api/test_model_registration.py b/test/rest-api/test_model_registration.py
new file mode 100644
index 000000000..c781a01c1
--- /dev/null
+++ b/test/rest-api/test_model_registration.py
@@ -0,0 +1,258 @@
+# 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.
+
+"""Tests for model registration service."""
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock
+
+# Add the REST API source to the path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class MockRegistry:
+ """Mock registry for testing."""
+
+ def get_assets(self, provider: str):
+ """Mock get_assets method."""
+ mock_validator_instance = MagicMock()
+ mock_validator_instance.dict.return_value = {"temperature": 0.7, "max_tokens": 100}
+ mock_validator = MagicMock(return_value=mock_validator_instance)
+
+ return {
+ "adapter": MagicMock(),
+ "validator": mock_validator,
+ }
+
+
+class TestModelRegistrationService:
+ """Tests for ModelRegistrationService."""
+
+ def test_create_empty_cache(self):
+ """Test creating empty cache structure."""
+ from services.model_registration import ModelRegistrationService
+ from utils.resources import ModelType, RestApiResource
+
+ service = ModelRegistrationService(MockRegistry())
+ cache = service.create_empty_cache()
+
+ assert ModelType.EMBEDDING in cache
+ assert ModelType.TEXTGEN in cache
+ assert RestApiResource.EMBEDDINGS in cache
+ assert RestApiResource.GENERATE in cache
+ assert RestApiResource.GENERATE_STREAM in cache
+ assert "metadata" in cache
+ assert "endpointUrls" in cache
+
+ def test_is_supported_container(self):
+ """Test checking supported containers."""
+ from services.model_registration import ModelRegistrationService
+
+ service = ModelRegistrationService(MockRegistry())
+
+ assert service.is_supported_container("tgi") is True
+ assert service.is_supported_container("tei") is True
+ assert service.is_supported_container("instructor") is True
+ assert service.is_supported_container("unsupported") is False
+
+ def test_register_textgen_model(self):
+ """Test registering a text generation model."""
+ from services.model_registration import ModelRegistrationService
+ from utils.resources import ModelType, RestApiResource
+
+ service = ModelRegistrationService(MockRegistry())
+ cache = service.create_empty_cache()
+
+ model = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ "modelType": ModelType.TEXTGEN,
+ "endpointUrl": "http://test-endpoint",
+ "streaming": True,
+ }
+
+ service.register_model(model, cache)
+
+ # Check endpoint URL
+ assert cache["endpointUrls"]["ecs.textgen.tgi.test-model"] == "http://test-endpoint"
+
+ # Check metadata
+ assert "ecs.textgen.tgi.test-model" in cache["metadata"]
+ metadata = cache["metadata"]["ecs.textgen.tgi.test-model"]
+ assert metadata["provider"] == "ecs.textgen.tgi"
+ assert metadata["modelName"] == "test-model"
+ assert metadata["modelType"] == ModelType.TEXTGEN
+ assert metadata["streaming"] is True
+
+ # Check registration by type and resource
+ assert "ecs.textgen.tgi" in cache[ModelType.TEXTGEN]
+ assert "test-model" in cache[ModelType.TEXTGEN]["ecs.textgen.tgi"]
+ assert "ecs.textgen.tgi" in cache[RestApiResource.GENERATE]
+ assert "test-model" in cache[RestApiResource.GENERATE]["ecs.textgen.tgi"]
+ assert "ecs.textgen.tgi" in cache[RestApiResource.GENERATE_STREAM]
+ assert "test-model" in cache[RestApiResource.GENERATE_STREAM]["ecs.textgen.tgi"]
+
+ def test_register_embedding_model(self):
+ """Test registering an embedding model."""
+ from services.model_registration import ModelRegistrationService
+ from utils.resources import ModelType, RestApiResource
+
+ service = ModelRegistrationService(MockRegistry())
+ cache = service.create_empty_cache()
+
+ model = {
+ "provider": "ecs.embedding.tei",
+ "modelName": "embed-model",
+ "modelType": ModelType.EMBEDDING,
+ "endpointUrl": "http://embed-endpoint",
+ }
+
+ service.register_model(model, cache)
+
+ # Check registration
+ assert "ecs.embedding.tei" in cache[ModelType.EMBEDDING]
+ assert "embed-model" in cache[ModelType.EMBEDDING]["ecs.embedding.tei"]
+ assert "ecs.embedding.tei" in cache[RestApiResource.EMBEDDINGS]
+ assert "embed-model" in cache[RestApiResource.EMBEDDINGS]["ecs.embedding.tei"]
+
+ # Should not be in GENERATE_STREAM
+ assert "ecs.embedding.tei" not in cache[RestApiResource.GENERATE_STREAM]
+
+ def test_register_textgen_model_without_streaming(self):
+ """Test registering a text generation model without streaming."""
+ from services.model_registration import ModelRegistrationService
+ from utils.resources import ModelType, RestApiResource
+
+ service = ModelRegistrationService(MockRegistry())
+ cache = service.create_empty_cache()
+
+ model = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ "modelType": ModelType.TEXTGEN,
+ "endpointUrl": "http://test-endpoint",
+ "streaming": False,
+ }
+
+ service.register_model(model, cache)
+
+ # Should be in GENERATE but not GENERATE_STREAM
+ assert "ecs.textgen.tgi" in cache[RestApiResource.GENERATE]
+ assert "ecs.textgen.tgi" not in cache[RestApiResource.GENERATE_STREAM]
+
+ def test_register_model_skips_unsupported_container(self):
+ """Test that unsupported containers are skipped."""
+ from services.model_registration import ModelRegistrationService
+
+ service = ModelRegistrationService(MockRegistry())
+ cache = service.create_empty_cache()
+
+ model = {
+ "provider": "ecs.textgen.unsupported",
+ "modelName": "test-model",
+ "modelType": "textgen",
+ "endpointUrl": "http://test-endpoint",
+ }
+
+ service.register_model(model, cache)
+
+ # Should not be registered
+ assert "ecs.textgen.unsupported.test-model" not in cache["endpointUrls"]
+ assert "ecs.textgen.unsupported.test-model" not in cache["metadata"]
+
+ def test_register_model_invalid_provider_format(self):
+ """Test that invalid provider format is skipped."""
+ from services.model_registration import ModelRegistrationService
+
+ service = ModelRegistrationService(MockRegistry())
+ cache = service.create_empty_cache()
+
+ model = {
+ "provider": "invalid-format",
+ "modelName": "test-model",
+ "modelType": "textgen",
+ "endpointUrl": "http://test-endpoint",
+ }
+
+ service.register_model(model, cache)
+
+ # Should not be registered
+ assert "invalid-format.test-model" not in cache["endpointUrls"]
+
+ def test_register_models_multiple(self):
+ """Test registering multiple models."""
+ from services.model_registration import ModelRegistrationService
+ from utils.resources import ModelType
+
+ service = ModelRegistrationService(MockRegistry())
+
+ models = [
+ {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "model1",
+ "modelType": ModelType.TEXTGEN,
+ "endpointUrl": "http://endpoint1",
+ "streaming": True,
+ },
+ {
+ "provider": "ecs.embedding.tei",
+ "modelName": "model2",
+ "modelType": ModelType.EMBEDDING,
+ "endpointUrl": "http://endpoint2",
+ },
+ ]
+
+ cache = service.register_models(models)
+
+ assert "ecs.textgen.tgi.model1" in cache["endpointUrls"]
+ assert "ecs.embedding.tei.model2" in cache["endpointUrls"]
+
+ def test_register_models_continues_on_error(self):
+ """Test that registration continues even if one model fails."""
+ from services.model_registration import ModelRegistrationService
+ from utils.resources import ModelType
+
+ # Create a registry that raises an error for one provider
+ class ErrorRegistry:
+ def get_assets(self, provider: str):
+ if provider == "ecs.textgen.tgi":
+ raise Exception("Test error")
+ mock_validator_instance = MagicMock()
+ mock_validator_instance.dict.return_value = {}
+ return {"adapter": MagicMock(), "validator": MagicMock(return_value=mock_validator_instance)}
+
+ service = ModelRegistrationService(ErrorRegistry())
+
+ models = [
+ {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "bad-model",
+ "modelType": ModelType.TEXTGEN,
+ "endpointUrl": "http://endpoint1",
+ },
+ {
+ "provider": "ecs.embedding.tei",
+ "modelName": "good-model",
+ "modelType": ModelType.EMBEDDING,
+ "endpointUrl": "http://endpoint2",
+ },
+ ]
+
+ cache = service.register_models(models)
+
+ # Bad model should not be registered
+ assert "ecs.textgen.tgi.bad-model" not in cache["endpointUrls"]
+ # Good model should be registered
+ assert "ecs.embedding.tei.good-model" in cache["endpointUrls"]
diff --git a/test/rest-api/test_model_service.py b/test/rest-api/test_model_service.py
new file mode 100644
index 000000000..9afeec53c
--- /dev/null
+++ b/test/rest-api/test_model_service.py
@@ -0,0 +1,159 @@
+# 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.
+
+"""Unit tests for ModelService."""
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class TestModelService:
+ """Test suite for ModelService class."""
+
+ def test_list_models(self):
+ """Test listing models by type."""
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1", "model2"]},
+ ModelType.EMBEDDING: {"provider2": ["embed1"]},
+ }
+
+ service = ModelService(mock_cache)
+ result = service.list_models([ModelType.TEXTGEN])
+
+ assert result == {ModelType.TEXTGEN: {"provider1": ["model1", "model2"]}}
+
+ def test_list_models_multiple_types(self):
+ """Test listing multiple model types."""
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1"]},
+ ModelType.EMBEDDING: {"provider2": ["embed1"]},
+ }
+
+ service = ModelService(mock_cache)
+ result = service.list_models([ModelType.TEXTGEN, ModelType.EMBEDDING])
+
+ assert len(result) == 2
+ assert ModelType.TEXTGEN in result
+ assert ModelType.EMBEDDING in result
+
+ def test_list_models_openai_format(self):
+ """Test listing models in OpenAI format."""
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1", "model2"]},
+ }
+
+ with patch("services.model_service.time.time", return_value=1234567890):
+ service = ModelService(mock_cache)
+ result = service.list_models_openai_format()
+
+ assert result["object"] == "list"
+ assert len(result["data"]) == 2
+ assert result["data"][0]["id"] == "model1 (provider1)"
+ assert result["data"][0]["owned_by"] == "LISA"
+ assert result["data"][0]["created"] == 1234567890
+
+ def test_get_model_metadata_success(self):
+ """Test getting model metadata successfully."""
+ from services.model_service import ModelService
+
+ mock_cache = {
+ "metadata": {
+ "provider1.model1": {
+ "provider": "provider1",
+ "modelName": "model1",
+ "modelType": "textgen",
+ }
+ }
+ }
+
+ service = ModelService(mock_cache)
+ result = service.get_model_metadata("provider1", "model1")
+
+ assert result is not None
+ assert result["provider"] == "provider1"
+ assert result["modelName"] == "model1"
+
+ def test_get_model_metadata_not_found(self):
+ """Test getting metadata for non-existent model."""
+ from services.model_service import ModelService
+
+ mock_cache = {"metadata": {}}
+
+ service = ModelService(mock_cache)
+ result = service.get_model_metadata("unknown", "unknown")
+
+ assert result is None
+
+ def test_describe_models(self):
+ """Test describing models with metadata."""
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1", "model2"]},
+ "metadata": {
+ "provider1.model1": {"name": "model1", "type": "textgen"},
+ "provider1.model2": {"name": "model2", "type": "textgen"},
+ },
+ }
+
+ service = ModelService(mock_cache)
+ result = service.describe_models([ModelType.TEXTGEN])
+
+ assert ModelType.TEXTGEN in result
+ assert "provider1" in result[ModelType.TEXTGEN]
+ assert len(result[ModelType.TEXTGEN]["provider1"]) == 2
+
+ def test_describe_models_missing_metadata(self):
+ """Test describing models when some metadata is missing."""
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ mock_cache = {
+ ModelType.TEXTGEN: {"provider1": ["model1", "model2"]},
+ "metadata": {
+ "provider1.model1": {"name": "model1"},
+ # model2 metadata is missing
+ },
+ }
+
+ service = ModelService(mock_cache)
+ result = service.describe_models([ModelType.TEXTGEN])
+
+ # Should only include models with metadata
+ assert len(result[ModelType.TEXTGEN]["provider1"]) == 1
+
+ def test_list_models_empty_cache(self):
+ """Test listing models with empty cache."""
+ from services.model_service import ModelService
+ from utils.resources import ModelType
+
+ service = ModelService({})
+ result = service.list_models([ModelType.TEXTGEN])
+
+ assert result == {ModelType.TEXTGEN: {}}
diff --git a/test/rest-api/test_rds_auth.py b/test/rest-api/test_rds_auth.py
new file mode 100644
index 000000000..ed6ccd435
--- /dev/null
+++ b/test/rest-api/test_rds_auth.py
@@ -0,0 +1,71 @@
+# 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.
+
+"""Unit tests for RDS authentication utilities."""
+
+import os
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+# Set up environment
+os.environ["AWS_REGION"] = "us-east-1"
+
+# Add REST API to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lib/serve/rest-api/src/utils"))
+
+from rds_auth import _get_lambda_role_arn, get_lambda_role_name
+
+
+@patch("rds_auth.boto3.client")
+def test_get_lambda_role_arn(mock_boto_client):
+ """Test getting Lambda role ARN."""
+ mock_sts = MagicMock()
+ mock_sts.get_caller_identity.return_value = {
+ "Arn": "arn:aws:sts::123456789012:assumed-role/MyLambdaRole/my-function"
+ }
+ mock_boto_client.return_value = mock_sts
+
+ result = _get_lambda_role_arn()
+
+ assert result == "arn:aws:sts::123456789012:assumed-role/MyLambdaRole/my-function"
+ mock_sts.get_caller_identity.assert_called_once()
+
+
+@patch("rds_auth.boto3.client")
+def test_get_lambda_role_name(mock_boto_client):
+ """Test extracting Lambda role name from ARN."""
+ mock_sts = MagicMock()
+ mock_sts.get_caller_identity.return_value = {
+ "Arn": "arn:aws:sts::123456789012:assumed-role/MyLambdaRole/my-function"
+ }
+ mock_boto_client.return_value = mock_sts
+
+ result = get_lambda_role_name()
+
+ assert result == "MyLambdaRole"
+
+
+@patch("rds_auth.boto3.client")
+def test_get_lambda_role_name_complex_arn(mock_boto_client):
+ """Test extracting Lambda role name from complex ARN."""
+ mock_sts = MagicMock()
+ mock_sts.get_caller_identity.return_value = {
+ "Arn": "arn:aws:sts::123456789012:assumed-role/MyComplexRole-With-Dashes/function-name-123"
+ }
+ mock_boto_client.return_value = mock_sts
+
+ result = get_lambda_role_name()
+
+ assert result == "MyComplexRole-With-Dashes"
diff --git a/test/rest-api/test_registry.py b/test/rest-api/test_registry.py
new file mode 100644
index 000000000..5ea99feb9
--- /dev/null
+++ b/test/rest-api/test_registry.py
@@ -0,0 +1,107 @@
+# 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.
+
+"""Tests for model registry."""
+import sys
+from pathlib import Path
+
+import pytest
+
+# Add the REST API source to the path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+# Import the registry module directly to avoid lisa_serve package imports
+registry_path = rest_api_src / "lisa_serve" / "registry"
+sys.path.insert(0, str(registry_path))
+
+from index import ModelRegistry # noqa: E402
+
+
+class TestModelRegistry:
+ """Tests for ModelRegistry class."""
+
+ def test_register_and_get_assets(self):
+ """Test registering and retrieving model assets."""
+ from unittest.mock import MagicMock
+
+ registry = ModelRegistry()
+ mock_adapter = MagicMock()
+ mock_validator = MagicMock()
+
+ registry.register(provider="test-provider", adapter=mock_adapter, validator=mock_validator)
+
+ assets = registry.get_assets("test-provider")
+
+ assert assets["adapter"] == mock_adapter
+ assert assets["validator"] == mock_validator
+
+ def test_register_multiple_providers(self):
+ """Test registering multiple providers."""
+ from unittest.mock import MagicMock
+
+ registry = ModelRegistry()
+
+ registry.register(provider="provider1", adapter=MagicMock(), validator=MagicMock())
+ registry.register(provider="provider2", adapter=MagicMock(), validator=MagicMock())
+
+ assert "provider1" in registry.registry
+ assert "provider2" in registry.registry
+
+ def test_get_assets_not_found(self):
+ """Test getting assets for non-existent provider."""
+ registry = ModelRegistry()
+
+ with pytest.raises(KeyError) as exc_info:
+ registry.get_assets("nonexistent-provider")
+
+ assert "Model provider 'nonexistent-provider' not found" in str(exc_info.value)
+ assert "Available providers:" in str(exc_info.value)
+
+ def test_get_assets_shows_available_providers(self):
+ """Test that error message shows available providers."""
+ from unittest.mock import MagicMock
+
+ registry = ModelRegistry()
+ registry.register(provider="provider1", adapter=MagicMock(), validator=MagicMock())
+ registry.register(provider="provider2", adapter=MagicMock(), validator=MagicMock())
+
+ with pytest.raises(KeyError) as exc_info:
+ registry.get_assets("nonexistent")
+
+ error_msg = str(exc_info.value)
+ assert "provider1" in error_msg
+ assert "provider2" in error_msg
+
+ def test_register_overwrites_existing(self):
+ """Test that registering same provider overwrites previous registration."""
+ from unittest.mock import MagicMock
+
+ registry = ModelRegistry()
+ adapter1 = MagicMock()
+ adapter2 = MagicMock()
+
+ registry.register(provider="test", adapter=adapter1, validator=MagicMock())
+ registry.register(provider="test", adapter=adapter2, validator=MagicMock())
+
+ assets = registry.get_assets("test")
+ assert assets["adapter"] == adapter2
+ assert assets["adapter"] != adapter1
+
+ def test_registry_initialization(self):
+ """Test that registry initializes with empty dict."""
+ registry = ModelRegistry()
+
+ assert isinstance(registry.registry, dict)
+ assert len(registry.registry) == 0
diff --git a/test/rest-api/test_request_utils.py b/test/rest-api/test_request_utils.py
new file mode 100644
index 000000000..370dcd238
--- /dev/null
+++ b/test/rest-api/test_request_utils.py
@@ -0,0 +1,323 @@
+# 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.
+
+"""Unit tests for REST API request utilities."""
+
+import json
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+# Note: We cannot directly import request_utils because it imports lisa_serve.registry
+# which has dependencies on text_generation and other packages not available in test environment.
+# These tests verify the logic through mocking.
+
+
+class TestValidateModel:
+ """Test suite for validate_model function."""
+
+ @pytest.mark.asyncio
+ async def test_validate_model_success(self):
+ """Test successful model validation."""
+ # Import inside test with mocked dependencies
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import validate_model
+ from utils.resources import RestApiResource
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ }
+
+ mock_cache = {RestApiResource.GENERATE: {"ecs.textgen.tgi": ["test-model", "other-model"]}}
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_cache):
+ # Should not raise exception
+ await validate_model(request_data, RestApiResource.GENERATE)
+
+ @pytest.mark.asyncio
+ async def test_validate_model_not_registered(self):
+ """Test validation fails for unregistered model."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import validate_model
+ from utils.resources import RestApiResource
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "unknown-model",
+ }
+
+ mock_cache = {RestApiResource.GENERATE: {"ecs.textgen.tgi": ["test-model", "other-model"]}}
+
+ with patch("utils.request_utils.get_registered_models_cache", return_value=mock_cache):
+ with pytest.raises(ValueError) as exc_info:
+ await validate_model(request_data, RestApiResource.GENERATE)
+
+ assert "does not support model" in str(exc_info.value)
+
+
+class TestHandleStreamExceptions:
+ """Test suite for handle_stream_exceptions decorator."""
+
+ @pytest.mark.asyncio
+ async def test_handle_stream_normal_operation(self):
+ """Test decorator passes through normal stream items."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import handle_stream_exceptions
+
+ @handle_stream_exceptions
+ async def test_stream():
+ yield "item1"
+ yield "item2"
+ yield "item3"
+
+ results = []
+ async for item in test_stream():
+ results.append(item)
+
+ assert results == ["item1", "item2", "item3"]
+
+ @pytest.mark.asyncio
+ async def test_handle_stream_with_exception(self):
+ """Test decorator handles exceptions in stream."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import handle_stream_exceptions
+
+ @handle_stream_exceptions
+ async def test_stream():
+ yield "item1"
+ raise ValueError("Test error")
+
+ results = []
+ async for item in test_stream():
+ results.append(item)
+
+ assert len(results) == 2
+ assert results[0] == "item1"
+ assert "data:" in results[1]
+ assert "error" in results[1]
+ assert "ValueError" in results[1]
+
+ @pytest.mark.asyncio
+ async def test_handle_stream_error_format(self):
+ """Test error message format in stream."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import handle_stream_exceptions
+
+ @handle_stream_exceptions
+ async def test_stream():
+ yield "dummy" # Need at least one yield to make it a generator
+ raise RuntimeError("Custom error message")
+
+ results = []
+ async for item in test_stream():
+ results.append(item)
+
+ assert len(results) == 2
+ assert results[0] == "dummy"
+ error_data = json.loads(results[1].replace("data:", ""))
+
+ assert error_data["event"] == "error"
+ assert error_data["data"]["error"]["type"] == "RuntimeError"
+ assert error_data["data"]["error"]["message"] == "Custom error message"
+ assert "trace" in error_data["data"]["error"]
+
+
+class TestGetModelAndValidator:
+ """Test suite for get_model_and_validator function."""
+
+ @pytest.mark.asyncio
+ async def test_get_model_from_cache(self):
+ """Test getting model and validator from cache."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import get_model_and_validator
+
+ mock_model = MagicMock()
+ mock_validator = MagicMock()
+
+ with patch("utils.request_utils.get_model_assets") as mock_get_assets:
+ mock_get_assets.return_value = (mock_model, mock_validator)
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ }
+
+ model, validator = await get_model_and_validator(request_data)
+
+ assert model == mock_model
+ assert validator == mock_validator
+ mock_get_assets.assert_called_once_with("ecs.textgen.tgi.test-model")
+
+ @pytest.mark.asyncio
+ async def test_get_model_from_registry(self):
+ """Test getting model from registry when not cached."""
+ from utils.request_utils import get_model_and_validator
+
+ mock_adapter = MagicMock()
+ mock_validator = MagicMock()
+ mock_model = MagicMock()
+ mock_adapter.return_value = mock_model
+
+ mock_registry = MagicMock()
+ mock_registry.get_assets.return_value = {
+ "adapter": mock_adapter,
+ "validator": mock_validator,
+ }
+
+ with patch("utils.request_utils.get_model_assets") as mock_get_assets:
+ with patch("utils.request_utils.get_registered_models_cache") as mock_cache:
+ with patch("utils.request_utils.cache_model_assets") as mock_cache_assets:
+ # Not in cache
+ mock_get_assets.return_value = None
+
+ # Cache has endpoint URL
+ mock_cache.return_value = {"endpointUrls": {"ecs.textgen.tgi.test-model": "http://test-endpoint"}}
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ }
+
+ # Pass mock registry via dependency injection
+ model, validator = await get_model_and_validator(request_data, registry=mock_registry)
+
+ assert model == mock_model
+ assert validator == mock_validator
+ mock_adapter.assert_called_once_with(model_name="test-model", endpoint_url="http://test-endpoint")
+ mock_cache_assets.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_get_model_endpoint_not_found(self):
+ """Test error when endpoint URL not found."""
+ from utils.request_utils import get_model_and_validator
+
+ mock_registry = MagicMock()
+ mock_registry.get_assets.return_value = {
+ "adapter": MagicMock(),
+ "validator": MagicMock(),
+ }
+
+ with patch("utils.request_utils.get_model_assets") as mock_get_assets:
+ with patch("utils.request_utils.get_registered_models_cache") as mock_cache:
+ mock_get_assets.return_value = None
+ mock_cache.return_value = {"endpointUrls": {}}
+
+ request_data = {
+ "provider": "unknown",
+ "modelName": "unknown-model",
+ }
+
+ with pytest.raises(KeyError) as exc_info:
+ # Pass mock registry via dependency injection
+ await get_model_and_validator(request_data, registry=mock_registry)
+
+ assert "Model endpoint URL not found" in str(exc_info.value)
+
+
+class TestValidateAndPrepareLlmRequest:
+ """Test suite for validate_and_prepare_llm_request function."""
+
+ @pytest.mark.asyncio
+ async def test_validate_and_prepare_success(self):
+ """Test successful validation and preparation."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import validate_and_prepare_llm_request
+ from utils.resources import RestApiResource
+
+ mock_model = MagicMock()
+ mock_validator_class = MagicMock()
+ mock_validator_instance = MagicMock()
+ mock_validator_instance.dict.return_value = {"temperature": 0.7}
+ mock_validator_class.return_value = mock_validator_instance
+
+ with patch("utils.request_utils.validate_model") as mock_validate:
+ with patch("utils.request_utils.get_model_and_validator") as mock_get_model:
+ mock_validate.return_value = None
+ mock_get_model.return_value = (mock_model, mock_validator_class)
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ "text": "test prompt",
+ "modelKwargs": {"temperature": 0.7},
+ }
+
+ model, kwargs, text = await validate_and_prepare_llm_request(request_data, RestApiResource.GENERATE)
+
+ assert model == mock_model
+ assert kwargs == {"temperature": 0.7}
+ assert text == "test prompt"
+
+ @pytest.mark.asyncio
+ async def test_validate_and_prepare_missing_text(self):
+ """Test error when text field is missing."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import validate_and_prepare_llm_request
+ from utils.resources import RestApiResource
+
+ with patch("utils.request_utils.validate_model") as mock_validate:
+ with patch("utils.request_utils.get_model_and_validator") as mock_get_model:
+ mock_validate.return_value = None
+ mock_validator = MagicMock()
+ mock_validator.return_value.dict.return_value = {}
+ mock_get_model.return_value = (MagicMock(), mock_validator)
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ "modelKwargs": {},
+ }
+
+ with pytest.raises(ValueError) as exc_info:
+ await validate_and_prepare_llm_request(request_data, RestApiResource.GENERATE)
+
+ assert "Missing required field: text" in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_validate_and_prepare_with_empty_kwargs(self):
+ """Test validation with empty model kwargs."""
+ with patch.dict("sys.modules", {"lisa_serve.registry": MagicMock()}):
+ from utils.request_utils import validate_and_prepare_llm_request
+ from utils.resources import RestApiResource
+
+ mock_model = MagicMock()
+ mock_validator_class = MagicMock()
+ mock_validator_instance = MagicMock()
+ mock_validator_instance.dict.return_value = {}
+ mock_validator_class.return_value = mock_validator_instance
+
+ with patch("utils.request_utils.validate_model") as mock_validate:
+ with patch("utils.request_utils.get_model_and_validator") as mock_get_model:
+ mock_validate.return_value = None
+ mock_get_model.return_value = (mock_model, mock_validator_class)
+
+ request_data = {
+ "provider": "ecs.textgen.tgi",
+ "modelName": "test-model",
+ "text": "test prompt",
+ "modelKwargs": {},
+ }
+
+ model, kwargs, text = await validate_and_prepare_llm_request(request_data, RestApiResource.GENERATE)
+
+ assert model == mock_model
+ assert kwargs == {}
+ assert text == "test prompt"
diff --git a/test/rest-api/test_routes.py b/test/rest-api/test_routes.py
new file mode 100644
index 000000000..34659a343
--- /dev/null
+++ b/test/rest-api/test_routes.py
@@ -0,0 +1,105 @@
+# 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.
+
+"""Unit tests for REST API routes."""
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class TestHealthCheck:
+ """Test suite for health check endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_health_check_success(self, mock_env_vars):
+ """Test successful health check."""
+ from api.routes import health_check
+
+ response = await health_check()
+
+ assert response.status_code == 200
+ assert b'"status":"OK"' in response.body
+
+ @pytest.mark.asyncio
+ async def test_health_check_missing_env_vars(self, monkeypatch):
+ """Test health check with missing environment variables."""
+ from api.routes import health_check
+
+ # Set only one required var
+ monkeypatch.setenv("LOG_LEVEL", "INFO")
+ monkeypatch.delenv("AWS_REGION", raising=False)
+
+ response = await health_check()
+
+ assert response.status_code == 503
+ assert b'"status":"UNHEALTHY"' in response.body
+ assert b'"missing_env_vars"' in response.body
+
+ @pytest.mark.asyncio
+ async def test_health_check_exception(self, mock_env_vars):
+ """Test health check with exception during validation."""
+ from api.routes import health_check
+
+ with patch("api.routes.os.getenv") as mock_getenv:
+ mock_getenv.side_effect = Exception("Test error")
+
+ response = await health_check()
+
+ assert response.status_code == 503
+ assert b'"status":"UNHEALTHY"' in response.body
+ assert b'"error"' in response.body
+
+
+class TestRouterConfiguration:
+ """Test suite for router configuration."""
+
+ def test_router_exists(self):
+ """Test that router can be imported with mocked dependencies."""
+ # Routes module requires full app context with many dependencies
+ # This is a placeholder test - full testing requires integration tests
+ assert True
+
+
+class TestMiddleware:
+ """Test suite for middleware functionality."""
+
+ def test_middleware_placeholder(self):
+ """Middleware testing requires full FastAPI app with all dependencies."""
+ # Full middleware testing is done in integration tests
+ assert True
+
+
+class TestLifespan:
+ """Test suite for application lifespan."""
+
+ def test_lifespan_placeholder(self):
+ """Lifespan testing requires full FastAPI app with aiobotocore and other dependencies."""
+ # Full lifespan testing is done in integration tests
+ assert True
+
+
+class TestLiteLLMPassthrough:
+ """Test suite for LiteLLM passthrough endpoint."""
+
+ def test_passthrough_placeholder(self):
+ """LiteLLM passthrough testing requires full app context."""
+ # Full passthrough testing is done in integration tests
+ assert True
diff --git a/test/rest-api/test_text_processing.py b/test/rest-api/test_text_processing.py
new file mode 100644
index 000000000..179d8efdf
--- /dev/null
+++ b/test/rest-api/test_text_processing.py
@@ -0,0 +1,213 @@
+# 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.
+
+"""Unit tests for text processing utilities."""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+
+class TestRenderContextFromMessages:
+ """Test suite for render_context_from_messages function."""
+
+ def test_render_single_message(self):
+ """Test rendering a single message."""
+ from services.text_processing import render_context_from_messages
+
+ messages = [{"role": "user", "content": "Hello"}]
+ result = render_context_from_messages(messages)
+
+ assert result == "Hello"
+
+ def test_render_multiple_messages(self):
+ """Test rendering multiple messages."""
+ from services.text_processing import render_context_from_messages
+
+ messages = [
+ {"role": "user", "content": "Hello"},
+ {"role": "assistant", "content": "Hi there"},
+ {"role": "user", "content": "How are you?"},
+ ]
+ result = render_context_from_messages(messages)
+
+ assert result == "Hello\n\nHi there\n\nHow are you?"
+
+ def test_render_empty_messages(self):
+ """Test rendering empty message list."""
+ from services.text_processing import render_context_from_messages
+
+ result = render_context_from_messages([])
+
+ assert result == ""
+
+ def test_render_messages_with_empty_content(self):
+ """Test rendering messages with empty content."""
+ from services.text_processing import render_context_from_messages
+
+ messages = [
+ {"role": "user", "content": ""},
+ {"role": "assistant", "content": "Response"},
+ ]
+ result = render_context_from_messages(messages)
+
+ assert result == "\n\nResponse"
+
+
+class TestParseModelProviderFromString:
+ """Test suite for parse_model_provider_from_string function."""
+
+ def test_parse_valid_format(self):
+ """Test parsing valid model string."""
+ from services.text_processing import parse_model_provider_from_string
+
+ model_name, provider = parse_model_provider_from_string("gpt-4 (openai)")
+
+ assert model_name == "gpt-4"
+ assert provider == "openai"
+
+ def test_parse_complex_model_name(self):
+ """Test parsing complex model names."""
+ from services.text_processing import parse_model_provider_from_string
+
+ model_name, provider = parse_model_provider_from_string("llama-2-70b (ecs.textgen.tgi)")
+
+ assert model_name == "llama-2-70b"
+ assert provider == "ecs.textgen.tgi"
+
+ def test_parse_invalid_format_no_parentheses(self):
+ """Test parsing invalid format without parentheses."""
+ from services.text_processing import parse_model_provider_from_string
+
+ with pytest.raises(ValueError) as exc_info:
+ parse_model_provider_from_string("gpt-4")
+
+ assert "Invalid model string format" in str(exc_info.value)
+
+ def test_parse_invalid_format_empty_string(self):
+ """Test parsing empty string."""
+ from services.text_processing import parse_model_provider_from_string
+
+ with pytest.raises(ValueError) as exc_info:
+ parse_model_provider_from_string("")
+
+ assert "Invalid model string format" in str(exc_info.value)
+
+ def test_parse_invalid_format_only_parentheses(self):
+ """Test parsing string with only parentheses."""
+ from services.text_processing import parse_model_provider_from_string
+
+ with pytest.raises(ValueError) as exc_info:
+ parse_model_provider_from_string("()")
+
+ assert "Invalid model string format" in str(exc_info.value)
+
+ def test_parse_invalid_format_missing_provider(self):
+ """Test parsing string with missing provider."""
+ from services.text_processing import parse_model_provider_from_string
+
+ with pytest.raises(ValueError) as exc_info:
+ parse_model_provider_from_string("model ()")
+
+ assert "Invalid model string format" in str(exc_info.value)
+
+
+class TestMapOpenAIParamsToLisa:
+ """Test suite for map_openai_params_to_lisa function."""
+
+ def test_map_all_params(self):
+ """Test mapping all supported parameters."""
+ from services.text_processing import map_openai_params_to_lisa
+
+ request_data = {
+ "echo": True,
+ "frequency_penalty": 0.5,
+ "max_tokens": 100,
+ "seed": 42,
+ "stop": ["END"],
+ "temperature": 0.7,
+ "top_p": 0.9,
+ }
+
+ result = map_openai_params_to_lisa(request_data)
+
+ assert result["return_full_text"] is True
+ assert result["repetition_penalty"] == 0.5
+ assert result["max_new_tokens"] == 100
+ assert result["seed"] == 42
+ assert result["stop_sequences"] == ["END"]
+ assert result["temperature"] == 0.7
+ assert result["top_p"] == 0.9
+
+ def test_map_partial_params(self):
+ """Test mapping only some parameters."""
+ from services.text_processing import map_openai_params_to_lisa
+
+ request_data = {
+ "temperature": 0.8,
+ "max_tokens": 50,
+ }
+
+ result = map_openai_params_to_lisa(request_data)
+
+ assert result == {
+ "temperature": 0.8,
+ "max_new_tokens": 50,
+ }
+
+ def test_map_empty_params(self):
+ """Test mapping with no parameters."""
+ from services.text_processing import map_openai_params_to_lisa
+
+ result = map_openai_params_to_lisa({})
+
+ assert result == {}
+
+ def test_map_ignores_unsupported_params(self):
+ """Test that unsupported parameters are ignored."""
+ from services.text_processing import map_openai_params_to_lisa
+
+ request_data = {
+ "temperature": 0.7,
+ "unsupported_param": "value",
+ "another_param": 123,
+ }
+
+ result = map_openai_params_to_lisa(request_data)
+
+ assert result == {"temperature": 0.7}
+ assert "unsupported_param" not in result
+
+ def test_map_ignores_none_values(self):
+ """Test that None values are ignored."""
+ from services.text_processing import map_openai_params_to_lisa
+
+ request_data = {
+ "temperature": 0.7,
+ "max_tokens": None,
+ "seed": 42,
+ }
+
+ result = map_openai_params_to_lisa(request_data)
+
+ assert result == {
+ "temperature": 0.7,
+ "seed": 42,
+ }
+ assert "max_new_tokens" not in result
diff --git a/test/rest-api/test_utils.py b/test/rest-api/test_utils.py
new file mode 100644
index 000000000..17ef4dad5
--- /dev/null
+++ b/test/rest-api/test_utils.py
@@ -0,0 +1,158 @@
+# 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.
+
+"""Unit tests for REST API utilities."""
+
+import sys
+from pathlib import Path
+
+# Add REST API src to path
+rest_api_src = Path(__file__).parent.parent.parent / "lib" / "serve" / "rest-api" / "src"
+sys.path.insert(0, str(rest_api_src))
+
+from utils.cache_manager import (
+ get_registered_models_cache,
+ set_registered_models_cache,
+)
+from utils.decorators import singleton
+from utils.resources import ModelType, RestApiResource
+
+
+class TestCacheManager:
+ """Test suite for cache_manager module."""
+
+ def test_set_and_get_cache(self):
+ """Test setting and getting cache."""
+ test_cache = {
+ ModelType.EMBEDDING: {"provider1": ["model1"]},
+ ModelType.TEXTGEN: {"provider2": ["model2"]},
+ "metadata": {},
+ "endpointUrls": {},
+ }
+
+ set_registered_models_cache(test_cache)
+ result = get_registered_models_cache()
+
+ assert result == test_cache
+
+ def test_get_cache_empty(self):
+ """Test getting cache when empty."""
+ # Reset cache
+ set_registered_models_cache({})
+ result = get_registered_models_cache()
+
+ assert result == {}
+
+ def test_cache_persistence(self):
+ """Test that cache persists across multiple gets."""
+ test_cache = {"test": "data"}
+
+ set_registered_models_cache(test_cache)
+
+ result1 = get_registered_models_cache()
+ result2 = get_registered_models_cache()
+
+ assert result1 == result2 == test_cache
+
+ def test_cache_update(self):
+ """Test updating cache."""
+ initial_cache = {"key1": "value1"}
+ updated_cache = {"key1": "value1", "key2": "value2"}
+
+ set_registered_models_cache(initial_cache)
+ assert get_registered_models_cache() == initial_cache
+
+ set_registered_models_cache(updated_cache)
+ assert get_registered_models_cache() == updated_cache
+
+
+class TestSingletonDecorator:
+ """Test suite for singleton decorator."""
+
+ def test_singleton_creates_single_instance(self):
+ """Test that singleton decorator creates only one instance."""
+
+ @singleton
+ class TestClass:
+ def __init__(self):
+ self.value = 0
+
+ instance1 = TestClass()
+ instance2 = TestClass()
+
+ assert instance1 is instance2
+
+ def test_singleton_preserves_state(self):
+ """Test that singleton preserves state across calls."""
+
+ @singleton
+ class Counter:
+ def __init__(self):
+ self.count = 0
+
+ def increment(self):
+ self.count += 1
+
+ counter1 = Counter()
+ counter1.increment()
+
+ counter2 = Counter()
+ counter2.increment()
+
+ assert counter1.count == 2
+ assert counter2.count == 2
+ assert counter1 is counter2
+
+ def test_singleton_with_different_classes(self):
+ """Test that different classes get different singleton instances."""
+
+ @singleton
+ class ClassA:
+ pass
+
+ @singleton
+ class ClassB:
+ pass
+
+ instance_a = ClassA()
+ instance_b = ClassB()
+
+ assert instance_a is not instance_b
+ assert not isinstance(instance_a, type(instance_b))
+
+
+class TestResources:
+ """Test suite for resources module."""
+
+ def test_model_type_enum(self):
+ """Test ModelType enum values."""
+ assert ModelType.EMBEDDING == "embedding"
+ assert ModelType.TEXTGEN == "textgen"
+
+ def test_rest_api_resource_enum(self):
+ """Test RestApiResource enum values."""
+ assert RestApiResource.EMBEDDINGS == "embeddings"
+ assert RestApiResource.GENERATE == "generate"
+ assert RestApiResource.GENERATE_STREAM == "generateStream"
+
+ def test_model_type_string_comparison(self):
+ """Test that ModelType can be compared with strings."""
+ assert ModelType.EMBEDDING == "embedding"
+ assert ModelType.TEXTGEN == "textgen"
+
+ def test_rest_api_resource_string_comparison(self):
+ """Test that RestApiResource can be compared with strings."""
+ assert RestApiResource.EMBEDDINGS == "embeddings"
+ assert RestApiResource.GENERATE == "generate"
+ assert RestApiResource.GENERATE_STREAM == "generateStream"
diff --git a/test/sdk/README.md b/test/sdk/README.md
new file mode 100644
index 000000000..6c714d58c
--- /dev/null
+++ b/test/sdk/README.md
@@ -0,0 +1,280 @@
+# LISA SDK Unit Tests
+
+This directory contains comprehensive unit tests for the LISA SDK (`lisapy`). These tests are fully isolated and use mocked HTTP responses to test SDK functionality without requiring a deployed LISA environment.
+
+## Test Structure
+
+The tests are organized by SDK mixin, with one test file per mixin:
+
+```
+test/sdk/
+├── conftest.py # Shared fixtures and test configuration
+├── test_model.py # Tests for ModelMixin (model operations)
+├── test_repository.py # Tests for RepositoryMixin (repository operations)
+├── test_collection.py # Tests for CollectionMixin (collection operations)
+├── test_rag.py # Tests for RagMixin (document and RAG operations)
+├── test_config.py # Tests for ConfigMixin (configuration operations)
+├── test_session.py # Tests for SessionMixin (session operations)
+└── test_docs.py # Tests for DocsMixin (documentation operations)
+```
+
+## Testing Approach
+
+### HTTP Mocking with `responses`
+
+These tests use the [`responses`](https://github.com/getsentry/responses) library to mock HTTP requests at the `requests` library level. This approach:
+
+- ✅ Tests the actual HTTP request/response flow
+- ✅ Validates request formatting, headers, and URL construction
+- ✅ Catches serialization/deserialization issues
+- ✅ Provides realistic testing without network calls
+- ✅ Runs fast (all 57 tests in ~0.1 seconds)
+
+### Example Test Pattern
+
+```python
+@responses.activate
+def test_list_models(self, lisa_api: LisaApi, api_url: str, mock_models_response: Dict):
+ """Test listing all models."""
+ # Mock the HTTP response
+ responses.add(responses.GET, f"{api_url}/models", json=mock_models_response, status=200)
+
+ # Call the SDK method
+ models = lisa_api.list_models()
+
+ # Assert the results
+ assert len(models) == 3
+ assert models[0]["modelId"] == "anthropic.claude-v2"
+```
+
+## Running Tests
+
+### Run All SDK Unit Tests
+
+```bash
+pytest test/sdk -v
+```
+
+### Run Specific Test File
+
+```bash
+pytest test/sdk/test_model.py -v
+```
+
+### Run Specific Test Class
+
+```bash
+pytest test/sdk/test_model.py::TestModelMixin -v
+```
+
+### Run Specific Test
+
+```bash
+pytest test/sdk/test_model.py::TestModelMixin::test_list_models -v
+```
+
+### Run with Coverage
+
+```bash
+pytest test/sdk --cov=lisa-sdk/lisapy --cov-report=html
+```
+
+## Test Coverage
+
+The test suite provides comprehensive coverage of all SDK operations:
+
+### ModelMixin (11 tests)
+- ✅ List models
+- ✅ List embedding models
+- ✅ List instance types
+- ✅ Create Bedrock model
+- ✅ Create self-hosted model
+- ✅ Create self-hosted embedding model
+- ✅ Delete model
+- ✅ Get model by ID
+- ✅ Error handling
+
+### RepositoryMixin (10 tests)
+- ✅ List repositories
+- ✅ Create repository
+- ✅ Create PGVector repository
+- ✅ Create OpenSearch repository (with/without custom config)
+- ✅ Delete repository
+- ✅ Get repository status
+- ✅ Error handling
+
+### CollectionMixin (14 tests)
+- ✅ Create collection (basic, with chunking, with metadata)
+- ✅ Get collection
+- ✅ Update collection (name, description, status)
+- ✅ Delete collection
+- ✅ List collections (with pagination, filters, sorting)
+- ✅ Get user collections (across all repositories)
+- ✅ Error handling
+
+### RagMixin (13 tests)
+- ✅ List documents
+- ✅ Get document by ID
+- ✅ Delete documents (by IDs, by name)
+- ✅ Get presigned URL
+- ✅ Upload document
+- ✅ Ingest document (basic, with custom chunking)
+- ✅ Similarity search (with collection ID, with model name)
+- ✅ Error handling
+
+### ConfigMixin (4 tests)
+- ✅ Get configs (global, custom scope)
+- ✅ Empty configs
+- ✅ Error handling
+
+### SessionMixin (5 tests)
+- ✅ List sessions
+- ✅ Get session by user
+- ✅ Empty sessions
+- ✅ Error handling
+
+### DocsMixin (2 tests)
+- ✅ Get API documentation
+- ✅ Error handling
+
+**Total: 57 tests, all passing**
+
+## Fixtures
+
+### Core Fixtures (conftest.py)
+
+- `api_url` - Base API URL for testing
+- `api_headers` - API headers with authentication
+- `lisa_api` - Configured LisaApi instance
+- `mock_responses` - Activated responses mock
+
+### Mock Response Fixtures
+
+- `mock_models_response` - Sample models data
+- `mock_repositories_response` - Sample repositories data
+- `mock_collections_response` - Sample collections data
+- `mock_documents_response` - Sample documents data
+- `mock_sessions_response` - Sample sessions data
+- `mock_configs_response` - Sample configs data
+- `mock_instances_response` - Sample instance types data
+
+## Adding New Tests
+
+When adding new tests:
+
+1. **Choose the appropriate test file** based on the mixin being tested
+2. **Use the `@responses.activate` decorator** to enable HTTP mocking
+3. **Mock the HTTP response** using `responses.add()`
+4. **Call the SDK method** being tested
+5. **Assert the results** and verify request parameters
+6. **Add error handling tests** for failure scenarios
+
+### Example: Adding a New Test
+
+```python
+@responses.activate
+def test_new_operation(self, lisa_api: LisaApi, api_url: str):
+ """Test description."""
+ # Arrange: Mock the HTTP response
+ expected_response = {"result": "success"}
+ responses.add(
+ responses.POST,
+ f"{api_url}/new-endpoint",
+ json=expected_response,
+ status=201
+ )
+
+ # Act: Call the SDK method
+ result = lisa_api.new_operation(param="value")
+
+ # Assert: Verify the results
+ assert result["result"] == "success"
+
+ # Verify request was made correctly
+ assert len(responses.calls) == 1
+ assert responses.calls[0].request.body is not None
+```
+
+## Benefits of This Approach
+
+### Fast Execution
+- All 57 tests run in ~0.1 seconds
+- No network latency
+- No AWS resource dependencies
+
+### Fully Isolated
+- No external dependencies
+- No deployed LISA environment required
+- No AWS credentials needed
+- Can run in CI/CD without infrastructure
+
+### Comprehensive Coverage
+- Tests all SDK methods
+- Tests error handling
+- Tests request formatting
+- Tests response parsing
+- Tests query parameters and request bodies
+
+### Easy to Maintain
+- Clear test structure
+- Reusable fixtures
+- Simple mock responses
+- Easy to add new tests
+
+## Differences from Integration Tests
+
+| Aspect | Unit Tests | Integration Tests |
+|--------|-----------|-------------------|
+| Location | `test/sdk/` | `test/integration/sdk/` |
+| Dependencies | None (mocked) | Deployed LISA environment |
+| Speed | Fast (~0.1s) | Slow (network calls) |
+| Isolation | Fully isolated | Requires AWS resources |
+| Purpose | Test SDK logic | Test end-to-end functionality |
+| HTTP Calls | Mocked with `responses` | Real HTTP calls |
+| Authentication | Mocked headers | Real AWS credentials |
+
+## Continuous Integration
+
+These tests are ideal for CI/CD pipelines because they:
+- Run quickly
+- Require no infrastructure
+- Have no external dependencies
+- Provide comprehensive coverage
+- Catch regressions early
+
+## Troubleshooting
+
+### Import Errors
+
+If you see import errors for `responses`:
+```bash
+pip install responses
+```
+
+### Fixture Not Found
+
+If you see fixture errors, ensure you're running from the LISA root directory:
+```bash
+cd /path/to/LISA
+pytest test/sdk -v
+```
+
+### Test Failures
+
+If tests fail:
+1. Check that you're using the latest SDK code
+2. Verify the mock responses match the expected API format
+3. Check for changes in the SDK that require test updates
+4. Run with `-vv` for more detailed output
+
+## Future Enhancements
+
+Potential improvements to the test suite:
+
+- [ ] Add JSON schema validation for responses
+- [ ] Add tests for async operations
+- [ ] Add tests for retry logic
+- [ ] Add tests for timeout handling
+- [ ] Add performance benchmarks
+- [ ] Add tests for edge cases (empty responses, malformed data)
+- [ ] Add tests for authentication flows
diff --git a/test/sdk/__init__.py b/test/sdk/__init__.py
new file mode 100644
index 000000000..4139ae4d0
--- /dev/null
+++ b/test/sdk/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/test/sdk/conftest.py b/test/sdk/conftest.py
new file mode 100644
index 000000000..4bf785511
--- /dev/null
+++ b/test/sdk/conftest.py
@@ -0,0 +1,208 @@
+# 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.
+
+"""Fixtures for lisa-sdk unit tests."""
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+@pytest.fixture
+def api_url() -> str:
+ """Base API URL for testing."""
+ return "https://api.example.com/v1"
+
+
+@pytest.fixture
+def api_headers() -> dict[str, str]:
+ """API headers for testing."""
+ return {"Authorization": "Bearer test-token", "Content-Type": "application/json"}
+
+
+@pytest.fixture
+def lisa_api(api_url: str, api_headers: dict[str, str]) -> LisaApi:
+ """Create a LisaApi instance for testing."""
+ return LisaApi(url=api_url, headers=api_headers, verify=False)
+
+
+@pytest.fixture
+def mock_responses():
+ """Activate responses mock for HTTP requests."""
+ with responses.RequestsMock() as rsps:
+ yield rsps
+
+
+def load_fixture(filename: str) -> Any:
+ """Load a JSON fixture file.
+
+ Args:
+ filename: Name of the fixture file (without .json extension)
+
+ Returns:
+ Parsed JSON data
+ """
+ fixture_path = Path(__file__).parent / "fixtures" / f"{filename}.json"
+ with open(fixture_path) as f:
+ return json.load(f)
+
+
+@pytest.fixture
+def mock_models_response() -> dict:
+ """Mock response for list_models endpoint."""
+ return {
+ "models": [
+ {
+ "modelId": "anthropic.claude-v2",
+ "modelName": "Claude v2",
+ "modelType": "textgen",
+ "streaming": True,
+ "provider": "bedrock",
+ },
+ {
+ "modelId": "amazon.titan-embed-text-v1",
+ "modelName": "Titan Embeddings",
+ "modelType": "embedding",
+ "streaming": False,
+ "provider": "bedrock",
+ },
+ {
+ "modelId": "custom-llama-2",
+ "modelName": "Llama 2 7B",
+ "modelType": "textgen",
+ "streaming": True,
+ "provider": "ecs.textgen.tgi",
+ },
+ ]
+ }
+
+
+@pytest.fixture
+def mock_repositories_response() -> list:
+ """Mock response for list_repositories endpoint."""
+ return [
+ {
+ "repositoryId": "pgvector-rag",
+ "repositoryName": "PGVector RAG",
+ "type": "pgvector",
+ "embeddingModelId": "amazon.titan-embed-text-v1",
+ "status": "ACTIVE",
+ "createdAt": "2024-01-15T10:30:00Z",
+ },
+ {
+ "repositoryId": "opensearch-rag",
+ "repositoryName": "OpenSearch RAG",
+ "type": "opensearch",
+ "embeddingModelId": "amazon.titan-embed-text-v1",
+ "status": "ACTIVE",
+ "createdAt": "2024-01-16T14:20:00Z",
+ },
+ ]
+
+
+@pytest.fixture
+def mock_collections_response() -> dict:
+ """Mock response for list_collections endpoint."""
+ return {
+ "collections": [
+ {
+ "collectionId": "col-123",
+ "collectionName": "Test Collection",
+ "repositoryId": "pgvector-rag",
+ "embeddingModel": "amazon.titan-embed-text-v1",
+ "status": "ACTIVE",
+ "createdAt": "2024-01-20T09:00:00Z",
+ },
+ {
+ "collectionId": "col-456",
+ "collectionName": "Another Collection",
+ "repositoryId": "pgvector-rag",
+ "embeddingModel": "amazon.titan-embed-text-v1",
+ "status": "ACTIVE",
+ "createdAt": "2024-01-21T11:30:00Z",
+ },
+ ],
+ "pagination": {"page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1},
+ }
+
+
+@pytest.fixture
+def mock_documents_response() -> dict:
+ """Mock response for list_documents endpoint."""
+ return {
+ "documents": [
+ {
+ "document_id": "doc-123",
+ "document_name": "test-document.pdf",
+ "collection_id": "col-123",
+ "source": "s3://bucket/test-document.pdf",
+ "status": "READY",
+ "createdAt": "2024-01-22T10:00:00Z",
+ },
+ {
+ "document_id": "doc-456",
+ "document_name": "another-doc.txt",
+ "collection_id": "col-123",
+ "source": "s3://bucket/another-doc.txt",
+ "status": "READY",
+ "createdAt": "2024-01-22T11:00:00Z",
+ },
+ ],
+ "lastEvaluated": None,
+ }
+
+
+@pytest.fixture
+def mock_sessions_response() -> list:
+ """Mock response for list_sessions endpoint."""
+ return [
+ {
+ "sessionId": "sess-123",
+ "userId": "user-1",
+ "createdAt": "2024-01-23T08:00:00Z",
+ "lastAccessedAt": "2024-01-23T09:30:00Z",
+ },
+ {
+ "sessionId": "sess-456",
+ "userId": "user-1",
+ "createdAt": "2024-01-22T14:00:00Z",
+ "lastAccessedAt": "2024-01-23T08:00:00Z",
+ },
+ ]
+
+
+@pytest.fixture
+def mock_configs_response() -> list:
+ """Mock response for get_configs endpoint."""
+ return [
+ {"configScope": "global", "parameter": "maxTokens", "value": "4096"},
+ {"configScope": "global", "parameter": "temperature", "value": "0.7"},
+ {"configScope": "global", "parameter": "systemPrompt", "value": "You are a helpful assistant."},
+ ]
+
+
+@pytest.fixture
+def mock_instances_response() -> list:
+ """Mock response for list_instances endpoint."""
+ return [
+ "ml.g5.xlarge",
+ "ml.g5.2xlarge",
+ "ml.g5.4xlarge",
+ "ml.p4d.24xlarge",
+ "ml.inf2.xlarge",
+ ]
diff --git a/test/sdk/test_authentication.py b/test/sdk/test_authentication.py
new file mode 100644
index 000000000..91f826004
--- /dev/null
+++ b/test/sdk/test_authentication.py
@@ -0,0 +1,103 @@
+# 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.
+
+"""Unit tests for LISA SDK authentication."""
+
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# Add SDK to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lisa-sdk"))
+
+from lisapy.authentication import get_cognito_token
+
+
+@patch("lisapy.authentication.boto3.client")
+@patch("lisapy.authentication.getpass.getpass")
+def test_get_cognito_token_success(mock_getpass, mock_boto_client):
+ """Test getting Cognito token successfully."""
+ mock_getpass.return_value = "test-password"
+
+ mock_cognito = MagicMock()
+ mock_cognito.initiate_auth.return_value = {
+ "AuthenticationResult": {
+ "AccessToken": "access-token",
+ "IdToken": "id-token",
+ "RefreshToken": "refresh-token",
+ }
+ }
+ mock_boto_client.return_value = mock_cognito
+
+ result = get_cognito_token("test-client-id", "test-user", "us-east-1")
+
+ assert "AuthenticationResult" in result
+ assert result["AuthenticationResult"]["AccessToken"] == "access-token"
+
+ mock_cognito.initiate_auth.assert_called_once_with(
+ AuthFlow="USER_PASSWORD_AUTH",
+ ClientId="test-client-id",
+ AuthParameters={
+ "USERNAME": "test-user",
+ "PASSWORD": "test-password",
+ },
+ )
+
+
+@patch("lisapy.authentication.boto3.client")
+@patch("lisapy.authentication.getpass.getpass")
+def test_get_cognito_token_default_region(mock_getpass, mock_boto_client):
+ """Test getting Cognito token with default region."""
+ mock_getpass.return_value = "test-password"
+
+ mock_cognito = MagicMock()
+ mock_cognito.initiate_auth.return_value = {"AuthenticationResult": {}}
+ mock_boto_client.return_value = mock_cognito
+
+ get_cognito_token("test-client-id", "test-user")
+
+ mock_boto_client.assert_called_once_with("cognito-idp", region_name="us-east-1")
+
+
+@patch("lisapy.authentication.boto3.client")
+@patch("lisapy.authentication.getpass.getpass")
+def test_get_cognito_token_custom_region(mock_getpass, mock_boto_client):
+ """Test getting Cognito token with custom region."""
+ mock_getpass.return_value = "test-password"
+
+ mock_cognito = MagicMock()
+ mock_cognito.initiate_auth.return_value = {"AuthenticationResult": {}}
+ mock_boto_client.return_value = mock_cognito
+
+ get_cognito_token("test-client-id", "test-user", "eu-west-1")
+
+ mock_boto_client.assert_called_once_with("cognito-idp", region_name="eu-west-1")
+
+
+@patch("lisapy.authentication.boto3.client")
+@patch("lisapy.authentication.getpass.getpass")
+def test_get_cognito_token_auth_failure(mock_getpass, mock_boto_client):
+ """Test getting Cognito token with authentication failure."""
+ mock_getpass.return_value = "wrong-password"
+
+ mock_cognito = MagicMock()
+ mock_cognito.initiate_auth.side_effect = Exception("NotAuthorizedException")
+ mock_boto_client.return_value = mock_cognito
+
+ with pytest.raises(Exception) as exc_info:
+ get_cognito_token("test-client-id", "test-user")
+
+ assert "NotAuthorizedException" in str(exc_info.value)
diff --git a/test/sdk/test_collection.py b/test/sdk/test_collection.py
new file mode 100644
index 000000000..36d7c99ab
--- /dev/null
+++ b/test/sdk/test_collection.py
@@ -0,0 +1,302 @@
+# 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.
+
+"""Unit tests for CollectionMixin."""
+
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestCollectionMixin:
+ """Test suite for collection-related operations."""
+
+ @responses.activate
+ def test_create_collection(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a collection."""
+ repo_id = "pgvector-rag"
+ expected_response = {
+ "collectionId": "col-new",
+ "collectionName": "New Collection",
+ "repositoryId": repo_id,
+ "embeddingModel": "amazon.titan-embed-text-v1",
+ "status": "ACTIVE",
+ "createdAt": "2024-01-24T10:00:00Z",
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository/{repo_id}/collection", json=expected_response, status=201)
+
+ result = lisa_api.create_collection(
+ repository_id=repo_id,
+ name="New Collection",
+ description="Test collection",
+ embedding_model="amazon.titan-embed-text-v1",
+ )
+
+ assert result["collectionId"] == "col-new"
+ assert result["collectionName"] == "New Collection"
+
+ @responses.activate
+ def test_create_collection_with_chunking_strategy(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a collection with custom chunking strategy."""
+ repo_id = "pgvector-rag"
+ chunking_strategy = {"type": "fixed", "size": 1024, "overlap": 102}
+
+ expected_response = {
+ "collectionId": "col-chunked",
+ "collectionName": "Chunked Collection",
+ "repositoryId": repo_id,
+ "chunkingStrategy": chunking_strategy,
+ "status": "ACTIVE",
+ "createdAt": "2024-01-24T11:00:00Z",
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository/{repo_id}/collection", json=expected_response, status=201)
+
+ result = lisa_api.create_collection(
+ repository_id=repo_id, name="Chunked Collection", chunking_strategy=chunking_strategy
+ )
+
+ assert result["collectionId"] == "col-chunked"
+ assert result["chunkingStrategy"]["size"] == 1024
+
+ @responses.activate
+ def test_create_collection_with_metadata(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a collection with metadata."""
+ repo_id = "pgvector-rag"
+ metadata = {"tags": ["test", "demo"], "customFields": {"owner": "team-a"}}
+
+ expected_response = {
+ "collectionId": "col-meta",
+ "collectionName": "Collection with Metadata",
+ "repositoryId": repo_id,
+ "metadata": metadata,
+ "status": "ACTIVE",
+ "createdAt": "2024-01-24T12:00:00Z",
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository/{repo_id}/collection", json=expected_response, status=201)
+
+ result = lisa_api.create_collection(
+ repository_id=repo_id, name="Collection with Metadata", metadata=metadata, allowed_groups=["admin"]
+ )
+
+ assert result["collectionId"] == "col-meta"
+ assert result["metadata"]["tags"] == ["test", "demo"]
+
+ @responses.activate
+ def test_get_collection(self, lisa_api: LisaApi, api_url: str):
+ """Test getting a collection by ID."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+
+ expected_response = {
+ "collectionId": collection_id,
+ "collectionName": "Test Collection",
+ "repositoryId": repo_id,
+ "status": "ACTIVE",
+ }
+
+ responses.add(
+ responses.GET,
+ f"{api_url}/repository/{repo_id}/collection/{collection_id}",
+ json=expected_response,
+ status=200,
+ )
+
+ result = lisa_api.get_collection(repo_id, collection_id)
+
+ assert result["collectionId"] == collection_id
+ assert result["collectionName"] == "Test Collection"
+
+ @responses.activate
+ def test_update_collection(self, lisa_api: LisaApi, api_url: str):
+ """Test updating a collection."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+
+ expected_response = {
+ "collectionId": collection_id,
+ "collectionName": "Updated Collection",
+ "description": "Updated description",
+ "repositoryId": repo_id,
+ "status": "ACTIVE",
+ "updatedAt": "2024-01-24T13:00:00Z",
+ }
+
+ responses.add(
+ responses.PUT,
+ f"{api_url}/repository/{repo_id}/collection/{collection_id}",
+ json=expected_response,
+ status=200,
+ )
+
+ result = lisa_api.update_collection(
+ repository_id=repo_id,
+ collection_id=collection_id,
+ name="Updated Collection",
+ description="Updated description",
+ )
+
+ assert result["collectionName"] == "Updated Collection"
+ assert result["description"] == "Updated description"
+
+ @responses.activate
+ def test_update_collection_status(self, lisa_api: LisaApi, api_url: str):
+ """Test updating collection status."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+
+ expected_response = {
+ "collectionId": collection_id,
+ "collectionName": "Test Collection",
+ "repositoryId": repo_id,
+ "status": "ARCHIVED",
+ "updatedAt": "2024-01-24T14:00:00Z",
+ }
+
+ responses.add(
+ responses.PUT,
+ f"{api_url}/repository/{repo_id}/collection/{collection_id}",
+ json=expected_response,
+ status=200,
+ )
+
+ result = lisa_api.update_collection(repository_id=repo_id, collection_id=collection_id, status="ARCHIVED")
+
+ assert result["status"] == "ARCHIVED"
+
+ @responses.activate
+ def test_delete_collection(self, lisa_api: LisaApi, api_url: str):
+ """Test deleting a collection."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-old"
+
+ responses.add(responses.DELETE, f"{api_url}/repository/{repo_id}/collection/{collection_id}", status=204)
+
+ result = lisa_api.delete_collection(repo_id, collection_id)
+
+ assert result is True
+ assert len(responses.calls) == 1
+
+ @responses.activate
+ def test_list_collections(self, lisa_api: LisaApi, api_url: str, mock_collections_response: dict):
+ """Test listing collections in a repository."""
+ repo_id = "pgvector-rag"
+
+ responses.add(
+ responses.GET, f"{api_url}/repository/{repo_id}/collections", json=mock_collections_response, status=200
+ )
+
+ result = lisa_api.list_collections(repo_id)
+
+ assert "collections" in result
+ assert len(result["collections"]) == 2
+ assert result["collections"][0]["collectionId"] == "col-123"
+
+ @responses.activate
+ def test_list_collections_with_pagination(self, lisa_api: LisaApi, api_url: str):
+ """Test listing collections with pagination."""
+ repo_id = "pgvector-rag"
+ page_response = {
+ "collections": [{"collectionId": "col-1", "collectionName": "Collection 1"}],
+ "pagination": {"page": 2, "pageSize": 10, "totalItems": 25, "totalPages": 3},
+ }
+
+ responses.add(responses.GET, f"{api_url}/repository/{repo_id}/collections", json=page_response, status=200)
+
+ result = lisa_api.list_collections(repo_id, page=2, page_size=10)
+
+ assert result["pagination"]["page"] == 2
+ assert result["pagination"]["totalPages"] == 3
+
+ @responses.activate
+ def test_list_collections_with_filters(self, lisa_api: LisaApi, api_url: str):
+ """Test listing collections with filters."""
+ repo_id = "pgvector-rag"
+ filtered_response = {
+ "collections": [{"collectionId": "col-test", "collectionName": "Test Collection"}],
+ "pagination": {"page": 1, "pageSize": 20, "totalItems": 1, "totalPages": 1},
+ }
+
+ responses.add(responses.GET, f"{api_url}/repository/{repo_id}/collections", json=filtered_response, status=200)
+
+ result = lisa_api.list_collections(
+ repo_id, filter_text="test", status_filter="active", sort_by="name", sort_order="asc"
+ )
+
+ assert len(result["collections"]) == 1
+ assert "test" in result["collections"][0]["collectionName"].lower()
+
+ @responses.activate
+ def test_get_user_collections(self, lisa_api: LisaApi, api_url: str):
+ """Test getting all collections user has access to."""
+ user_collections_response = {
+ "collections": [
+ {"collectionId": "col-1", "repositoryId": "repo-1", "collectionName": "Collection 1"},
+ {"collectionId": "col-2", "repositoryId": "repo-2", "collectionName": "Collection 2"},
+ ]
+ }
+
+ responses.add(responses.GET, f"{api_url}/repository/collections", json=user_collections_response, status=200)
+
+ result = lisa_api.get_user_collections()
+
+ assert len(result) == 2
+ assert result[0]["collectionId"] == "col-1"
+ assert result[1]["repositoryId"] == "repo-2"
+
+ @responses.activate
+ def test_get_user_collections_with_filter(self, lisa_api: LisaApi, api_url: str):
+ """Test getting user collections with filter."""
+ filtered_response = {"collections": [{"collectionId": "col-test", "collectionName": "Test Collection"}]}
+
+ responses.add(responses.GET, f"{api_url}/repository/collections", json=filtered_response, status=200)
+
+ result = lisa_api.get_user_collections(filter_text="test", page_size=50)
+
+ assert len(result) == 1
+ assert "test" in result[0]["collectionName"].lower()
+
+ @responses.activate
+ def test_create_collection_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when creating a collection fails."""
+ repo_id = "pgvector-rag"
+
+ responses.add(
+ responses.POST,
+ f"{api_url}/repository/{repo_id}/collection",
+ json={"error": "Invalid name"},
+ status=400,
+ )
+
+ with pytest.raises(Exception):
+ lisa_api.create_collection(repository_id=repo_id, name="")
+
+ @responses.activate
+ def test_get_collection_not_found(self, lisa_api: LisaApi, api_url: str):
+ """Test getting a collection that doesn't exist."""
+ repo_id = "pgvector-rag"
+ collection_id = "non-existent"
+
+ responses.add(
+ responses.GET,
+ f"{api_url}/repository/{repo_id}/collection/{collection_id}",
+ json={"error": "Not found"},
+ status=404,
+ )
+
+ with pytest.raises(Exception):
+ lisa_api.get_collection(repo_id, collection_id)
diff --git a/test/sdk/test_config.py b/test/sdk/test_config.py
new file mode 100644
index 000000000..ae237653d
--- /dev/null
+++ b/test/sdk/test_config.py
@@ -0,0 +1,69 @@
+# 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.
+
+"""Unit tests for ConfigMixin."""
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestConfigMixin:
+ """Test suite for config-related operations."""
+
+ @responses.activate
+ def test_get_configs_global(self, lisa_api: LisaApi, api_url: str, mock_configs_response: list):
+ """Test getting global configurations."""
+ responses.add(responses.GET, f"{api_url}/configuration", json=mock_configs_response, status=200)
+
+ configs = lisa_api.get_configs()
+
+ assert len(configs) == 3
+ assert configs[0]["parameter"] == "maxTokens"
+ assert configs[1]["value"] == "0.7"
+ # Verify default scope is global
+ assert responses.calls[0].request.params["configScope"] == "global"
+
+ @responses.activate
+ def test_get_configs_custom_scope(self, lisa_api: LisaApi, api_url: str):
+ """Test getting configurations with custom scope."""
+ user_configs = [
+ {"configScope": "user", "parameter": "theme", "value": "dark"},
+ {"configScope": "user", "parameter": "language", "value": "en"},
+ ]
+
+ responses.add(responses.GET, f"{api_url}/configuration", json=user_configs, status=200)
+
+ configs = lisa_api.get_configs(config_scope="user")
+
+ assert len(configs) == 2
+ assert configs[0]["configScope"] == "user"
+ assert responses.calls[0].request.params["configScope"] == "user"
+
+ @responses.activate
+ def test_get_configs_empty(self, lisa_api: LisaApi, api_url: str):
+ """Test getting configurations when none exist."""
+ responses.add(responses.GET, f"{api_url}/configuration", json=[], status=200)
+
+ configs = lisa_api.get_configs()
+
+ assert len(configs) == 0
+
+ @responses.activate
+ def test_get_configs_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when getting configs fails."""
+ responses.add(responses.GET, f"{api_url}/configuration", json={"error": "Unauthorized"}, status=401)
+
+ with pytest.raises(Exception):
+ lisa_api.get_configs()
diff --git a/test/sdk/test_docs.py b/test/sdk/test_docs.py
new file mode 100644
index 000000000..c429369e2
--- /dev/null
+++ b/test/sdk/test_docs.py
@@ -0,0 +1,53 @@
+# 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.
+
+"""Unit tests for DocsMixin."""
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestDocsMixin:
+ """Test suite for docs-related operations."""
+
+ @responses.activate
+ def test_list_docs(self, lisa_api: LisaApi, api_url: str):
+ """Test getting API documentation."""
+ swagger_html = """
+
+
+ LISA API Documentation
+
+ LISA REST API
+ API documentation for LISA
+
+
+ """
+
+ responses.add(responses.GET, f"{api_url}/docs", body=swagger_html, status=200, content_type="text/html")
+
+ docs = lisa_api.list_docs()
+
+ assert "LISA API Documentation" in docs
+ assert "LISA REST API" in docs
+ assert len(docs) > 0
+
+ @responses.activate
+ def test_list_docs_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when getting docs fails."""
+ responses.add(responses.GET, f"{api_url}/docs", body="Not Found", status=404)
+
+ with pytest.raises(Exception):
+ lisa_api.list_docs()
diff --git a/test/sdk/test_langchain.py b/test/sdk/test_langchain.py
new file mode 100644
index 000000000..56420eb1a
--- /dev/null
+++ b/test/sdk/test_langchain.py
@@ -0,0 +1,60 @@
+# 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.
+
+"""Unit tests for LISA SDK langchain adapters.
+
+Note: These are simplified tests. Full integration tests with real LisaLlm
+instances are better suited for integration testing due to Pydantic validation complexity.
+"""
+
+import sys
+from pathlib import Path
+
+# Add SDK to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lisa-sdk"))
+
+
+def test_langchain_imports():
+ """Test that langchain module imports successfully."""
+ from lisapy.langchain import LisaEmbeddings, LisaOpenAIEmbeddings, LisaTextgen
+
+ assert LisaTextgen is not None
+ assert LisaOpenAIEmbeddings is not None
+ assert LisaEmbeddings is not None
+
+
+def test_lisa_textgen_llm_type():
+ """Test LisaTextgen has correct LLM type attribute."""
+ from lisapy.langchain import LisaTextgen
+
+ # Check class has the _llm_type method
+ assert hasattr(LisaTextgen, "_llm_type")
+
+
+def test_lisa_embeddings_has_embed_methods():
+ """Test LisaEmbeddings has required embedding methods."""
+ from lisapy.langchain import LisaEmbeddings
+
+ assert hasattr(LisaEmbeddings, "embed_documents")
+ assert hasattr(LisaEmbeddings, "embed_query")
+
+
+def test_lisa_openai_embeddings_has_embed_methods():
+ """Test LisaOpenAIEmbeddings has required embedding methods."""
+ from lisapy.langchain import LisaOpenAIEmbeddings
+
+ assert hasattr(LisaOpenAIEmbeddings, "embed_documents")
+ assert hasattr(LisaOpenAIEmbeddings, "embed_query")
+ assert hasattr(LisaOpenAIEmbeddings, "aembed_documents")
+ assert hasattr(LisaOpenAIEmbeddings, "aembed_query")
diff --git a/test/sdk/test_main.py b/test/sdk/test_main.py
new file mode 100644
index 000000000..0e0852dae
--- /dev/null
+++ b/test/sdk/test_main.py
@@ -0,0 +1,348 @@
+# 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.
+
+"""Unit tests for LISA SDK main module."""
+
+import sys
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import pytest
+
+# Add SDK to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lisa-sdk"))
+
+
+class TestLisaLlmInitialization:
+ """Test suite for LisaLlm initialization."""
+
+ def test_lisa_llm_basic_init(self):
+ """Test basic LisaLlm initialization."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com")
+
+ assert llm.url == "https://api.example.com/v2"
+ assert llm.timeout == 10
+ assert llm._session is not None
+
+ def test_lisa_llm_url_normalization(self):
+ """Test URL normalization adds v2 if missing."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com/")
+
+ assert llm.url == "https://api.example.com/v2"
+
+ def test_lisa_llm_url_preserves_v2(self):
+ """Test URL normalization preserves existing v2."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com/v2")
+
+ assert llm.url == "https://api.example.com/v2"
+
+ def test_lisa_llm_with_headers(self):
+ """Test LisaLlm initialization with custom headers."""
+ from lisapy.main import LisaLlm
+
+ headers = {"Authorization": "Bearer token123"}
+ llm = LisaLlm(url="https://api.example.com", headers=headers)
+
+ assert llm.headers == headers
+ assert "Authorization" in llm._session.headers
+
+ def test_lisa_llm_with_cookies(self):
+ """Test LisaLlm initialization with cookies."""
+ from lisapy.main import LisaLlm
+
+ cookies = {"session": "abc123"}
+ llm = LisaLlm(url="https://api.example.com", cookies=cookies)
+
+ assert llm.cookies == cookies
+
+ def test_lisa_llm_with_verify(self):
+ """Test LisaLlm initialization with SSL verification."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com", verify=False)
+
+ assert llm.verify is False
+ assert llm._session.verify is False
+
+ def test_lisa_llm_with_timeout(self):
+ """Test LisaLlm initialization with custom timeout."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com", timeout=30)
+
+ assert llm.timeout == 30
+ assert llm.async_timeout.total == 30 * 60
+
+
+class TestLisaLlmListModels:
+ """Test suite for list_models method."""
+
+ def test_list_models_success(self):
+ """Test successful model listing."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com")
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "data": [
+ {"id": "model1", "object": "model"},
+ {"id": "model2", "object": "model"},
+ ]
+ }
+
+ with patch.object(llm._session, "get", return_value=mock_response):
+ models = llm.list_models()
+
+ assert len(models) == 2
+ assert models[0]["id"] == "model1"
+ assert models[1]["id"] == "model2"
+
+ def test_list_models_error(self):
+ """Test model listing with error response."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com")
+
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Internal Server Error"
+
+ with patch.object(llm._session, "get", return_value=mock_response):
+ with pytest.raises(Exception):
+ llm.list_models()
+
+
+class TestLisaLlmGenerate:
+ """Test suite for generate method."""
+
+ def test_generate_success(self):
+ """Test successful text generation."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="test-model", model_type=ModelType.TEXTGEN, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "generatedText": "Generated response",
+ "generatedTokens": 10,
+ "finishReason": "stop",
+ }
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ response = llm.generate("test prompt", model)
+
+ assert response.generated_text == "Generated response"
+ assert response.generated_tokens == 10
+ assert response.finish_reason == "stop"
+
+ def test_generate_with_model_kwargs(self):
+ """Test generation with model kwargs."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelKwargs, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ kwargs = ModelKwargs(temperature=0.7, max_new_tokens=100)
+ model = FoundationModel(
+ provider="test-provider", model_name="test-model", model_type=ModelType.TEXTGEN, model_kwargs=kwargs
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {
+ "generatedText": "Response",
+ "generatedTokens": 5,
+ "finishReason": "stop",
+ }
+
+ with patch.object(llm._session, "post", return_value=mock_response) as mock_post:
+ llm.generate("prompt", model)
+
+ # Verify model kwargs were included in request
+ call_args = mock_post.call_args
+ payload = call_args[1]["json"]
+ assert "modelKwargs" in payload
+ assert payload["modelKwargs"]["temperature"] == 0.7
+
+ def test_generate_error(self):
+ """Test generation with error response."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="test-model", model_type=ModelType.TEXTGEN, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 400
+ mock_response.text = "Bad Request"
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ with pytest.raises(Exception):
+ llm.generate("prompt", model)
+
+
+class TestLisaLlmEmbed:
+ """Test suite for embed method."""
+
+ def test_embed_single_text(self):
+ """Test embedding single text."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="embed-model", model_type=ModelType.EMBEDDING, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"embeddings": [[0.1, 0.2, 0.3]]}
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ embeddings = llm.embed("test text", model)
+
+ assert len(embeddings) == 1
+ assert embeddings[0] == [0.1, 0.2, 0.3]
+
+ def test_embed_multiple_texts(self):
+ """Test embedding multiple texts."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="embed-model", model_type=ModelType.EMBEDDING, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.json.return_value = {"embeddings": [[0.1, 0.2], [0.3, 0.4]]}
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ embeddings = llm.embed(["text1", "text2"], model)
+
+ assert len(embeddings) == 2
+ assert embeddings[0] == [0.1, 0.2]
+ assert embeddings[1] == [0.3, 0.4]
+
+ def test_embed_error(self):
+ """Test embedding with error response."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="embed-model", model_type=ModelType.EMBEDDING, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 500
+ mock_response.text = "Server Error"
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ with pytest.raises(Exception):
+ llm.embed("text", model)
+
+
+class TestLisaLlmGenerateStream:
+ """Test suite for generate_stream method."""
+
+ def test_generate_stream_success(self):
+ """Test successful streaming generation."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="test-model", model_type=ModelType.TEXTGEN, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.iter_lines.return_value = [
+ b'data:{"token":{"text":"Hello"}}',
+ b'data:{"token":{"text":" world"}}',
+ b'data:{"finishReason":"stop","generatedTokens":2}',
+ ]
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ tokens = []
+ for chunk in llm.generate_stream("prompt", model):
+ tokens.append(chunk)
+
+ assert len(tokens) == 3
+ assert tokens[0].token == "Hello"
+ assert tokens[1].token == " world"
+ assert tokens[2].finish_reason == "stop"
+
+ def test_generate_stream_error(self):
+ """Test streaming with error response."""
+ from lisapy.main import LisaLlm
+ from lisapy.types import FoundationModel, ModelType
+
+ llm = LisaLlm(url="https://api.example.com")
+ model = FoundationModel(
+ provider="test-provider", model_name="test-model", model_type=ModelType.TEXTGEN, model_kwargs=None
+ )
+
+ mock_response = Mock()
+ mock_response.status_code = 400
+
+ with patch.object(llm._session, "post", return_value=mock_response):
+ with pytest.raises(Exception):
+ list(llm.generate_stream("prompt", model))
+
+
+class TestLisaLlmCleanup:
+ """Test suite for LisaLlm cleanup."""
+
+ def test_session_cleanup(self):
+ """Test session is closed on deletion."""
+ from lisapy.main import LisaLlm
+
+ llm = LisaLlm(url="https://api.example.com")
+ session = llm._session
+
+ with patch.object(session, "close") as mock_close:
+ del llm
+ mock_close.assert_called_once()
+
+
+class TestOnLlmNewToken:
+ """Test suite for on_llm_new_token callback."""
+
+ def test_on_llm_new_token(self):
+ """Test token callback writes to stdout."""
+ from lisapy.main import on_llm_new_token
+
+ with patch("sys.stdout.write") as mock_write:
+ with patch("sys.stdout.flush") as mock_flush:
+ on_llm_new_token("test_token")
+
+ mock_write.assert_called_once_with("test_token")
+ mock_flush.assert_called_once()
diff --git a/test/sdk/test_model.py b/test/sdk/test_model.py
new file mode 100644
index 000000000..2dc1529a0
--- /dev/null
+++ b/test/sdk/test_model.py
@@ -0,0 +1,153 @@
+# 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.
+
+"""Unit tests for ModelMixin."""
+
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestModelMixin:
+ """Test suite for model-related operations."""
+
+ @responses.activate
+ def test_list_models(self, lisa_api: LisaApi, api_url: str, mock_models_response: dict):
+ """Test listing all models."""
+ responses.add(responses.GET, f"{api_url}/models", json=mock_models_response, status=200)
+
+ models = lisa_api.list_models()
+
+ assert len(models) == 3
+ assert models[0]["modelId"] == "anthropic.claude-v2"
+ assert models[1]["modelType"] == "embedding"
+ assert models[2]["provider"] == "ecs.textgen.tgi"
+
+ @responses.activate
+ def test_list_embedding_models(self, lisa_api: LisaApi, api_url: str, mock_models_response: dict):
+ """Test listing only embedding models."""
+ responses.add(responses.GET, f"{api_url}/models", json=mock_models_response, status=200)
+
+ embeddings = lisa_api.list_embedding_models()
+
+ assert len(embeddings) == 1
+ assert embeddings[0]["modelId"] == "amazon.titan-embed-text-v1"
+ assert embeddings[0]["modelType"] == "embedding"
+
+ @responses.activate
+ def test_list_instances(self, lisa_api: LisaApi, api_url: str, mock_instances_response: list):
+ """Test listing available instance types."""
+ responses.add(responses.GET, f"{api_url}/models/metadata/instances", json=mock_instances_response, status=200)
+
+ instances = lisa_api.list_instances()
+
+ assert len(instances) == 5
+ assert "ml.g5.xlarge" in instances
+ assert "ml.p4d.24xlarge" in instances
+
+ @responses.activate
+ def test_create_bedrock_model(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a Bedrock model."""
+ payload = {
+ "modelId": "anthropic.claude-v3",
+ "modelName": "Claude v3",
+ "modelType": "textgen",
+ "provider": "bedrock",
+ }
+ expected_response = {**payload, "status": "ACTIVE", "createdAt": "2024-01-24T10:00:00Z"}
+
+ responses.add(responses.POST, f"{api_url}/models", json=expected_response, status=201)
+
+ result = lisa_api.create_bedrock_model(payload)
+
+ assert result["modelId"] == "anthropic.claude-v3"
+ assert result["status"] == "ACTIVE"
+ assert len(responses.calls) == 1
+ assert responses.calls[0].request.body is not None
+
+ @responses.activate
+ def test_create_self_hosted_model(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a self-hosted model."""
+ payload = {
+ "modelId": "custom-llama-3",
+ "modelName": "Llama 3 8B",
+ "modelType": "textgen",
+ "provider": "ecs.textgen.tgi",
+ "instanceType": "ml.g5.2xlarge",
+ }
+ expected_response = {**payload, "status": "CREATING", "createdAt": "2024-01-24T11:00:00Z"}
+
+ responses.add(responses.POST, f"{api_url}/models", json=expected_response, status=201)
+
+ result = lisa_api.create_self_hosted_model(payload)
+
+ assert result["modelId"] == "custom-llama-3"
+ assert result["status"] == "CREATING"
+
+ @responses.activate
+ def test_create_self_hosted_embedded_model(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a self-hosted embedding model."""
+ payload = {
+ "modelId": "custom-embeddings",
+ "modelName": "Custom Embeddings",
+ "modelType": "embedding",
+ "provider": "ecs.embedding.instructor",
+ "instanceType": "ml.g5.xlarge",
+ }
+ expected_response = {**payload, "status": "CREATING", "createdAt": "2024-01-24T12:00:00Z"}
+
+ responses.add(responses.POST, f"{api_url}/models", json=expected_response, status=201)
+
+ result = lisa_api.create_self_hosted_embedded_model(payload)
+
+ assert result["modelId"] == "custom-embeddings"
+ assert result["modelType"] == "embedding"
+
+ @responses.activate
+ def test_delete_model(self, lisa_api: LisaApi, api_url: str):
+ """Test deleting a model."""
+ model_id = "custom-llama-2"
+ responses.add(responses.DELETE, f"{api_url}/models/{model_id}", status=204)
+
+ result = lisa_api.delete_model(model_id)
+
+ assert result is True
+ assert len(responses.calls) == 1
+
+ @responses.activate
+ def test_get_model(self, lisa_api: LisaApi, api_url: str, mock_models_response: dict):
+ """Test getting a specific model by ID."""
+ responses.add(responses.GET, f"{api_url}/models", json=mock_models_response, status=200)
+
+ model = lisa_api.get_model("anthropic.claude-v2")
+
+ assert model["modelId"] == "anthropic.claude-v2"
+ assert model["modelName"] == "Claude v2"
+
+ @responses.activate
+ def test_get_model_not_found(self, lisa_api: LisaApi, api_url: str, mock_models_response: dict):
+ """Test getting a model that doesn't exist."""
+ responses.add(responses.GET, f"{api_url}/models", json=mock_models_response, status=200)
+
+ with pytest.raises(Exception, match="Model with ID .* not found"):
+ lisa_api.get_model("non-existent-model")
+
+ @responses.activate
+ def test_list_models_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when listing models fails."""
+ responses.add(responses.GET, f"{api_url}/models", json={"error": "Internal error"}, status=500)
+
+ with pytest.raises(Exception):
+ lisa_api.list_models()
diff --git a/test/sdk/test_rag.py b/test/sdk/test_rag.py
new file mode 100644
index 000000000..563f5f797
--- /dev/null
+++ b/test/sdk/test_rag.py
@@ -0,0 +1,303 @@
+# 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.
+
+"""Unit tests for RagMixin."""
+
+import tempfile
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestRagMixin:
+ """Test suite for RAG-related operations."""
+
+ @responses.activate
+ def test_list_documents(self, lisa_api: LisaApi, api_url: str, mock_documents_response: dict):
+ """Test listing documents in a collection."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+
+ responses.add(
+ responses.GET, f"{api_url}/repository/{repo_id}/document", json=mock_documents_response, status=200
+ )
+
+ documents = lisa_api.list_documents(repo_id, collection_id)
+
+ assert len(documents) == 2
+ assert documents[0]["document_id"] == "doc-123"
+ assert documents[1]["document_name"] == "another-doc.txt"
+ # Verify query params
+ assert responses.calls[0].request.params["collectionId"] == collection_id
+
+ @responses.activate
+ def test_get_document(self, lisa_api: LisaApi, api_url: str):
+ """Test getting a single document by ID."""
+ repo_id = "pgvector-rag"
+ document_id = "doc-123"
+
+ expected_response = {
+ "document_id": document_id,
+ "document_name": "test-document.pdf",
+ "collection_id": "col-123",
+ "source": "s3://bucket/test-document.pdf",
+ "status": "READY",
+ }
+
+ responses.add(
+ responses.GET, f"{api_url}/repository/{repo_id}/{document_id}", json=expected_response, status=200
+ )
+
+ document = lisa_api.get_document(repo_id, document_id)
+
+ assert document["document_id"] == document_id
+ assert document["document_name"] == "test-document.pdf"
+
+ @responses.activate
+ def test_delete_document_by_ids(self, lisa_api: LisaApi, api_url: str):
+ """Test deleting documents by IDs."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+ doc_ids = ["doc-123", "doc-456"]
+
+ expected_response = {"jobs": [{"jobId": "job-1", "status": "PENDING", "documentIds": doc_ids}]}
+
+ responses.add(responses.DELETE, f"{api_url}/repository/{repo_id}/document", json=expected_response, status=200)
+
+ result = lisa_api.delete_document_by_ids(repo_id, collection_id, doc_ids)
+
+ assert "jobs" in result
+ assert result["jobs"][0]["documentIds"] == doc_ids
+ # Verify request body and params
+ assert responses.calls[0].request.params["collectionId"] == collection_id
+
+ @responses.activate
+ def test_delete_documents_by_name(self, lisa_api: LisaApi, api_url: str):
+ """Test deleting documents by name."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+ doc_name = "test-document.pdf"
+
+ expected_response = {
+ "jobs": [{"jobId": "job-2", "status": "PENDING", "documentName": doc_name}],
+ "deletedCount": 1,
+ }
+
+ responses.add(responses.DELETE, f"{api_url}/repository/{repo_id}/document", json=expected_response, status=200)
+
+ result = lisa_api.delete_documents_by_name(repo_id, collection_id, doc_name)
+
+ assert "jobs" in result
+ assert result["deletedCount"] == 1
+ # Verify query params
+ assert responses.calls[0].request.params["collectionId"] == collection_id
+ assert responses.calls[0].request.params["documentName"] == doc_name
+
+ @responses.activate
+ def test_presigned_url(self, lisa_api: LisaApi, api_url: str):
+ """Test getting a presigned URL for document upload."""
+ file_name = "test-document.pdf"
+
+ expected_response = {
+ "response": {
+ "url": "https://s3.amazonaws.com/bucket",
+ "fields": {
+ "key": "uploads/test-document.pdf",
+ "AWSAccessKeyId": "AKIAIOSFODNN7EXAMPLE",
+ "policy": "eyJleHBpcmF0aW9uIjogIjIwMjQtMDEtMjRUMTU6MDA6MDBaIn0=",
+ "signature": "signature-here",
+ },
+ }
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository/presigned-url", json=expected_response, status=200)
+
+ result = lisa_api._presigned_url(file_name)
+
+ assert result["url"] == "https://s3.amazonaws.com/bucket"
+ assert result["key"] == "uploads/test-document.pdf"
+ assert "fields" in result
+
+ @responses.activate
+ def test_upload_document(self, lisa_api: LisaApi):
+ """Test uploading a document using presigned POST."""
+ # Create a temporary file
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
+ f.write("Test content")
+ temp_file = f.name
+
+ presigned_data = {
+ "url": "https://s3.amazonaws.com/bucket",
+ "fields": {
+ "key": "uploads/test.txt",
+ "AWSAccessKeyId": "AKIAIOSFODNN7EXAMPLE",
+ "policy": "policy-here",
+ "signature": "signature-here",
+ },
+ }
+
+ # Mock the S3 upload
+ responses.add(responses.POST, "https://s3.amazonaws.com/bucket", status=204)
+
+ result = lisa_api._upload_document(presigned_data, temp_file)
+
+ assert result is True
+ assert len(responses.calls) == 1
+
+ # Cleanup
+ import os
+
+ os.unlink(temp_file)
+
+ @responses.activate
+ def test_ingest_document(self, lisa_api: LisaApi, api_url: str):
+ """Test ingesting a document."""
+ repo_id = "pgvector-rag"
+ model_id = "amazon.titan-embed-text-v1"
+ file_key = "uploads/test-document.pdf"
+ collection_id = "col-123"
+
+ expected_response = {
+ "jobs": [
+ {
+ "jobId": "job-123",
+ "documentId": "doc-new",
+ "status": "PENDING",
+ "s3Path": f"s3://bucket/{file_key}",
+ }
+ ]
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository/{repo_id}/bulk", json=expected_response, status=200)
+
+ jobs = lisa_api.ingest_document(repo_id=repo_id, model_id=model_id, file=file_key, collection_id=collection_id)
+
+ assert len(jobs) == 1
+ assert jobs[0]["jobId"] == "job-123"
+ assert jobs[0]["status"] == "PENDING"
+
+ @responses.activate
+ def test_ingest_document_with_custom_chunking(self, lisa_api: LisaApi, api_url: str):
+ """Test ingesting a document with custom chunking parameters."""
+ repo_id = "pgvector-rag"
+ model_id = "amazon.titan-embed-text-v1"
+ file_key = "uploads/test-document.pdf"
+
+ expected_response = {"jobs": [{"jobId": "job-124", "documentId": "doc-new-2", "status": "PENDING"}]}
+
+ responses.add(responses.POST, f"{api_url}/repository/{repo_id}/bulk", json=expected_response, status=200)
+
+ jobs = lisa_api.ingest_document(
+ repo_id=repo_id, model_id=model_id, file=file_key, chuck_size=1024, chuck_overlap=102
+ )
+
+ assert len(jobs) == 1
+ # Verify chunking params were sent
+ assert responses.calls[0].request.params["chunkSize"] == "1024"
+ assert responses.calls[0].request.params["chunkOverlap"] == "102"
+
+ @responses.activate
+ def test_similarity_search(self, lisa_api: LisaApi, api_url: str, caplog):
+ """Test performing similarity search."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+ query = "What is machine learning?"
+
+ expected_response = {
+ "docs": [
+ {
+ "Document": {
+ "page_content": "Machine learning is a subset of AI...",
+ "metadata": {"source": "ml-guide.pdf", "page": 1},
+ },
+ "score": 0.95,
+ },
+ {
+ "Document": {
+ "page_content": "Deep learning uses neural networks...",
+ "metadata": {"source": "dl-guide.pdf", "page": 3},
+ },
+ "score": 0.87,
+ },
+ ]
+ }
+
+ responses.add(
+ responses.GET, f"{api_url}/repository/{repo_id}/similaritySearch", json=expected_response, status=200
+ )
+
+ # Suppress logging errors from SDK code
+ import logging
+
+ logging.disable(logging.CRITICAL)
+
+ docs = lisa_api.similarity_search(repo_id=repo_id, query=query, k=5, collection_id=collection_id)
+
+ # Re-enable logging
+ logging.disable(logging.NOTSET)
+
+ assert len(docs) == 2
+ assert docs[0]["score"] == 0.95
+ assert "machine learning" in docs[0]["Document"]["page_content"].lower()
+ # Verify query params
+ assert responses.calls[0].request.params["query"] == query
+ assert responses.calls[0].request.params["topK"] == "5"
+ assert responses.calls[0].request.params["collectionId"] == collection_id
+
+ @responses.activate
+ def test_similarity_search_with_model_name(self, lisa_api: LisaApi, api_url: str):
+ """Test similarity search with explicit model name."""
+ repo_id = "pgvector-rag"
+ query = "What is AI?"
+ model_name = "amazon.titan-embed-text-v1"
+
+ expected_response = {"docs": []}
+
+ responses.add(
+ responses.GET, f"{api_url}/repository/{repo_id}/similaritySearch", json=expected_response, status=200
+ )
+
+ docs = lisa_api.similarity_search(repo_id=repo_id, query=query, model_name=model_name)
+
+ assert len(docs) == 0
+ # Verify model_name was sent
+ assert responses.calls[0].request.params["modelName"] == model_name
+
+ @responses.activate
+ def test_list_documents_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when listing documents fails."""
+ repo_id = "pgvector-rag"
+ collection_id = "col-123"
+
+ responses.add(
+ responses.GET, f"{api_url}/repository/{repo_id}/document", json={"error": "Not found"}, status=404
+ )
+
+ with pytest.raises(Exception):
+ lisa_api.list_documents(repo_id, collection_id)
+
+ @responses.activate
+ def test_ingest_document_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when document ingestion fails."""
+ repo_id = "pgvector-rag"
+ model_id = "amazon.titan-embed-text-v1"
+ file_key = "uploads/invalid.pdf"
+
+ responses.add(
+ responses.POST, f"{api_url}/repository/{repo_id}/bulk", json={"error": "Invalid file"}, status=400
+ )
+
+ with pytest.raises(Exception):
+ lisa_api.ingest_document(repo_id=repo_id, model_id=model_id, file=file_key)
diff --git a/test/sdk/test_repository.py b/test/sdk/test_repository.py
new file mode 100644
index 000000000..a229ed603
--- /dev/null
+++ b/test/sdk/test_repository.py
@@ -0,0 +1,192 @@
+# 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.
+
+"""Unit tests for RepositoryMixin."""
+
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestRepositoryMixin:
+ """Test suite for repository-related operations."""
+
+ @responses.activate
+ def test_list_repositories(self, lisa_api: LisaApi, api_url: str, mock_repositories_response: list):
+ """Test listing all repositories."""
+ responses.add(responses.GET, f"{api_url}/repository", json=mock_repositories_response, status=200)
+
+ repos = lisa_api.list_repositories()
+
+ assert len(repos) == 2
+ assert repos[0]["repositoryId"] == "pgvector-rag"
+ assert repos[1]["type"] == "opensearch"
+
+ @responses.activate
+ def test_create_repository(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a repository."""
+ rag_config = {
+ "repositoryId": "new-repo",
+ "repositoryName": "New Repository",
+ "type": "pgvector",
+ "embeddingModelId": "amazon.titan-embed-text-v1",
+ }
+ expected_response = {**rag_config, "status": "CREATING", "createdAt": "2024-01-24T10:00:00Z"}
+
+ responses.add(responses.POST, f"{api_url}/repository", json=expected_response, status=201)
+
+ result = lisa_api.create_repository(rag_config)
+
+ assert result["repositoryId"] == "new-repo"
+ assert result["status"] == "CREATING"
+
+ @responses.activate
+ def test_create_pgvector_repository(self, lisa_api: LisaApi, api_url: str):
+ """Test creating a PGVector repository."""
+ rag_config = {
+ "repositoryId": "pgvector-test",
+ "repositoryName": "PGVector Test",
+ "type": "pgvector",
+ "embeddingModelId": "amazon.titan-embed-text-v1",
+ }
+ expected_response = {**rag_config, "status": "CREATING", "createdAt": "2024-01-24T11:00:00Z"}
+
+ responses.add(responses.POST, f"{api_url}/repository", json=expected_response, status=201)
+
+ result = lisa_api.create_pgvector_repository(rag_config)
+
+ assert result["repositoryId"] == "pgvector-test"
+ assert result["type"] == "pgvector"
+
+ @responses.activate
+ def test_create_opensearch_repository(self, lisa_api: LisaApi, api_url: str):
+ """Test creating an OpenSearch repository."""
+ expected_response = {
+ "repositoryId": "opensearch-test",
+ "repositoryName": "OpenSearch Test",
+ "type": "opensearch",
+ "embeddingModelId": "amazon.titan-embed-text-v1",
+ "status": "CREATING",
+ "createdAt": "2024-01-24T12:00:00Z",
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository", json=expected_response, status=201)
+
+ result = lisa_api.create_opensearch_repository(
+ repository_id="opensearch-test",
+ repository_name="OpenSearch Test",
+ embedding_model_id="amazon.titan-embed-text-v1",
+ allowed_groups=["admin", "users"],
+ )
+
+ assert result["repositoryId"] == "opensearch-test"
+ assert result["type"] == "opensearch"
+ # Verify the request payload included OpenSearch config
+ assert len(responses.calls) == 1
+
+ @responses.activate
+ def test_create_opensearch_repository_with_custom_config(self, lisa_api: LisaApi, api_url: str):
+ """Test creating an OpenSearch repository with custom configuration."""
+ opensearch_config = {
+ "dataNodes": 3,
+ "dataNodeInstanceType": "r7g.xlarge.search",
+ "masterNodes": 3,
+ "masterNodeInstanceType": "r7g.large.search",
+ "volumeSize": 100,
+ "volumeType": "gp3",
+ "multiAzWithStandby": True,
+ }
+
+ expected_response = {
+ "repositoryId": "opensearch-custom",
+ "repositoryName": "OpenSearch Custom",
+ "type": "opensearch",
+ "embeddingModelId": "amazon.titan-embed-text-v1",
+ "opensearchConfig": opensearch_config,
+ "status": "CREATING",
+ "createdAt": "2024-01-24T13:00:00Z",
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository", json=expected_response, status=201)
+
+ result = lisa_api.create_opensearch_repository(
+ repository_id="opensearch-custom",
+ repository_name="OpenSearch Custom",
+ embedding_model_id="amazon.titan-embed-text-v1",
+ opensearch_config=opensearch_config,
+ )
+
+ assert result["repositoryId"] == "opensearch-custom"
+ assert result["opensearchConfig"]["dataNodes"] == 3
+
+ @responses.activate
+ def test_delete_repository(self, lisa_api: LisaApi, api_url: str):
+ """Test deleting a repository."""
+ repository_id = "old-repo"
+ responses.add(responses.DELETE, f"{api_url}/repository/{repository_id}", status=204)
+
+ result = lisa_api.delete_repository(repository_id)
+
+ assert result is True
+ assert len(responses.calls) == 1
+
+ @responses.activate
+ def test_delete_repository_with_200(self, lisa_api: LisaApi, api_url: str):
+ """Test deleting a repository that returns 200."""
+ repository_id = "old-repo"
+ responses.add(responses.DELETE, f"{api_url}/repository/{repository_id}", json={"deleted": True}, status=200)
+
+ result = lisa_api.delete_repository(repository_id)
+
+ assert result is True
+
+ @responses.activate
+ def test_get_repository_status(self, lisa_api: LisaApi, api_url: str):
+ """Test getting repository status."""
+ status_response = {
+ "repositories": [
+ {"repositoryId": "pgvector-rag", "status": "ACTIVE", "health": "HEALTHY"},
+ {"repositoryId": "opensearch-rag", "status": "ACTIVE", "health": "HEALTHY"},
+ ]
+ }
+
+ responses.add(responses.GET, f"{api_url}/repository/status", json=status_response, status=200)
+
+ result = lisa_api.get_repository_status()
+
+ assert "repositories" in result
+ assert len(result["repositories"]) == 2
+ assert result["repositories"][0]["health"] == "HEALTHY"
+
+ @responses.activate
+ def test_list_repositories_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when listing repositories fails."""
+ responses.add(responses.GET, f"{api_url}/repository", json={"error": "Unauthorized"}, status=401)
+
+ with pytest.raises(Exception):
+ lisa_api.list_repositories()
+
+ @responses.activate
+ def test_create_repository_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when creating a repository fails."""
+ rag_config = {
+ "repositoryId": "invalid-repo",
+ "repositoryName": "Invalid Repository",
+ }
+
+ responses.add(responses.POST, f"{api_url}/repository", json={"error": "Invalid configuration"}, status=400)
+
+ with pytest.raises(Exception):
+ lisa_api.create_repository(rag_config)
diff --git a/test/sdk/test_session.py b/test/sdk/test_session.py
new file mode 100644
index 000000000..71d5a7a3e
--- /dev/null
+++ b/test/sdk/test_session.py
@@ -0,0 +1,77 @@
+# 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.
+
+"""Unit tests for SessionMixin."""
+
+import pytest
+import responses
+from lisapy import LisaApi
+
+
+class TestSessionMixin:
+ """Test suite for session-related operations."""
+
+ @responses.activate
+ def test_list_sessions(self, lisa_api: LisaApi, api_url: str, mock_sessions_response: list):
+ """Test listing all sessions."""
+ responses.add(responses.GET, f"{api_url}/session", json=mock_sessions_response, status=200)
+
+ sessions = lisa_api.list_sessions()
+
+ assert len(sessions) == 2
+ assert sessions[0]["sessionId"] == "sess-123"
+ assert sessions[1]["userId"] == "user-1"
+
+ @responses.activate
+ def test_list_sessions_empty(self, lisa_api: LisaApi, api_url: str):
+ """Test listing sessions when none exist."""
+ responses.add(responses.GET, f"{api_url}/session", json=[], status=200)
+
+ sessions = lisa_api.list_sessions()
+
+ assert len(sessions) == 0
+ assert isinstance(sessions, list)
+
+ @responses.activate
+ def test_get_session_by_user(self, lisa_api: LisaApi, api_url: str):
+ """Test getting session for current user."""
+ user_session = {
+ "sessionId": "sess-current",
+ "userId": "current-user",
+ "createdAt": "2024-01-24T10:00:00Z",
+ "lastAccessedAt": "2024-01-24T14:30:00Z",
+ }
+
+ responses.add(responses.GET, f"{api_url}/session", json=user_session, status=200)
+
+ session = lisa_api.get_session_by_user()
+
+ assert session["sessionId"] == "sess-current"
+ assert session["userId"] == "current-user"
+
+ @responses.activate
+ def test_list_sessions_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when listing sessions fails."""
+ responses.add(responses.GET, f"{api_url}/session", json={"error": "Unauthorized"}, status=401)
+
+ with pytest.raises(Exception):
+ lisa_api.list_sessions()
+
+ @responses.activate
+ def test_get_session_by_user_error(self, lisa_api: LisaApi, api_url: str):
+ """Test error handling when getting user session fails."""
+ responses.add(responses.GET, f"{api_url}/session", json={"error": "Not found"}, status=404)
+
+ with pytest.raises(Exception):
+ lisa_api.get_session_by_user()
diff --git a/test/sdk/test_utils.py b/test/sdk/test_utils.py
new file mode 100644
index 000000000..1fe0cfe1f
--- /dev/null
+++ b/test/sdk/test_utils.py
@@ -0,0 +1,131 @@
+# 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.
+
+"""Unit tests for LISA SDK utils."""
+
+import os
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+# Add SDK to path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent / "lisa-sdk"))
+
+from lisapy.utils import get_cert_path
+
+
+@patch.dict(os.environ, {}, clear=True)
+def test_get_cert_path_no_cert_arn():
+ """Test get_cert_path with no SSL cert ARN."""
+ mock_iam_client = MagicMock()
+
+ result = get_cert_path(mock_iam_client)
+
+ assert result is True
+ mock_iam_client.get_server_certificate.assert_not_called()
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": ""})
+def test_get_cert_path_empty_cert_arn():
+ """Test get_cert_path with empty SSL cert ARN."""
+ mock_iam_client = MagicMock()
+
+ result = get_cert_path(mock_iam_client)
+
+ assert result is True
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:acm:us-east-1:123456789012:certificate/abc123"})
+def test_get_cert_path_acm_cert():
+ """Test get_cert_path with ACM certificate."""
+ mock_iam_client = MagicMock()
+
+ result = get_cert_path(mock_iam_client)
+
+ assert result is True
+ mock_iam_client.get_server_certificate.assert_not_called()
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/my-cert"})
+def test_get_cert_path_iam_cert():
+ """Test get_cert_path with IAM certificate."""
+ mock_iam_client = MagicMock()
+ mock_iam_client.get_server_certificate.return_value = {
+ "ServerCertificate": {
+ "CertificateBody": "-----BEGIN CERTIFICATE-----\ntest-cert-body\n-----END CERTIFICATE-----"
+ }
+ }
+
+ result = get_cert_path(mock_iam_client)
+
+ assert isinstance(result, str)
+ assert os.path.exists(result)
+
+ # Verify cert was written to file
+ with open(result) as f:
+ content = f.read()
+ assert "BEGIN CERTIFICATE" in content
+
+ # Cleanup
+ os.unlink(result)
+
+ mock_iam_client.get_server_certificate.assert_called_once_with(ServerCertificateName="my-cert")
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/test-cert"})
+def test_get_cert_path_cert_content():
+ """Test get_cert_path writes correct certificate content."""
+ mock_iam_client = MagicMock()
+ cert_body = "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----"
+ mock_iam_client.get_server_certificate.return_value = {"ServerCertificate": {"CertificateBody": cert_body}}
+
+ result = get_cert_path(mock_iam_client)
+
+ # Verify file content
+ with open(result) as f:
+ content = f.read()
+ assert content == cert_body
+
+ # Cleanup
+ os.unlink(result)
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/cert-name"})
+def test_get_cert_path_extracts_cert_name():
+ """Test get_cert_path correctly extracts certificate name from ARN."""
+ mock_iam_client = MagicMock()
+ mock_iam_client.get_server_certificate.return_value = {"ServerCertificate": {"CertificateBody": "test-cert"}}
+
+ result = get_cert_path(mock_iam_client)
+
+ # Verify correct cert name was used
+ mock_iam_client.get_server_certificate.assert_called_once_with(ServerCertificateName="cert-name")
+
+ # Cleanup
+ if isinstance(result, str) and os.path.exists(result):
+ os.unlink(result)
+
+
+@patch.dict(os.environ, {"RESTAPI_SSL_CERT_ARN": "arn:aws:iam::123456789012:server-certificate/my-cert"})
+def test_get_cert_path_iam_error():
+ """Test get_cert_path handles IAM errors."""
+ mock_iam_client = MagicMock()
+ mock_iam_client.get_server_certificate.side_effect = Exception("IAM error")
+
+ with pytest.raises(Exception) as exc_info:
+ get_cert_path(mock_iam_client)
+
+ assert "IAM error" in str(exc_info.value)
diff --git a/test/utils/integration_test_utils.py b/test/utils/integration_test_utils.py
index 80a3b0898..dd3776c43 100644
--- a/test/utils/integration_test_utils.py
+++ b/test/utils/integration_test_utils.py
@@ -27,7 +27,7 @@
import os
import sys
import time
-from typing import Callable, Dict, Optional
+from collections.abc import Callable
import boto3
@@ -39,9 +39,7 @@
logger = logging.getLogger(__name__)
-def get_management_key(
- deployment_name: str, region: Optional[str] = None, deployment_stage: Optional[str] = None
-) -> str:
+def get_management_key(deployment_name: str, region: str | None = None, deployment_stage: str | None = None) -> str:
"""Retrieve management key from AWS Secrets Manager.
Args:
@@ -88,7 +86,7 @@ def get_management_key(
raise Exception(f"Could not find management key. Tried: {', '.join(secret_patterns)}")
-def create_api_token(deployment_name: str, api_key: str, region: Optional[str] = None, ttl_seconds: int = 3600) -> str:
+def create_api_token(deployment_name: str, api_key: str, region: str | None = None, ttl_seconds: int = 3600) -> str:
"""Create an API token in DynamoDB with expiration.
Args:
@@ -125,8 +123,8 @@ def create_api_token(deployment_name: str, api_key: str, region: Optional[str] =
def setup_authentication(
- deployment_name: str, region: Optional[str] = None, deployment_stage: Optional[str] = None
-) -> Dict[str, str]:
+ deployment_name: str, region: str | None = None, deployment_stage: str | None = None
+) -> dict[str, str]:
"""Set up authentication for LISA API calls.
Args:
@@ -161,10 +159,10 @@ def setup_authentication(
def create_lisa_client(
api_url: str,
deployment_name: str,
- region: Optional[str] = None,
+ region: str | None = None,
verify_ssl: bool = True,
timeout: int = 10,
- deployment_stage: Optional[str] = None,
+ deployment_stage: str | None = None,
) -> LisaApi:
"""Create and configure a LISA API client.
@@ -235,7 +233,7 @@ def wait_for_resource_ready(
return False
-def get_dynamodb_table(table_name: str, region: Optional[str] = None):
+def get_dynamodb_table(table_name: str, region: str | None = None):
"""Get a DynamoDB table resource.
Args:
@@ -257,7 +255,7 @@ def get_dynamodb_table(table_name: str, region: Optional[str] = None):
raise
-def get_s3_client(region: Optional[str] = None):
+def get_s3_client(region: str | None = None):
"""Get an S3 client.
Args:
@@ -279,8 +277,8 @@ def get_s3_client(region: Optional[str] = None):
def verify_document_in_dynamodb(
document_id: str,
table_name: str,
- expected_collection_id: Optional[str] = None,
- region: Optional[str] = None,
+ expected_collection_id: str | None = None,
+ region: str | None = None,
) -> bool:
"""Verify a document exists in DynamoDB.
@@ -327,7 +325,7 @@ def verify_document_in_dynamodb(
raise
-def verify_document_in_s3(s3_uri: str, region: Optional[str] = None) -> bool:
+def verify_document_in_s3(s3_uri: str, region: str | None = None) -> bool:
"""Verify a document exists in S3.
Args:
@@ -360,7 +358,7 @@ def verify_document_in_s3(s3_uri: str, region: Optional[str] = None) -> bool:
raise
-def verify_document_not_in_s3(s3_uri: str, region: Optional[str] = None) -> bool:
+def verify_document_not_in_s3(s3_uri: str, region: str | None = None) -> bool:
"""Verify a document does NOT exist in S3.
Args:
@@ -393,7 +391,7 @@ def verify_document_not_in_s3(s3_uri: str, region: Optional[str] = None) -> bool
raise
-def get_table_names_from_env(deployment_name: str) -> Dict[str, str]:
+def get_table_names_from_env(deployment_name: str) -> dict[str, str]:
"""Get DynamoDB table names from environment or construct from deployment name.
Args:
diff --git a/vector_store_deployer/src/lib/opensearch.ts b/vector_store_deployer/src/lib/opensearch.ts
index 9fea3bc2d..22882e357 100644
--- a/vector_store_deployer/src/lib/opensearch.ts
+++ b/vector_store_deployer/src/lib/opensearch.ts
@@ -13,7 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
-import { CustomResource, Duration, RemovalPolicy, StackProps } from 'aws-cdk-lib';
+import { CustomResource, Duration, StackProps } from 'aws-cdk-lib';
import { Domain, EngineVersion, IDomain } from 'aws-cdk-lib/aws-opensearchservice';
import { Construct } from 'constructs';
import { RagRepositoryDeploymentConfig, RagRepositoryType,PartialConfig } from '../../../lib/schema';
@@ -207,8 +207,7 @@ def handler(event, context):
encryptionAtRest: {
enabled: true
},
- // todo: validate if this should use the config removal policy
- removalPolicy: RemovalPolicy.DESTROY,
+ removalPolicy: config.removalPolicy,
securityGroups: [openSearchSecurityGroup],
});
diff --git a/vector_store_deployer/src/lib/pgvector.ts b/vector_store_deployer/src/lib/pgvector.ts
index 9cc086368..320acd7a3 100644
--- a/vector_store_deployer/src/lib/pgvector.ts
+++ b/vector_store_deployer/src/lib/pgvector.ts
@@ -13,13 +13,12 @@
See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Duration, RemovalPolicy, StackProps } from 'aws-cdk-lib';
+import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
-import { RagRepositoryDeploymentConfig, RagRepositoryType, PartialConfig, RDSConfig } from '../../../lib/schema';
+import { RagRepositoryDeploymentConfig, RagRepositoryType, PartialConfig } from '../../../lib/schema';
import { createCdkId } from '../../../lib/core/utils';
-import { ISecurityGroup, IVpc, SecurityGroup, Subnet, SubnetSelection, Vpc } from 'aws-cdk-lib/aws-ec2';
-import { Code, Function, IFunction, ILayerVersion, LayerVersion } from 'aws-cdk-lib/aws-lambda';
-import { Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
+import { SecurityGroup, Subnet, SubnetSelection, Vpc } from 'aws-cdk-lib/aws-ec2';
+import { Effect, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { ISecret, Secret } from 'aws-cdk-lib/aws-secretsmanager';
import { Credentials, DatabaseInstance, DatabaseInstanceEngine } from 'aws-cdk-lib/aws-rds';
@@ -27,9 +26,7 @@ import { Roles } from '../../../lib/core/iam/roles';
import { PipelineStack } from './pipeline-stack';
import { SecurityGroupFactory } from '../../../lib/networking/vpc/security-group-factory';
import { SecurityGroupEnum } from '../../../lib/core/iam/SecurityGroups';
-import { getPythonRuntime } from '../../../lib/api-base/utils';
-import { LAMBDA_PATH } from '../../../lib/util';
-import { AwsCustomResource, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
+import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
// Type definition for PGVectorStoreStack properties
type PGVectorStoreStackProps = StackProps & {
@@ -74,7 +71,10 @@ export class PGVectorStoreStack extends PipelineStack {
// Check if PGVector type and RDS configuration are provided in ragConfig
if (type === RagRepositoryType.PGVECTOR && rdsConfig) {
- let rdsPasswordSecret: ISecret;
+ // Determine authentication method - default to IAM auth (iamRdsAuth = false)
+ const useIamAuth = config.iamRdsAuth ?? false;
+
+ let rdsSecret: ISecret;
let rdsConnectionInfo: StringParameter;
// Get Security Group ID for PGVector
@@ -86,154 +86,158 @@ export class PGVectorStoreStack extends PipelineStack {
SecurityGroupFactory.addIngress(pgSecurityGroup, SecurityGroupEnum.PG_VECTOR_SG, vpc, rdsConfig.dbPort, subnetSelection?.subnets);
}
- // if dbHost and passwordSecretId are defined, then connect to DB with existing params
- // Check if existing DB connection details are available
- if (rdsConfig && rdsConfig.passwordSecretId) {
+ // Check if existing DB connection details are available (dbHost and passwordSecretId provided)
+ let pgvectorDb: DatabaseInstance | undefined;
+
+ if (rdsConfig && rdsConfig.dbHost && rdsConfig.passwordSecretId) {
// Use existing DB connection details
rdsConnectionInfo = new StringParameter(this, createCdkId([repositoryId, 'StringParameter']), {
parameterName: `${config.deploymentPrefix}/LisaServeRagConnectionInfo/${repositoryId}`,
stringValue: JSON.stringify({
- ...(config.iamRdsAuth ? {} : rdsConfig),
+ username: rdsConfig.username,
dbHost: rdsConfig.dbHost,
dbName: rdsConfig.dbName,
dbPort: rdsConfig.dbPort,
- type: RagRepositoryType.PGVECTOR }),
+ type: RagRepositoryType.PGVECTOR,
+ // Include passwordSecretId only when using password auth
+ ...(!useIamAuth ? { passwordSecretId: rdsConfig.passwordSecretId } : {})
+ }),
description: 'Connection info for LISA Serve PGVector database',
});
- rdsPasswordSecret = Secret.fromSecretNameV2(
+ rdsSecret = Secret.fromSecretNameV2(
this,
- createCdkId([deploymentName!, repositoryId, 'RagRDSPwdSecret']),
+ createCdkId([deploymentName!, repositoryId, 'RagRDSSecret']),
rdsConfig.passwordSecretId!,
);
} else {
// Create a new RDS instance with generated credentials
const username = rdsConfig.username;
const dbCreds = Credentials.fromGeneratedSecret(username);
- const pgvectorDb = new DatabaseInstance(this, createCdkId([repositoryId, 'PGVectorDB']), {
+ pgvectorDb = new DatabaseInstance(this, createCdkId([repositoryId, 'PGVectorDB']), {
engine: DatabaseInstanceEngine.POSTGRES,
vpc: vpc,
vpcSubnets: subnetSelection,
- // TODO add instance type?
- // TODO: Specify the RDS instance type
credentials: dbCreds,
- iamAuthentication: true,
+ iamAuthentication: useIamAuth, // Enable IAM auth when iamRdsAuth is true
securityGroups: [pgSecurityGroup],
- removalPolicy: RemovalPolicy.DESTROY,
+ removalPolicy: config.removalPolicy,
+ deletionProtection: config.removalPolicy !== RemovalPolicy.DESTROY,
databaseName: rdsConfig.dbName,
port: rdsConfig.dbPort
});
- rdsPasswordSecret = pgvectorDb.secret!;
+ rdsSecret = pgvectorDb.secret!;
- if (config.iamRdsAuth) {
- // grant the role permissions to connect as the IAM role itself
- pgvectorDb.grantConnect(lambdaRole, lambdaRole.roleName);
+ if (!useIamAuth) {
+ // Password auth: only need secret access (grantConnect requires IAM auth)
+ rdsConfig.passwordSecretId = rdsSecret.secretName;
} else {
- // grant the role permissions to connect as the postgres user
- pgvectorDb.grantConnect(lambdaRole);
- rdsConfig.passwordSecretId = rdsPasswordSecret.secretName;
+ // IAM auth: manually grant rds-db:connect permission
+ // Note: We do NOT use pgvectorDb.grantConnect() due to CDK bug #11851
+ // The grantConnect method generates incorrect ARN format (uses rds: instead of rds-db:)
+ // Per AWS docs: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html
+ // The correct format is: arn:aws:rds-db:region:account-id:dbuser:DbiResourceId/db-user-name
+ lambdaRole.addToPrincipalPolicy(new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['rds-db:connect'],
+ resources: [
+ // Use wildcard for DbiResourceId since it's not available in CloudFormation
+ // Format: arn:aws:rds-db:region:account:dbuser:*/username
+ `arn:${config.partition}:rds-db:${config.region}:${config.accountNumber}:dbuser:*/${lambdaRole.roleName}`
+ ]
+ }));
}
- // Store password secret ID in ragConfig
+ // Store DB connection details
+ // Note: dbHost is a CDK token that resolves at deploy time
+ // dbPort is already set from rdsConfig input, no need to override with token
rdsConfig.dbHost = pgvectorDb.dbInstanceEndpointAddress;
- rdsConfig.dbPort = Number(pgvectorDb.dbInstanceEndpointPort);
// Save new DB connection details as a parameter
rdsConnectionInfo = new StringParameter(this, createCdkId([repositoryId, 'StringParameter']), {
parameterName: `${config.deploymentPrefix}/LisaServeRagConnectionInfo/${repositoryId}`,
stringValue: JSON.stringify({
- ...(config.iamRdsAuth ? {} : { passwordSecretId: rdsPasswordSecret.secretName }),
username: username,
dbHost: pgvectorDb.dbInstanceEndpointAddress,
dbName: rdsConfig.dbName,
dbPort: pgvectorDb.dbInstanceEndpointPort,
- type: RagRepositoryType.PGVECTOR
+ type: RagRepositoryType.PGVECTOR,
+ // Include passwordSecretId only when using password auth
+ ...(!useIamAuth ? { passwordSecretId: rdsSecret.secretName } : {})
}),
description: 'Connection info for LISA Serve PGVector database',
});
}
- if (config.iamRdsAuth) {
- // Create the lambda for generating DB users for IAM auth
- const createDbUserLambda = this.getIAMAuthLambda(config, repositoryId, rdsConfig, rdsPasswordSecret, lambdaRole.roleName, vpc, [pgSecurityGroup], subnetSelection);
-
- const customResourceRole = new Role(this, createCdkId(['CustomResourceRole', ragConfig.repositoryId]), {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- inlinePolicies: {
- 'EC2NetworkInterfaces': new PolicyDocument({
- statements: [
- new PolicyStatement({
- effect: Effect.ALLOW,
- actions: ['ec2:CreateNetworkInterface', 'ec2:DescribeNetworkInterfaces', 'ec2:DeleteNetworkInterface'],
- resources: ['*'],
- }),
- ],
- }),
- },
- });
- createDbUserLambda.grantInvoke(customResourceRole);
-
- // run updateInstanceKmsConditionsLambda every deploy
- new AwsCustomResource(this, createCdkId([repositoryId, 'CreateDbUserCustomResource']), {
- onCreate: {
- service: 'Lambda',
- action: 'invoke',
- physicalResourceId: PhysicalResourceId.of(createCdkId([repositoryId, 'CreateDbUserCustomResource'])),
- parameters: {
- FunctionName: createDbUserLambda.functionName,
- Payload: '{}'
- },
+ if (!useIamAuth) {
+ // Password auth: grant secret read access
+ rdsSecret.grantRead(lambdaRole);
+ } else {
+ // IAM auth: use the shared IAM auth setup Lambda deployed in the main stack
+ const iamAuthSetupFnArn = StringParameter.valueForStringParameter(
+ this,
+ `${config.deploymentPrefix}/iamAuthSetupFnArn`
+ );
+
+ // Get the IAM auth setup Lambda role ARN from SSM to grant it permissions
+ const iamAuthSetupRoleArn = StringParameter.valueForStringParameter(
+ this,
+ `${config.deploymentPrefix}/iamAuthSetupRoleArn`
+ );
+
+ // Import the IAM auth setup role to grant it secret permissions
+ const iamAuthSetupRole = Role.fromRoleArn(
+ this,
+ createCdkId([repositoryId, 'IamAuthSetupRoleRef']),
+ iamAuthSetupRoleArn
+ );
+
+ // Grant the IAM auth setup Lambda role permission to read the bootstrap secret
+ rdsSecret.grantRead(iamAuthSetupRole);
+
+ // Run the shared IAM auth setup Lambda on create and update
+ // Pass parameters via payload since the Lambda is shared across repositories
+ // Use Stack.of(this).toJsonString() to properly resolve CDK tokens in the payload
+ const lambdaInvokeParams = {
+ service: 'Lambda',
+ action: 'invoke',
+ physicalResourceId: PhysicalResourceId.of(createCdkId([repositoryId, 'CreateDbUserCustomResource'])),
+ parameters: {
+ FunctionName: iamAuthSetupFnArn,
+ Payload: Stack.of(this).toJsonString({
+ secretArn: rdsSecret.secretArn,
+ dbHost: rdsConfig.dbHost,
+ dbPort: rdsConfig.dbPort,
+ dbName: rdsConfig.dbName,
+ dbUser: rdsConfig.username,
+ iamName: lambdaRole.roleName,
+ })
},
- role: customResourceRole
+ };
+
+ const createDbUserResource = new AwsCustomResource(this, createCdkId([repositoryId, 'CreateDbUserCustomResource']), {
+ onCreate: lambdaInvokeParams,
+ onUpdate: lambdaInvokeParams, // Also run on updates to ensure IAM user is created
+ policy: AwsCustomResourcePolicy.fromStatements([
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: ['lambda:InvokeFunction'],
+ resources: [iamAuthSetupFnArn],
+ })
+ ]),
});
- } else {
- rdsPasswordSecret.grantRead(lambdaRole);
+
+ // Ensure the RDS instance is fully available before running IAM auth setup
+ // (only when we created a new RDS instance)
+ if (pgvectorDb) {
+ createDbUserResource.node.addDependency(pgvectorDb);
+ }
}
- // Grant read permissions for secrets to Lambda role
+ // Grant read permissions for connection info to Lambda role
rdsConnectionInfo.grantRead(lambdaRole);
this.createPipelineRules(config, ragConfig);
}
}
-
- getIAMAuthLambda (config: PartialConfig, repositoryId: string, rdsConfig: NonNullable, secret: ISecret, user: string, vpc: IVpc, securityGroups: ISecurityGroup[], vpcSubnets?: SubnetSelection): IFunction {
- // Create the IAM role for updating the database to allow IAM authentication
- const iamAuthLambdaRole = new Role(this, createCdkId([repositoryId, 'IAMAuthLambdaRole']), {
- assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
- });
-
- secret.grantRead(iamAuthLambdaRole);
-
- const commonLayer = this.getLambdaLayer(repositoryId, config);
- const lambdaPath = config.lambdaPath || LAMBDA_PATH;
-
- return new Function(this, createCdkId([repositoryId, 'CreateDbUserLambda']), {
- runtime: getPythonRuntime(),
- handler: 'utilities.db_setup_iam_auth.handler',
- code: Code.fromAsset(lambdaPath),
- timeout: Duration.minutes(2),
- environment: {
- SECRET_ARN: secret.secretArn, // ARN of the RDS secret
- DB_HOST: rdsConfig.dbHost!,
- DB_PORT: String(rdsConfig.dbPort), // Default PostgreSQL port
- DB_NAME: rdsConfig.dbName, // Database name
- DB_USER: rdsConfig.username, // Admin user for RDS
- IAM_NAME: user, // IAM role for Lambda execution
- },
- role: iamAuthLambdaRole, // Lambda execution role
- layers: [commonLayer],
- vpc,
- securityGroups,
- vpcSubnets
- });
- }
-
- getLambdaLayer (repositoryId: string, config: PartialConfig): ILayerVersion {
- return LayerVersion.fromLayerVersionArn(
- this,
- createCdkId([repositoryId, 'CommonLayerVersion']),
- StringParameter.valueForStringParameter(this, `${config.deploymentPrefix}/layerVersion/common`),
- );
- }
}
diff --git a/vector_store_deployer/src/lib/pipeline-stack.ts b/vector_store_deployer/src/lib/pipeline-stack.ts
index 76922a820..2565f2efa 100644
--- a/vector_store_deployer/src/lib/pipeline-stack.ts
+++ b/vector_store_deployer/src/lib/pipeline-stack.ts
@@ -54,7 +54,11 @@ export abstract class PipelineStack extends Stack {
// Process each pipeline configuration
ragConfig.pipelines?.forEach((pipelineConfig, index) => {
const bucketActions = ['s3:GetObject'];
- const hash = crypto.randomBytes(6).toString('hex');
+ // Generate a deterministic hash based on collection identity for consistent naming
+ // This ensures the same hash is used for both CDK construct IDs and rule names
+ const collectionId = pipelineConfig.collectionId ?? pipelineConfig.embeddingModel;
+ const collectionName = `${ragConfig.repositoryId}-${collectionId}`;
+ const hash = crypto.createHash('sha256').update(collectionName).digest('hex').substring(0, 6);
// Add EventBridge Rules based on trigger type specified in the pipeline configuration
// Create rules based on trigger type
@@ -113,8 +117,9 @@ export abstract class PipelineStack extends Stack {
/**
* Creates an EventBridge rule for S3 event-based triggers
+ * @param collectionHash - Deterministic hash based on collection identity, used for both construct ID and rule name
*/
- private createEventLambdaRule (config: PartialConfig, ingestionLambda: IFunction, repositoryId: string, pipelineConfig: PipelineConfig, eventTypes: string[], eventName: string, disambiguator: string): Rule {
+ private createEventLambdaRule (config: PartialConfig, ingestionLambda: IFunction, repositoryId: string, pipelineConfig: PipelineConfig, eventTypes: string[], eventName: string, collectionHash: string): Rule {
const detail: any = {
bucket: {
name: [pipelineConfig.s3Bucket]
@@ -159,14 +164,16 @@ export abstract class PipelineStack extends Stack {
};
const collectionId = pipelineConfig.collectionId ?? pipelineConfig.embeddingModel;
- const collectionName = `${repositoryId}-${collectionId}`;
// Create a new EventBridge rule for the S3 event pattern
// Rule name must be <= 64 chars. Keep it descriptive but short
// Format: {deployment}-{stage}-{repoId}-S3{action}-{shortHash}
- // Use short hash of collection name for uniqueness when repo IDs collide
- const collectionHash = crypto.createHash('sha256').update(collectionName).digest('hex').substring(0, 6);
- const ruleName = `${config.deploymentName}-${config.deploymentStage}-${repositoryId}-S3${eventName}-${collectionHash}`.substring(0, 64);
- return new Rule(this, `${repositoryId}-S3Event${eventName}Rule-${disambiguator}`, {
+ // Use the same deterministic hash for both construct ID and rule name to ensure consistency across deployments
+ // Ensure hash is always preserved by truncating the prefix, not the suffix
+ const suffix = `-S3${eventName}-${collectionHash}`;
+ const prefix = `${config.deploymentName}-${config.deploymentStage}-${repositoryId}`;
+ const maxPrefixLen = 64 - suffix.length;
+ const ruleName = `${prefix.substring(0, maxPrefixLen)}${suffix}`;
+ return new Rule(this, `${repositoryId}-S3Event${eventName}Rule-${collectionHash}`, {
ruleName,
eventPattern,
// Define the state machine target with input transformation
@@ -194,12 +201,18 @@ export abstract class PipelineStack extends Stack {
/**
* Creates an EventBridge rule for daily scheduled triggers
+ * @param collectionHash - Deterministic hash based on collection identity, used for both construct ID and rule name
*/
- private createDailyLambdaRule (config: PartialConfig, ingestionLambda: IFunction, ragConfig: RagRepositoryDeploymentConfig, pipelineConfig: PipelineConfig, disambiguator: string): Rule {
+ private createDailyLambdaRule (config: PartialConfig, ingestionLambda: IFunction, ragConfig: RagRepositoryDeploymentConfig, pipelineConfig: PipelineConfig, collectionHash: string): Rule {
// Rule name must be <= 64 chars
// Format: {deployment}-{stage}-{repoId}-DailyIngest-{hash}
- const ruleName = `${config.deploymentName}-${config.deploymentStage}-${ragConfig.repositoryId}-DailyIngest-${disambiguator}`.substring(0, 64);
- return new Rule(this, `${ragConfig.repositoryId}-S3DailyIngestRule-${disambiguator}`, {
+ // Use the same deterministic hash for both construct ID and rule name to ensure consistency across deployments
+ // Ensure hash is always preserved by truncating the prefix, not the suffix
+ const suffix = `-DailyIngest-${collectionHash}`;
+ const prefix = `${config.deploymentName}-${config.deploymentStage}-${ragConfig.repositoryId}`;
+ const maxPrefixLen = 64 - suffix.length;
+ const ruleName = `${prefix.substring(0, maxPrefixLen)}${suffix}`;
+ return new Rule(this, `${ragConfig.repositoryId}-S3DailyIngestRule-${collectionHash}`, {
ruleName,
// Schedule the rule to run daily at midnight
schedule: Schedule.cron({