diff --git a/.github/workflows/code.smoke-test.yml b/.github/workflows/code.smoke-test.yml index d3393791d..1041cab23 100644 --- a/.github/workflows/code.smoke-test.yml +++ b/.github/workflows/code.smoke-test.yml @@ -1,5 +1,8 @@ name: Smoke Tests +permissions: + contents: read + # Trigger on every push to any branch on: push: @@ -22,9 +25,6 @@ jobs: - name: Install base dependencies run: npm ci - - name: Install Cypress deps - run: npm ci --prefix cypress - # ──────────────────────────────────────────────────── # Start LISA UI in background & wait until it’s ready # ──────────────────────────────────────────────────── diff --git a/.github/workflows/issues.alert.yml b/.github/workflows/issues.alert.yml index 72d8de6c1..f7995928e 100644 --- a/.github/workflows/issues.alert.yml +++ b/.github/workflows/issues.alert.yml @@ -1,4 +1,5 @@ name: Alert on Issue Creation +permissions: {} on: issues: types: [opened, reopened] diff --git a/Makefile b/Makefile index db3e118d4..a12fb4dfa 100644 --- a/Makefile +++ b/Makefile @@ -177,36 +177,40 @@ dockerCheck: ## Check if models are uploaded modelCheck: - @$(foreach MODEL_ID,$(MODEL_IDS), \ - $(PROJECT_DIR)/scripts/check-for-models.sh -m $(MODEL_ID) -s $(MODEL_BUCKET); \ - if \ - [ $$? != 0 ]; \ - then \ - localModelDir="./models"; \ - if \ - [ ! -d "$localModelDir" ]; \ - then \ - mkdir "$localModelDir"; \ - fi; \ - echo; \ - echo "Preparing to download, convert, and upload safetensors for model: $(MODEL_ID)"; \ - echo "Local directory: '$$localModelDir' will be used to store downloaded and converted model weights"; \ - echo "Note: sudo privileges required to remove model dir due to docker mount using root"; \ - echo "Would you like to continue? [y/N] "; \ - read confirm_download; \ - if \ - [ $${confirm_download:-'N'} = 'y' ]; \ - then \ - mkdir -p $$localModelDir; \ + @access_token=""; \ + for MODEL_ID in $(MODEL_IDS); do \ + $(PROJECT_DIR)/scripts/check-for-models.sh -m $$MODEL_ID -s $(MODEL_BUCKET); \ + if [ $$? != 0 ]; then \ + localModelDir="./models"; \ + if [ ! -d "$$localModelDir" ]; then \ + mkdir "$$localModelDir"; \ + fi; \ + echo; \ + echo "Preparing to download, convert, and upload safetensors for model: $$MODEL_ID"; \ + echo "Local directory: '$$localModelDir' will be used to store downloaded and converted model weights"; \ + echo "Note: sudo privileges required to remove model dir due to docker mount using root"; \ + echo "Would you like to continue? [y/N] "; \ + read confirm_download; \ + if [ $${confirm_download:-'N'} = 'y' ]; then \ + mkdir -p $$localModelDir; \ + if [ -z "$$access_token" ]; then \ + if [ -n "$$HUGGINGFACE_TOKEN" ]; then \ + access_token="$$HUGGINGFACE_TOKEN"; \ + elif [ -f ".hf_token_cache" ]; then \ + access_token=$$(cat .hf_token_cache); \ + else \ echo "What is your huggingface access token? "; \ read access_token; \ - echo "Converting and uploading safetensors for model: $(MODEL_ID)"; \ - tgiImage=$$(yq -r '[.ecsModels[] | select(.inferenceContainer == "tgi") | .baseImage] | first' $(PROJECT_DIR)/config-custom.yaml); \ - echo $$tgiImage; \ - $(PROJECT_DIR)/scripts/convert-and-upload-model.sh -m $(MODEL_ID) -s $(MODEL_BUCKET) -a $$access_token -t $$tgiImage -d $$localModelDir; \ + echo "$$access_token" > .hf_token_cache; \ + fi; \ fi; \ + echo "Converting and uploading safetensors for model: $$MODEL_ID"; \ + tgiImage=$$(yq -r '[.ecsModels[] | select(.inferenceContainer == "tgi") | .baseImage] | first' $(PROJECT_DIR)/config-custom.yaml); \ + echo $$tgiImage; \ + $(PROJECT_DIR)/scripts/convert-and-upload-model.sh -m $$MODEL_ID -s $(MODEL_BUCKET) -a $$access_token -t $$tgiImage -d $$localModelDir; \ + fi; \ fi; \ - ) + done ## Run all clean commands clean: cleanTypeScript cleanPython cleanCfn cleanMisc @@ -243,6 +247,7 @@ cleanCfn: ## Delete all misc files cleanMisc: @find . -type f -name "*.DS_Store" -delete + @rm -f .hf_token_cache ## Login Docker CLI to Amazon Elastic Container Registry @@ -284,7 +289,7 @@ endef ## Deploy all infrastructure deploy: installPythonRequirements dockerCheck dockerLogin cleanMisc modelCheck buildNpmModules $(call print_config) -ifneq (,$(findstring true, $(HEADLESS))) +ifeq ($(HEADLESS),true) npx cdk deploy ${STACK} $(if $(PROFILE),--profile ${PROFILE}) --require-approval never -c ${ENV}='$(shell echo '${${ENV}}')'; else @printf "Is the configuration correct? [y/N] "\ @@ -298,7 +303,7 @@ endif ## Tear down all infrastructure destroy: cleanMisc $(call print_config) -ifneq (,$(findstring true, $(HEADLESS))) +ifeq ($(HEADLESS),true) npx cdk destroy ${STACK} --force $(if $(PROFILE),--profile ${PROFILE}); else @printf "Is the configuration correct? [y/N] "\ diff --git a/README.md b/README.md index 56f1b5ab7..e5e456147 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,34 @@ # LLM Inference Solution for Amazon Dedicated Cloud (LISA) - [![Full Documentation](https://img.shields.io/badge/Full%20Documentation-blue?style=for-the-badge&logo=Vite&logoColor=white)](https://awslabs.github.io/LISA/) - ## What is LISA? - -LISA is an infrastructure-as-code solution providing scalable, low latency access to customers’ generative LLMs and -embedding language models. LISA accelerates and supports customers’ GenAI experimentation and adoption, particularly in -regions where Amazon Bedrock is not available. LISA allows customers to move quickly rather than independently solve the -undifferentiated heavy lifting of hosting and inference architecture. Customers deploy LISA into a single AWS account -and integrate it with an identity provider. Customers bring their own models to LISA for self-hosting and inference -supported by Amazon Elastic Container Service (ECS). Model configuration is managed through LISA’s model management -APIs. - -As use cases and model requirements grow, customers can configure LISA with external model providers. Through OpenAI's -API spec via the LiteLLM proxy, LISA is compatible with 100+ models from various providers, including Amazon Bedrock and -Amazon Jumpstart. LISA customers can centralize communication across many model providers via LiteLLM, leveraging LISA -for model orchestration. Using LISA as a model orchestration layer allows customers to standardize integrations with -externally hosted models in a single place. Without an orchestration layer, customers must individually manage unique -API integrations with each provider. - +Our large language model (LLM) inference solution for the Amazon Dedicated Cloud (ADC), LISA, is an open source infrastructure-as-code solution. Customers deploy LISA directly into an Amazon Web Services (AWS) account. While specially designed for ADC regions that support government customers' most sensitive workloads, LISA is also compatible with commercial regions. LISA supports model self-hosting via Amazon Elastic Container Service (ECS). LISA's LiteLLM support also makes it compatible with 100+ models hosted by external model providers, including Amazon Bedrock. LISA further complements Amazon Bedrock by accelerating GenAI adoption. LISA's optional chat assistant user interface (UI) supports model management, model prompting, document summarization, chat session management, prompt libraries, retrieval augmented generation (RAG), automated document ingestion pipelines, and other advanced features. Customers can choose to integrate custom UIs directly with LISA, relying on LISA for centralized model orchestration, chat session management, and RAG. LISA is scalable and ready to support production use cases. The roadmap is customer-driven, with new capabilities launching monthly. ## Key Features - -* **Self Host Models:** Bring your own text generation and embedding models to LISA for hosting and inference. -* **Model Orchestration:** Centralize and standardize configuration with 100+ models from model providers via LiteLLM, - including Amazon Bedrock models. -* **Chatbot User Interface:** Through the chatbot user interface, users can prompt LLMs, receive responses, modify prompt - templates, change model arguments, and manage their session history. Administrators can control available features via - the configuration page. -* **Retrieval-augmented generation (RAG):** RAG reduces the need for fine-tuning, an expensive and time-consuming - undertaking, and delivers more contextually relevant outputs. LISA offers RAG through Amazon OpenSearch or - PostgreSQL’s PGVector extension on Amazon RDS. -* **Non-RAG Model Context:** Users can upload documents to their chat sessions to enhance responses or support use cases - like document summarization. -* **Model Management:** Administrators can add, remove, and update models configured with LISA through the model management - configuration page or APIs. -* **OpenAI API spec:** LISA can be configured with compatible tooling. For example, customers can configure LISA as the - model provider for the [Continue](https://www.continue.dev/) plugin, an open-source AI code assistance for JetBrains and Visual Studio Code - integrated development environments (IDEs). This allows users to select from any LISA-configured model to support LLM - prompting directly in their IDE. -* **Libraries:** If your workflow includes libraries such as [LangChain](https://python.langchain.com/) - or [OpenAI](https://github.com/openai/openai-python), then you can place LISA in your - application by changing only the endpoint and headers for the client objects. -* **FedRAMP:** The AWS services that LISA leverages are FedRAMP High compliant. -* **Ongoing Releases:** We offer on-going release with new functionality. LISA’s roadmap is customer driven. - +* **Open source**: No subscription or licensing fees. LISA costs are based on service usage. The roadmap is customer-driven with monthly releases. LISA is backed by a software development team. +* **Model Flexibility**: Bring your own models for self-hosting, or quickly configure LISA with 100+ models supported by third-party model providers, including Amazon Bedrock. +* **Model Orchestration**: Centralize and standardize unique API calls to third-party model providers automatically with LISA via LiteLLM. LISA standardizes the unique API calls into the OpenAI format automatically. All that is required is an API key, model name, and API endpoint. +* **Modular Components**: Accelerate GenAI adoption with secure, scalable software. LISA supports various use cases through configurable components: model serving and orchestration, chat user interface with advanced capabilities, authentication, retrieval augmented generation (RAG), Anthropic’s Model Context Protocol (MCP), and APIs. +* **CodeGen**: Supports OpenAI’s API specification, making LISA easily configurable with compatible solutions like the Continue plugin for VSCode and JetBrains integrated development environments (IDEs). This allows users to select from any LISA configured model to support LLM prompting directly in their IDE. +* **FedRAMP**: Leverages FedRAMP High compliant services. ## Deployment Prerequisites - ### Pre-Deployment Steps - -* Set up and have access to an AWS account with appropriate permissions - * All the resource creation that happens as part of CDK deployments expects Administrator or Administrator-like - permissions with resource creation and mutation permissions. Installation will not succeed if this profile does - not have permissions to create and edit arbitrary resources for the system. Note: This level of permissions is not - required for the runtime of LISA. This is only necessary for deployment and subsequent updates. -* Familiarity with AWS Cloud Development Kit (CDK) and infrastructure-as-code principles -* Optional: If using the chat UI, Have your Identity Provider (IdP) information and access -* Optional: Have your VPC information available, if you are using an existing one for your deployment -* Note: CDK and Model Management both leverage AWS Systems Manager Agent (SSM) parameter store. Confirm that SSM is approved for use by your organization before beginning. - +* Set up or have access to an AWS account. +* Ensure that your AWS account has the appropriate permissions. Resource creation during the AWS CDK deployment expects Administrator or Administrator-like permissions, to include resource creation and mutation permissions. Installation will not succeed if this profile does not have permissions to create and edit arbitrary resources for the system. This level of permissions is not required for the runtime of LISA. This is only necessary for deployment and subsequent updates. +* If using the chat UI, have your Identity Provider (IdP) information available, and access. +* If using an existing VPC, have its information available. +* Familiarity with AWS Cloud Development Kit (CDK) and infrastructure-as-code principles is a plus. +* AWS CDK and Model Management both leverage AWS Systems Manager Agent (SSM) parameter store. Confirm that SSM is approved for use by your organization before beginning. If you're new to CDK, review the [AWS CDK Documentation](https://docs.aws.amazon.com/cdk/v2/guide/home.html) and consult with your AWS support team. ### Software - * AWS CLI installed and configured * Python 3.9 or later * Node.js 14 or later * Docker installed and running * Sufficient disk space for model downloads and conversions - - ## Getting Started - For detailed instructions on setting up, configuring, and deploying LISA, please refer to our separate documentation on installation and usage. - - [Deployment Guide](lib/docs/admin/getting-started.md) - [Configuration](lib/docs/config/configuration.md) - ## License - Although this repository is released under the Apache 2.0 license, when configured to use PGVector as a RAG store it uses the third party `psycopg2-binary` library. The `psycopg2-binary` project's licensing includes diff --git a/VERSION b/VERSION index 0062ac971..6b244dcd6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.0.0 +5.0.1 diff --git a/cypress/package-lock.json b/cypress/package-lock.json deleted file mode 100644 index 6f0ce46e1..000000000 --- a/cypress/package-lock.json +++ /dev/null @@ -1,2607 +0,0 @@ -{ - "name": "@awslabs/lisae2e", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@awslabs/lisae2e", - "version": "1.0.0", - "devDependencies": { - "@types/node": "^22.14.1", - "cypress": "^14.3.0", - "lint-staged": "^15.5.1", - "lodash": "^4.17.21" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@cypress/request": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.8.tgz", - "integrity": "sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==", - "dev": true, - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~4.0.0", - "http-signature": "~1.4.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "6.14.0", - "safe-buffer": "^5.1.2", - "tough-cookie": "^5.0.0", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "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, - "dependencies": { - "debug": "^3.1.0", - "lodash.once": "^4.1.1" - } - }, - "node_modules/@cypress/xvfb/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", - "dev": true, - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "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", - "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", - "dev": true - }, - "node_modules/@types/sizzle": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz", - "integrity": "sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==", - "dev": true - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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, - "engines": { - "node": ">=8" - } - }, - "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, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arch": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", - "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", - "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" - } - ] - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "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" - } - ] - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/blob-util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "dev": true - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "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" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/cachedir": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", - "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ci-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.1.0.tgz", - "integrity": "sha512-HutrvTNsF48wnxkzERIXOe5/mlcfFcbfCmwcg6CJnizbSue78AbDt+1cgl26zwn61WFxhcPykPfZrbqjGmBb4A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", - "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cypress": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-14.3.0.tgz", - "integrity": "sha512-rRfPl9Z0/CczuYybBEoLbDVuT1OGkhYaJ0+urRCshgiDRz6QnoA0KQIQnPx7MJ3zy+VCsbUU1pV74n+6cbJEdg==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@cypress/request": "^3.0.8", - "@cypress/xvfb": "^1.2.4", - "@types/sinonjs__fake-timers": "8.1.1", - "@types/sizzle": "^2.3.2", - "arch": "^2.2.0", - "blob-util": "^2.0.2", - "bluebird": "^3.7.2", - "buffer": "^5.7.1", - "cachedir": "^2.3.0", - "chalk": "^4.1.0", - "check-more-types": "^2.24.0", - "ci-info": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.5", - "commander": "^6.2.1", - "common-tags": "^1.8.0", - "dayjs": "^1.10.4", - "debug": "^4.3.4", - "enquirer": "^2.3.6", - "eventemitter2": "6.4.7", - "execa": "4.1.0", - "executable": "^4.1.1", - "extract-zip": "2.0.1", - "figures": "^3.2.0", - "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", - "listr2": "^3.8.3", - "lodash": "^4.17.21", - "log-symbols": "^4.0.0", - "minimist": "^1.2.8", - "ospath": "^1.2.2", - "pretty-bytes": "^5.6.0", - "process": "^0.11.10", - "proxy-from-env": "1.0.0", - "request-progress": "^3.0.0", - "semver": "^7.7.1", - "supports-color": "^8.1.1", - "tmp": "~0.2.3", - "tree-kill": "1.2.2", - "untildify": "^4.0.0", - "yauzl": "^2.10.0" - }, - "bin": { - "cypress": "bin/cypress" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - } - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "dev": true - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dev": true, - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enquirer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/eventemitter2": { - "version": "6.4.7", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", - "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", - "dev": true - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "dev": true - }, - "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/executable": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", - "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", - "dev": true, - "dependencies": { - "pify": "^2.2.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true - }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, - "engines": [ - "node >=0.6.0" - ] - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "dev": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", - "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "dependencies": { - "async": "^3.2.0" - } - }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dev": true, - "dependencies": { - "assert-plus": "^1.0.0" - } - }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-signature": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", - "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", - "dev": true, - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.18.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", - "dev": true, - "engines": { - "node": ">=8.12.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "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" - } - ] - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", - "dev": true, - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "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", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true, - "engines": { - "node": "> 0.8" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lint-staged": { - "version": "15.5.1", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.1.tgz", - "integrity": "sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==", - "dev": true, - "dependencies": { - "chalk": "^5.4.1", - "commander": "^13.1.0", - "debug": "^4.4.0", - "execa": "^8.0.1", - "lilconfig": "^3.1.3", - "listr2": "^8.2.5", - "micromatch": "^4.0.8", - "pidtree": "^0.6.0", - "string-argv": "^0.3.2", - "yaml": "^2.7.0" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dev": true, - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/chalk": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", - "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dev": true, - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", - "dev": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/lint-staged/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true - }, - "node_modules/lint-staged/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/lint-staged/node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/listr2": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.2.tgz", - "integrity": "sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==", - "dev": true, - "dependencies": { - "cli-truncate": "^4.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/lint-staged/node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dev": true, - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/lint-staged/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/lint-staged/node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lint-staged/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/listr2": { - "version": "3.14.0", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", - "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", - "dev": true, - "dependencies": { - "cli-truncate": "^2.1.0", - "colorette": "^2.0.16", - "log-update": "^4.0.0", - "p-map": "^4.0.0", - "rfdc": "^1.3.0", - "rxjs": "^7.5.1", - "through": "^2.3.8", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "enquirer": ">= 2.3.0 < 3" - }, - "peerDependenciesMeta": { - "enquirer": { - "optional": true - } - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "dev": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, - "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ospath": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", - "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", - "dev": true - }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", - "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", - "dev": true, - "bin": { - "pidtree": "bin/pidtree.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", - "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true - }, - "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/request-progress": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", - "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", - "dev": true, - "dependencies": { - "throttleit": "^1.0.0" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "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" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true - }, - "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dev": true, - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/throttleit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", - "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "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==", - "dev": true, - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "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==", - "dev": true - }, - "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "dev": true, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "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, - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "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, - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/yaml": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", - "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", - "dev": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - } - } -} diff --git a/lambda/authorizer/lambda_functions.py b/lambda/authorizer/lambda_functions.py index c5b577c0b..c5ed40650 100644 --- a/lambda/authorizer/lambda_functions.py +++ b/lambda/authorizer/lambda_functions.py @@ -47,7 +47,7 @@ def lambda_handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: i id_token = get_id_token(event) if not id_token: - logger.warn("Missing id_token in request. Denying access.") + logger.warning("Missing id_token in request. Denying access.") logger.info(f"REST API authorization handler completed with 'Deny' for resource {event['methodArn']}") return generate_policy(effect="Deny", resource=event["methodArn"]) diff --git a/lambda/dockerimagebuilder/__init__.py b/lambda/dockerimagebuilder/__init__.py index 9b957965f..f50fae2a5 100644 --- a/lambda/dockerimagebuilder/__init__.py +++ b/lambda/dockerimagebuilder/__init__.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import logging import os import shlex import uuid @@ -20,11 +21,44 @@ import boto3 from botocore.exceptions import ClientError +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + user_data_template = """#! /bin/bash -ex export AWS_REGION={{AWS_REGION}} -(r=5;while ! yum install -y docker ; do ((--r))||exit;sleep 60;done) +export LOG_GROUP={{LOG_GROUP}} + +# Install CloudWatch agent and docker +(r=5;while ! yum install -y docker amazon-cloudwatch-agent ; do ((--r))||exit;sleep 60;done) + +# Configure CloudWatch agent +cat > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json << EOF +{ + "logs": { + "logs_collected": { + "files": { + "collect_list": [ + { + "file_path": "/var/log/docker-build.log", + "log_group_name": "${LOG_GROUP}", + "log_stream_name": "docker-build-{{IMAGE_ID}}" + } + ] + } + } + } +} +EOF + +# Start services systemctl start docker + +# Start CloudWatch agent with configuration +/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s + +# Setup build environment mkdir /home/ec2-user/docker_resources aws --region ${AWS_REGION} s3 sync s3://{{BUCKET_NAME}} /home/ec2-user/docker_resources cd /home/ec2-user/docker_resources/{{LAYER_TO_ADD}} @@ -35,11 +69,13 @@ done & function buildTagPush() { + echo "Starting Docker build for {{IMAGE_ID}}" | tee -a /var/log/docker-build.log sed -iE 's/^FROM.*/FROM {{BASE_IMAGE}}/' Dockerfile - docker build -t {{IMAGE_ID}} --build-arg BASE_IMAGE={{BASE_IMAGE}} --build-arg MOUNTS3_DEB_URL={{MOUNTS3_DEB_URL}} . && \ - docker tag {{IMAGE_ID}} {{ECR_URI}}:{{IMAGE_ID}} && \ - aws --region ${AWS_REGION} ecr get-login-password | docker login --username AWS --password-stdin {{ECR_URI}} && \ - docker push {{ECR_URI}}:{{IMAGE_ID}} + docker build -t {{IMAGE_ID}} --build-arg BASE_IMAGE={{BASE_IMAGE}} --build-arg MOUNTS3_DEB_URL={{MOUNTS3_DEB_URL}} . 2>&1 | tee -a /var/log/docker-build.log && \ + docker tag {{IMAGE_ID}} {{ECR_URI}}:{{IMAGE_ID}} 2>&1 | tee -a /var/log/docker-build.log && \ + aws --region ${AWS_REGION} ecr get-login-password | docker login --username AWS --password-stdin {{ECR_URI}} 2>&1 | tee -a /var/log/docker-build.log && \ + docker push {{ECR_URI}}:{{IMAGE_ID}} 2>&1 | tee -a /var/log/docker-build.log + echo "Build completed with exit code $?" | tee -a /var/log/docker-build.log return $? } @@ -48,10 +84,14 @@ 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"] layer_to_add = event["layer_to_add"] mounts3_deb_url = os.environ["LISA_MOUNTS3_DEB_URL"] + logger.info(f"Building image with base: {base_image}, layer: {layer_to_add}") + ec2_resource = boto3.resource("ec2", region_name=os.environ["AWS_REGION"]) ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"]) @@ -59,8 +99,11 @@ def handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: ignore [ ami_id = response["Parameter"]["Value"] image_tag = str(uuid.uuid4()) + logger.info(f"Using AMI: {ami_id}, Image tag: {image_tag}") + rendered_userdata = user_data_template rendered_userdata = rendered_userdata.replace("{{AWS_REGION}}", shlex.quote(os.environ["AWS_REGION"])) + rendered_userdata = rendered_userdata.replace("{{LOG_GROUP}}", shlex.quote(context.log_group_name)) rendered_userdata = rendered_userdata.replace("{{BUCKET_NAME}}", shlex.quote(os.environ["LISA_DOCKER_BUCKET"])) rendered_userdata = rendered_userdata.replace("{{LAYER_TO_ADD}}", shlex.quote(layer_to_add)) rendered_userdata = rendered_userdata.replace("{{BASE_IMAGE}}", shlex.quote(base_image)) @@ -96,9 +139,14 @@ def handler(event: Dict[str, Any], context) -> Dict[str, Any]: # type: ignore [ instance_params["SubnetId"] = os.environ["LISA_SUBNET_ID"] # Create instance with parameters + logger.info(f"Creating Builder EC2 instance with params: {instance_params}") instances = ec2_resource.create_instances(**instance_params) - return {"instance_id": instances[0].instance_id, "image_tag": image_tag} + instance_id = instances[0].instance_id + logger.info(f"Successfully created Builder EC2 instance: {instance_id}") + + return {"instance_id": instance_id, "image_tag": image_tag} except ClientError as e: + logger.error(f"Failed to create EC2 instance: {str(e)}") raise e diff --git a/lambda/models/domain_objects.py b/lambda/models/domain_objects.py index d0014ca3a..6d7ee2e58 100644 --- a/lambda/models/domain_objects.py +++ b/lambda/models/domain_objects.py @@ -122,11 +122,19 @@ class AutoScalingInstanceConfig(BaseModel): minCapacity: Optional[PositiveInt] = None maxCapacity: Optional[PositiveInt] = None desiredCapacity: Optional[PositiveInt] = None + cooldown: Optional[PositiveInt] = None + defaultInstanceWarmup: Optional[PositiveInt] = None @model_validator(mode="after") def validate_auto_scaling_instance_config(self) -> Self: """Validates auto-scaling instance configuration parameters.""" - config_fields = [self.minCapacity, self.maxCapacity, self.desiredCapacity] + config_fields = [ + self.minCapacity, + self.maxCapacity, + self.desiredCapacity, + self.cooldown, + self.defaultInstanceWarmup, + ] if not validate_any_fields_defined(config_fields): raise ValueError("At least one option of autoScalingInstanceConfig must be defined.") if self.desiredCapacity and self.maxCapacity and self.desiredCapacity > self.maxCapacity: @@ -171,6 +179,27 @@ def validate_environment(cls, environment: Dict[str, str]) -> Dict[str, str]: return environment +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 + + @field_validator("environment") + @classmethod + 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())): + raise ValueError("Empty strings are not allowed for environment variable key names.") + return environment + + class ModelFeature(BaseModel): """Defines model feature attributes.""" @@ -192,11 +221,13 @@ class LISAModel(BaseModel): loadBalancerConfig: Optional[LoadBalancerConfig] = None modelId: str modelName: str + modelDescription: Optional[str] = None modelType: ModelType modelUrl: Optional[str] = None status: ModelStatus streaming: bool features: Optional[List[ModelFeature]] = None + allowedGroups: Optional[List[str]] = None class ApiResponseBase(BaseModel): @@ -215,10 +246,12 @@ class CreateModelRequest(BaseModel): loadBalancerConfig: Optional[LoadBalancerConfig] = None modelId: str = Field(min_length=1) modelName: str = Field(min_length=1) + modelDescription: Optional[str] = None modelType: ModelType modelUrl: Optional[str] = None streaming: Optional[bool] = False features: Optional[List[ModelFeature]] = None + allowedGroups: Optional[List[str]] = None @model_validator(mode="after") def validate_create_model_request(self) -> Self: @@ -267,7 +300,11 @@ class UpdateModelRequest(BaseModel): 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 @model_validator(mode="after") def validate_update_model_request(self) -> Self: @@ -276,12 +313,16 @@ def validate_update_model_request(self) -> Self: self.autoScalingInstanceConfig, self.enabled, self.modelType, + self.modelDescription, self.streaming, + self.allowedGroups, + self.features, + self.containerConfig, ] if not validate_any_fields_defined(fields): raise ValueError( - "At least one field out of autoScalingInstanceConfig, enabled, modelType, or " - "streaming must be defined in request payload." + "At least one field out of autoScalingInstanceConfig, containerConfig, enabled, modelType, " + "modelDescription, streaming, allowedGroups, or features must be defined in request payload." ) if self.modelType == ModelType.EMBEDDING and self.streaming: @@ -296,6 +337,14 @@ def validate_autoscaling_instance_config(cls, config: AutoScalingInstanceConfig) raise ValueError("The autoScalingInstanceConfig must not be null if defined in request payload.") return config + @field_validator("containerConfig") + @classmethod + def validate_container_config(cls, config: ContainerConfigUpdatable) -> ContainerConfigUpdatable: + """Validates container configuration update.""" + if not config: + raise ValueError("The containerConfig must not be null if defined in request payload.") + return config + class UpdateModelResponse(ApiResponseBase): """Defines response structure for model updates.""" diff --git a/lambda/models/handler/base_handler.py b/lambda/models/handler/base_handler.py index 6fee94aa4..e165ab913 100644 --- a/lambda/models/handler/base_handler.py +++ b/lambda/models/handler/base_handler.py @@ -32,6 +32,6 @@ def __init__( self._stepfunctions = stepfunctions_client self._model_table = model_table_resource - def __call__(self, *args: Any, **kwargs: Any) -> None: + def __call__(self, *args: Any, **kwargs: Any) -> Any: """All handlers must implement the __call__ method.""" raise NotImplementedError("__call__ method must be defined in child API Handler class.") diff --git a/lambda/models/handler/get_model_handler.py b/lambda/models/handler/get_model_handler.py index cb868677a..675381953 100644 --- a/lambda/models/handler/get_model_handler.py +++ b/lambda/models/handler/get_model_handler.py @@ -14,6 +14,10 @@ """Handler for GetModel requests.""" +from typing import List, Optional + +from utilities.common_functions import user_has_group_access + from ..domain_objects import GetModelResponse from ..exception import ModelNotFoundError from .base_handler import BaseApiHandler @@ -23,9 +27,19 @@ class GetModelHandler(BaseApiHandler): """Handler class for GetModel requests.""" - def __call__(self, model_id: str) -> GetModelResponse: # type: ignore + def __call__( + self, model_id: str, user_groups: Optional[List[str]] = 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: raise ModelNotFoundError(f"Model '{model_id}' was not found.") - return GetModelResponse(model=to_lisa_model(ddb_item)) + + model = to_lisa_model(ddb_item) + + # Check if user has access to this model based on groups + if not is_admin and user_groups is not None: + if not user_has_group_access(user_groups, model.allowedGroups or []): + raise ModelNotFoundError(f"Model '{model_id}' was not found.") + + return GetModelResponse(model=model) diff --git a/lambda/models/handler/list_models_handler.py b/lambda/models/handler/list_models_handler.py index be870a235..56b29fd7b 100644 --- a/lambda/models/handler/list_models_handler.py +++ b/lambda/models/handler/list_models_handler.py @@ -14,6 +14,10 @@ """Handler for ListModels requests.""" +from typing import List, Optional + +from utilities.common_functions import user_has_group_access + from ..domain_objects import ListModelsResponse from .base_handler import BaseApiHandler from .utils import to_lisa_model @@ -22,7 +26,7 @@ class ListModelsHandler(BaseApiHandler): """Handler class for ListModels requests.""" - def __call__(self) -> ListModelsResponse: # type: ignore + def __call__(self, user_groups: Optional[List[str]] = 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() @@ -34,4 +38,11 @@ def __call__(self) -> ListModelsResponse: # type: ignore pagination_key = models_response.get("LastEvaluatedKey", None) models_list = [to_lisa_model(m) for m in ddb_models] + + # Filter models based on user groups if not admin + if not is_admin and user_groups is not None: + models_list = [ + model for model in models_list if user_has_group_access(user_groups, model.allowedGroups or []) + ] + return ListModelsResponse(models=models_list) diff --git a/lambda/models/handler/update_model_handler.py b/lambda/models/handler/update_model_handler.py index 748a16382..6ae691a28 100644 --- a/lambda/models/handler/update_model_handler.py +++ b/lambda/models/handler/update_model_handler.py @@ -88,6 +88,19 @@ def __call__(self, model_id: str, update_request: UpdateModelRequest) -> UpdateM if asg_config.minCapacity is None and asg_config.maxCapacity < model_asg["MinSize"]: raise ValueError(f"Max capacity cannot be less than ASG min of {model_asg['MinSize']}.") + # Validate containerConfig updates + if update_request.containerConfig is not None: + current_asg = ddb_item.get("auto_scaling_group", "") + if not current_asg: + raise ValueError("Cannot update Container Config for model not hosted in LISA infrastructure.") + + # Validate that containerConfig exists in the current model + current_container_config = ddb_item.get("model_config", {}).get("containerConfig", None) + if not current_container_config: + raise ValueError( + "Cannot update Container Config for model that was not originally configured with a container." + ) + # Post-validation. Send work to state machine. # package model ID and request payload into single payload for step functions diff --git a/lambda/models/lambda_functions.py b/lambda/models/lambda_functions.py index e9ad299c3..a04f08882 100644 --- a/lambda/models/lambda_functions.py +++ b/lambda/models/lambda_functions.py @@ -24,7 +24,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from mangum import Mangum -from utilities.common_functions import retry_config +from utilities.common_functions import get_groups, is_admin, retry_config from utilities.fastapi_middleware.aws_api_gateway_middleware import AWSAPIGatewayMiddleware from .domain_objects import ( @@ -97,14 +97,27 @@ async def create_model(create_request: CreateModelRequest) -> CreateModelRespons @app.get(path="", include_in_schema=False) @app.get(path="/") -async def list_models() -> ListModelsResponse: +async def list_models(request: Request) -> ListModelsResponse: """Endpoint to list models.""" list_handler = ListModelsHandler( autoscaling_client=autoscaling, stepfunctions_client=stepfunctions, model_table_resource=model_table, ) - return list_handler() + + 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 list_handler(user_groups=user_groups, is_admin=admin_status) @app.get(path="/{model_id}") @@ -117,7 +130,20 @@ async def get_model( stepfunctions_client=stepfunctions, model_table_resource=model_table, ) - return get_handler(model_id=model_id) + + 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 get_handler(model_id=model_id, user_groups=user_groups, is_admin=admin_status) @app.put(path="/{model_id}") diff --git a/lambda/models/state_machine/create_model.py b/lambda/models/state_machine/create_model.py index 7d9071347..3c97baa2c 100644 --- a/lambda/models/state_machine/create_model.py +++ b/lambda/models/state_machine/create_model.py @@ -15,9 +15,10 @@ """Lambda handlers for CreateModel state machine.""" import json +import logging import os from copy import deepcopy -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict import boto3 @@ -31,6 +32,9 @@ ) from utilities.common_functions import get_cert_path, get_rest_api_container_endpoint, retry_config +logger = logging.getLogger() +logger.setLevel(logging.INFO) + lambdaConfig = Config(connect_timeout=60, read_timeout=600, retries={"max_attempts": 1}) lambdaClient = boto3.client("lambda", region_name=os.environ["AWS_REGION"], config=lambdaConfig) ecrClient = boto3.client("ecr", region_name=os.environ["AWS_REGION"], config=retry_config) @@ -64,13 +68,15 @@ def get_container_path(inference_container_type: InferenceContainer) -> str: InferenceContainer.TGI: "textgen/tgi", InferenceContainer.VLLM: "vllm", } - return path_mapping[inference_container_type] # API validation before state machine guarantees the value exists. + # API validation before state machine guarantees the value exists. + return path_mapping[inference_container_type] 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.validate(event) + request = CreateModelRequest.model_validate(event) is_lisa_managed = all( ( @@ -87,11 +93,15 @@ def handle_set_model_to_creating(event: Dict[str, Any], context: Any) -> Dict[st model_table.update_item( Key={"model_id": request.modelId}, - UpdateExpression="SET model_status = :model_status, model_config = :model_config, last_modified_date = :lm", + UpdateExpression=( + "SET model_status = :model_status, model_config = :model_config, " + "model_description = :model_description, last_modified_date = :lm" + ), ExpressionAttributeValues={ ":model_status": ModelStatus.CREATING, ":model_config": event, - ":lm": int(datetime.utcnow().timestamp()), + ":model_description": request.modelDescription, + ":lm": int(datetime.now(UTC).timestamp()), }, ) output_dict["create_infra"] = is_lisa_managed @@ -100,8 +110,9 @@ def handle_set_model_to_creating(event: Dict[str, Any], context: Any) -> Dict[st 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) - request = CreateModelRequest.validate(event) + request = CreateModelRequest.model_validate(event) image_path = get_container_path(request.inferenceContainer) output_dict["containerConfig"]["image"]["path"] = image_path @@ -153,7 +164,7 @@ def handle_poll_docker_image_available(event: Dict[str, Any], context: Any) -> D def handle_start_create_stack(event: Dict[str, Any], context: Any) -> Dict[str, Any]: """Start model infrastructure creation.""" output_dict = deepcopy(event) - request = CreateModelRequest.validate(event) + request = CreateModelRequest.model_validate(event) def camelize_object(o): # type: ignore[no-untyped-def] o2 = {} @@ -204,7 +215,7 @@ def camelize_object(o): # type: ignore[no-untyped-def] ExpressionAttributeValues={ ":stack_name": stack_name, ":stack_arn": stack_arn, - ":lm": int(datetime.utcnow().timestamp()), + ":lm": int(datetime.now(UTC).timestamp()), }, ) @@ -292,7 +303,7 @@ def handle_add_model_to_litellm(event: Dict[str, Any], context: Any) -> Dict[str ExpressionAttributeValues={ ":ms": ModelStatus.IN_SERVICE, ":lid": litellm_id, - ":lm": int(datetime.utcnow().timestamp()), + ":lm": int(datetime.now(UTC).timestamp()), ":mu": litellm_params.get("api_base", ""), ":asg": event.get("autoScalingGroup", ""), }, @@ -313,14 +324,17 @@ def handle_failure(event: Dict[str, Any], context: Any) -> Dict[str, Any]: Expectation of this function is to terminate the EC2 instance if it is still running, and to set the model status to Failed. Cleaning up the CloudFormation stack, if it still exists, will happen in the DeleteModel API. """ + logger.error(f"Handling state machine failure: {event}") error_dict = json.loads( # error from SFN is json payload on top of json payload we add to the exception json.loads(event["Cause"])["errorMessage"] ) error_reason = error_dict["error"] original_event = error_dict["event"] + logger.error(f"Failure reason: {error_reason}, Model ID: {original_event.get('modelId', 'unknown')}") # terminate EC2 instance if we have one recorded if "image_info" in original_event and "instance_id" in original_event["image_info"]: + logger.info(f"Terminating EC2 instance: {original_event['image_info']['instance_id']}") ec2Client.terminate_instances(InstanceIds=[original_event["image_info"]["instance_id"]]) # set model as Failed in DDB, so it shows as such in the UI. adds error reason as well. @@ -329,7 +343,7 @@ def handle_failure(event: Dict[str, Any], context: Any) -> Dict[str, Any]: UpdateExpression="SET model_status = :ms, last_modified_date = :lm, failure_reason = :fr", ExpressionAttributeValues={ ":ms": ModelStatus.FAILED, - ":lm": int(datetime.utcnow().timestamp()), + ":lm": int(datetime.now(UTC).timestamp()), ":fr": error_reason, }, ) diff --git a/lambda/models/state_machine/delete_model.py b/lambda/models/state_machine/delete_model.py index 8ba002e86..e5f4ac9cc 100644 --- a/lambda/models/state_machine/delete_model.py +++ b/lambda/models/state_machine/delete_model.py @@ -14,9 +14,10 @@ """Lambda handlers for DeleteModel state machine.""" +import logging import os from copy import deepcopy -from datetime import datetime +from datetime import datetime, UTC from typing import Any, Dict from uuid import uuid4 @@ -26,6 +27,9 @@ from ..domain_objects import ModelStatus +logger = logging.getLogger() +logger.setLevel(logging.INFO) + # Clients cloudformation = boto3.client("cloudformation", region_name=os.environ["AWS_REGION"], config=retry_config) dynamodb = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config) @@ -53,6 +57,7 @@ def handle_set_model_to_deleting(event: Dict[str, Any], context: Any) -> Dict[st """Start deletion workflow based on user-specified model input.""" output_dict = deepcopy(event) model_id = event["modelId"] + logger.info(f"Starting deletion workflow for model: {model_id}") model_key = {"model_id": model_id} item = ddb_table.get_item( Key=model_key, @@ -68,7 +73,7 @@ def handle_set_model_to_deleting(event: Dict[str, Any], context: Any) -> Dict[st Key=model_key, UpdateExpression="SET last_modified_date = :lmd, model_status = :ms", ExpressionAttributeValues={ - ":lmd": int(datetime.utcnow().timestamp()), + ":lmd": int(datetime.now(UTC).timestamp()), ":ms": ModelStatus.DELETING, }, ) @@ -85,6 +90,7 @@ def handle_delete_from_litellm(event: Dict[str, Any], context: Any) -> Dict[str, 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}") client_request_token = str(uuid4()) cloudformation.delete_stack( StackName=stack_arn, diff --git a/lambda/models/state_machine/update_model.py b/lambda/models/state_machine/update_model.py index d458605e7..8965304ab 100644 --- a/lambda/models/state_machine/update_model.py +++ b/lambda/models/state_machine/update_model.py @@ -18,8 +18,8 @@ import logging import os from copy import deepcopy -from datetime import datetime -from typing import Any, Dict +from datetime import datetime, UTC +from typing import Any, Callable, Dict, List, Optional import boto3 from models.clients.litellm_client import LiteLLMClient @@ -29,6 +29,8 @@ ddbResource = boto3.resource("dynamodb", region_name=os.environ["AWS_REGION"], config=retry_config) model_table = ddbResource.Table(os.environ["MODEL_TABLE_NAME"]) autoscaling_client = boto3.client("autoscaling", region_name=os.environ["AWS_REGION"], config=retry_config) +ecs_client = boto3.client("ecs", region_name=os.environ["AWS_REGION"], config=retry_config) +cfn_client = boto3.client("cloudformation", region_name=os.environ["AWS_REGION"], config=retry_config) iam_client = boto3.client("iam", region_name=os.environ["AWS_REGION"], config=retry_config) secrets_manager = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"], config=retry_config) @@ -47,6 +49,119 @@ logging.basicConfig(level=logging.INFO) +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]: + """Handle container config update. + + Returns: + Dict containing any container config metadata needed for ECS updates + """ + logger.info(f"Updating container configuration for model '{model_id}'") + + container_metadata = {} + + if container_config.get("environment") is not None: + env_vars = container_config["environment"] + env_vars_to_delete = [] + + # Handle environment variable deletion markers + for key, value in env_vars.items(): + if value == "LISA_MARKED_FOR_DELETION": + env_vars_to_delete.append(key) + + for key in env_vars_to_delete: + del env_vars[key] + + model_config["containerConfig"]["environment"] = env_vars + + # Store deletion info for ECS update + if env_vars_to_delete: + container_metadata["env_vars_to_delete"] = env_vars_to_delete + logger.info(f"Deleted environment variables for model '{model_id}': {env_vars_to_delete}") + logger.info(f"Updated environment variables for model '{model_id}': {env_vars}") + + # Update sharedMemorySize + if container_config.get("sharedMemorySize") is not None: + model_config["containerConfig"]["sharedMemorySize"] = container_config["sharedMemorySize"] + logger.info(f"Updated shared memory size for model '{model_id}': {container_config['sharedMemorySize']}") + + # Update health check configuration + health_check_updates = {} + if container_config.get("healthCheckCommand") is not None: + health_check_updates["command"] = container_config["healthCheckCommand"] + if container_config.get("healthCheckInterval") is not None: + health_check_updates["interval"] = container_config["healthCheckInterval"] + if container_config.get("healthCheckTimeout") is not None: + health_check_updates["timeout"] = container_config["healthCheckTimeout"] + if container_config.get("healthCheckStartPeriod") is not None: + health_check_updates["startPeriod"] = container_config["healthCheckStartPeriod"] + if container_config.get("healthCheckRetries") is not None: + health_check_updates["retries"] = container_config["healthCheckRetries"] + + if health_check_updates: + # Update the health check config in model_config + if "healthCheckConfig" not in model_config["containerConfig"]: + model_config["containerConfig"]["healthCheckConfig"] = {} + + for field, value in health_check_updates.items(): + model_config["containerConfig"]["healthCheckConfig"][field] = value + + logger.info(f"Updated health check configuration for model '{model_id}': {health_check_updates}") + + return container_metadata + + +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), + "streaming": lambda value: _update_simple_field(model_config, "streaming", value, model_id), + "modelDescription": lambda value: _update_simple_field(model_config, "modelDescription", value, model_id), + "allowedGroups": lambda value: _update_simple_field(model_config, "allowedGroups", value, model_id), + "features": lambda value: _update_simple_field(model_config, "features", value, model_id), + "containerConfig": lambda value: _update_container_config(model_config, value, model_id), + } + + +def _process_metadata_updates( + model_config: Dict[str, Any], update_payload: Dict[str, Any], model_id: str +) -> tuple[bool, Dict[str, Any]]: + """ + Process metadata updates. + + Args: + model_config: The model configuration dictionary to update + update_payload: The payload containing updates + model_id: The model ID for logging purposes + + Returns: + tuple: (has_updates: bool, metadata: Dict containing update metadata) + """ + update_handlers = _get_metadata_update_handlers(model_config, model_id) + has_updates = False + update_metadata = {} + + for field_name, handler in update_handlers.items(): + if field_name in update_payload and update_payload[field_name] is not None: + # Handle containerConfig specially since it returns metadata + if field_name == "containerConfig": + container_metadata = handler(update_payload[field_name]) + if container_metadata: + update_metadata["container"] = container_metadata + else: + handler(update_payload[field_name]) + has_updates = True + + return has_updates, update_metadata + + def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]: """ Handle initial UpdateModel job submission. @@ -107,7 +222,7 @@ def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]: ddb_update_expression = "SET model_status = :ms, last_modified_date = :lm" ddb_update_values = { ":ms": ModelStatus.UPDATING, - ":lm": int(datetime.utcnow().timestamp()), + ":lm": int(datetime.now(UTC).timestamp()), } if is_activation_request: @@ -153,6 +268,11 @@ def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]: model_config["autoScalingConfig"]["minCapacity"] = int(minCapacity) if maxCapacity := asg_config.get("maxCapacity", False): model_config["autoScalingConfig"]["maxCapacity"] = int(maxCapacity) + if cooldown := asg_config.get("cooldown", False): + model_config["autoScalingConfig"]["cooldown"] = int(cooldown) + if defaultInstanceWarmup := asg_config.get("defaultInstanceWarmup", False): + model_config["autoScalingConfig"]["defaultInstanceWarmup"] = int(defaultInstanceWarmup) + # If model is running, apply update immediately, else set metadata but don't apply until an 'enable' operation if model_status == ModelStatus.IN_SERVICE: asg_update_payload = { @@ -164,24 +284,26 @@ def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]: asg_update_payload["MaxSize"] = int(maxCapacity) if desiredCapacity := asg_config.get("desiredCapacity", False): asg_update_payload["DesiredCapacity"] = int(desiredCapacity) + if cooldown: + asg_update_payload["DefaultCooldown"] = int(cooldown) + if defaultInstanceWarmup: + asg_update_payload["DefaultInstanceWarmup"] = int(defaultInstanceWarmup) # Start ASG update with known parameters. Because of model validations, at least one arg is guaranteed. autoscaling_client.update_auto_scaling_group(**asg_update_payload) - # metadata updates - payload_model_type = event["update_payload"].get("modelType", None) - is_payload_streaming_update = (payload_streaming := event["update_payload"].get("streaming", None)) is not None - if payload_model_type or is_payload_streaming_update or is_autoscaling_update: - if payload_model_type: - logger.info(f"Setting type '{payload_model_type}' for model '{model_id}'") - model_config["modelType"] = payload_model_type - if is_payload_streaming_update: - logger.info(f"Setting streaming to '{payload_streaming}' for model '{model_id}'") - model_config["streaming"] = payload_streaming + # Process metadata updates + has_metadata_update, update_metadata = _process_metadata_updates(model_config, event["update_payload"], model_id) + has_metadata_update = has_metadata_update or is_autoscaling_update + if has_metadata_update: ddb_update_expression += ", model_config = :mc" ddb_update_values[":mc"] = model_config + # Pass through container metadata for ECS updates + if update_metadata.get("container"): + output_dict["container_metadata"] = update_metadata["container"] + logger.info(f"Model '{model_id}' update expression: {ddb_update_expression}") logger.info(f"Model '{model_id}' update values: {ddb_update_values}") @@ -191,11 +313,20 @@ def handle_job_intake(event: Dict[str, Any], context: Any) -> Dict[str, Any]: ExpressionAttributeValues=ddb_update_values, ) + # Determine if ECS update is needed (container config changes for LISA-hosted models) + needs_ecs_update = ( + event["update_payload"].get("containerConfig") is not None + and model_asg is not None + and model_status == ModelStatus.IN_SERVICE + ) + # We only need to poll for activation so that we know when to add the model back to LiteLLM output_dict["has_capacity_update"] = is_enable output_dict["is_disable"] = is_disable + output_dict["needs_ecs_update"] = needs_ecs_update output_dict["initial_model_status"] = model_status # needed for simple metadata updates output_dict["current_model_status"] = ddb_update_values[":ms"] # for state machine debugging / visibility + return output_dict @@ -212,6 +343,7 @@ def handle_poll_capacity(event: Dict[str, Any], context: Any) -> Dict[str, Any]: output_dict = deepcopy(event) model_id = event["model_id"] asg_name = event["asg_name"] + logger.info(f"Polling capacity for model {model_id}, ASG: {asg_name}") asg_info = autoscaling_client.describe_auto_scaling_groups(AutoScalingGroupNames=[asg_name])["AutoScalingGroups"][0] desired_capacity = asg_info["DesiredCapacity"] @@ -263,7 +395,7 @@ def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]: ddb_update_expression = "SET model_status = :ms, last_modified_date = :lm" ddb_update_values: Dict[str, Any] = { - ":lm": int(datetime.utcnow().timestamp()), + ":lm": int(datetime.now(UTC).timestamp()), } if polling_error := event.get("polling_error", None): @@ -300,3 +432,315 @@ def handle_finish_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]: output_dict["current_model_status"] = ddb_update_values[":ms"] return output_dict + + +def get_ecs_resources_from_stack(stack_name: str) -> tuple[str, str, str]: + """Extract ECS service name, cluster name, and current task definition ARN from CloudFormation.""" + try: + resources = cfn_client.describe_stack_resources(StackName=stack_name)["StackResources"] + + service_arn = None + cluster_arn = None + + for resource in resources: + if resource["ResourceType"] == "AWS::ECS::Service": + service_arn = resource["PhysicalResourceId"] + elif resource["ResourceType"] == "AWS::ECS::Cluster": + cluster_arn = resource["PhysicalResourceId"] + + if not service_arn or not cluster_arn: + raise RuntimeError(f"Could not find ECS service or cluster in stack {stack_name}") + + # Get current task definition from service + service_info = ecs_client.describe_services(cluster=cluster_arn, services=[service_arn])["services"][0] + + current_task_def_arn = service_info["taskDefinition"] + + return service_arn, cluster_arn, current_task_def_arn + + except Exception as e: + logger.error(f"Error getting ECS resources from stack {stack_name}: {str(e)}") + raise RuntimeError(f"Failed to get ECS resources from CloudFormation stack: {str(e)}") + + +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, +) -> str: + """Create new task definition revision with updated environment variables and container config. + + Args: + task_definition_arn: ARN of the current task definition + updated_env_vars: Environment variables to add/update from DynamoDB config + env_vars_to_delete: List of environment variable names to delete + updated_container_config: Updated container configuration from DynamoDB + """ + try: + if env_vars_to_delete is None: + env_vars_to_delete = [] + + # Get current task definition + task_def_response = ecs_client.describe_task_definition(taskDefinition=task_definition_arn) + task_def = task_def_response["taskDefinition"] + + # Create new task definition with updated environment variables + new_task_def = { + "family": task_def["family"], + "volumes": task_def.get("volumes", []), + "containerDefinitions": [], + } + + # Add optional fields only if they have valid values + if task_def.get("taskRoleArn"): + new_task_def["taskRoleArn"] = task_def["taskRoleArn"] + if task_def.get("executionRoleArn"): + new_task_def["executionRoleArn"] = task_def["executionRoleArn"] + if task_def.get("networkMode"): + new_task_def["networkMode"] = task_def["networkMode"] + if task_def.get("requiresCompatibilities"): + new_task_def["requiresCompatibilities"] = task_def["requiresCompatibilities"] + + # Only include cpu and memory if they have valid non-None values + if task_def.get("cpu") is not None: + new_task_def["cpu"] = str(task_def["cpu"]) + if task_def.get("memory") is not None: + new_task_def["memory"] = str(task_def["memory"]) + + # Update container definitions with new environment variables + for container in task_def["containerDefinitions"]: + new_container = container.copy() + + # Start with existing environment variables from the task definition + existing_env = {env["name"]: env["value"] for env in container.get("environment", [])} + logger.info(f"Existing environment variables: {list(existing_env.keys())}") + + # Apply updates/additions from DynamoDB config + existing_env.update(updated_env_vars) + logger.info(f"Environment variables after model_config merge: {list(existing_env.keys())}") + + # Remove deleted variables + for var_name in env_vars_to_delete: + if var_name in existing_env: + del existing_env[var_name] + logger.info(f"Deleted environment variable: {var_name}") + + logger.info(f"Final environment variables: {list(existing_env.keys())}") + + # Set the new environment variables + new_container["environment"] = [{"name": key, "value": value} for key, value in existing_env.items()] + + # Update container configuration if provided + if updated_container_config: + # Update shared memory size + if updated_container_config.get("sharedMemorySize") is not None: + # Ensure linuxParameters exists + if "linuxParameters" not in new_container: + new_container["linuxParameters"] = {} + + new_container["linuxParameters"]["sharedMemorySize"] = int( + updated_container_config["sharedMemorySize"] + ) + logger.info( + f"Updated container shared memory size: {updated_container_config['sharedMemorySize']} MiB" + ) + + # Update health check configuration + health_check_config = updated_container_config.get("healthCheckConfig") + if health_check_config: + # Start with existing health check if it exists + current_health_check = new_container.get("healthCheck", {}) + + # Update individual health check fields, converting Decimal to int + if health_check_config.get("command") is not None: + current_health_check["command"] = health_check_config["command"] + if health_check_config.get("interval") is not None: + current_health_check["interval"] = int(health_check_config["interval"]) + if health_check_config.get("timeout") is not None: + current_health_check["timeout"] = int(health_check_config["timeout"]) + if health_check_config.get("startPeriod") is not None: + current_health_check["startPeriod"] = int(health_check_config["startPeriod"]) + if health_check_config.get("retries") is not None: + current_health_check["retries"] = int(health_check_config["retries"]) + + new_container["healthCheck"] = current_health_check + logger.info(f"Updated container health check: {current_health_check}") + + new_task_def["containerDefinitions"].append(new_container) + + # Register new task definition + response = ecs_client.register_task_definition(**new_task_def) + new_task_def_arn = str(response["taskDefinition"]["taskDefinitionArn"]) + + logger.info(f"Created new task definition: {new_task_def_arn}") + return new_task_def_arn + + except Exception as e: + logger.error(f"Error creating updated task definition: {str(e)}") + raise RuntimeError(f"Failed to create updated task definition: {str(e)}") + + +def update_ecs_service(cluster_arn: str, service_arn: str, task_definition_arn: str) -> None: + """Update ECS service to use new task definition.""" + try: + ecs_client.update_service(cluster=cluster_arn, service=service_arn, taskDefinition=task_definition_arn) + logger.info(f"Updated ECS service {service_arn} to use task definition {task_definition_arn}") + + except Exception as e: + logger.error(f"Error updating ECS service: {str(e)}") + raise RuntimeError(f"Failed to update ECS service: {str(e)}") + + +def handle_ecs_update(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Update ECS task definition with new environment variables and update service. + + This handler will: + 1. Retrieve current task definition from ECS + 2. Create new task definition revision with updated environment variables + 3. Update ECS service to use new task definition + 4. Set up for deployment monitoring + """ + output_dict = deepcopy(event) + model_id = event["model_id"] + + logger.info(f"Starting ECS update for model '{model_id}'") + + try: + # Get current model info from DDB + ddb_item = model_table.get_item(Key={"model_id": model_id})["Item"] + cloudformation_stack_name = ddb_item.get("cloudformation_stack_name") + + if not cloudformation_stack_name: + raise RuntimeError(f"No CloudFormation stack found for model '{model_id}'") + + # Get ECS service and task definition from CloudFormation stack + service_arn, cluster_arn, task_definition_arn = get_ecs_resources_from_stack(cloudformation_stack_name) + + # Get updated environment variables from model config + updated_env_vars = ddb_item["model_config"]["containerConfig"]["environment"] + + # Get environment variables to delete from container metadata (if available) + env_vars_to_delete = [] + if container_metadata := event.get("container_metadata"): + env_vars_to_delete = container_metadata.get("env_vars_to_delete", []) + + logger.info(f"Environment variables to delete: {env_vars_to_delete}") + + # Get updated container config from model config + updated_container_config = ddb_item["model_config"]["containerConfig"] + + # Create new task definition with updated environment variables and container config + new_task_def_arn = create_updated_task_definition( + task_definition_arn, updated_env_vars, env_vars_to_delete, updated_container_config + ) + + # Update ECS service to use new task definition + update_ecs_service(cluster_arn, service_arn, new_task_def_arn) + + # Set up tracking for deployment monitoring + output_dict["new_task_definition_arn"] = new_task_def_arn + output_dict["ecs_service_arn"] = service_arn + output_dict["ecs_cluster_arn"] = cluster_arn + output_dict["remaining_ecs_polls"] = 30 + + logger.info(f"Successfully initiated ECS update for model '{model_id}'") + + except Exception as e: + logger.error(f"ECS update failed for model '{model_id}': {str(e)}") + output_dict["ecs_update_error"] = str(e) + + return output_dict + + +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 + """ + output_dict = deepcopy(event) + model_id = event["model_id"] + + # Check if there was an error in the ECS update step + if event.get("ecs_update_error"): + logger.error(f"ECS update error for model '{model_id}': {event['ecs_update_error']}") + output_dict["should_continue_ecs_polling"] = False + return output_dict + + cluster_name = event["ecs_cluster_arn"] + service_name = event["ecs_service_arn"] + new_task_def_arn = event["new_task_definition_arn"] + + try: + # Get service deployment status + services = ecs_client.describe_services(cluster=cluster_name, services=[service_name])["services"] + + if not services: + raise RuntimeError(f"ECS service {service_name} not found") + + service = services[0] + deployments = service["deployments"] + + # Check if deployment is stable + is_deployment_stable = True + primary_deployment = None + + # Look for our deployment - check both exact match and just the revision number + for deployment in deployments: + task_def = deployment["taskDefinition"] + # Handle both full ARN and family:revision format + if task_def == new_task_def_arn or ( + new_task_def_arn.endswith(task_def.split(":")[-1]) + and task_def.startswith(new_task_def_arn.split(":")[0]) + ): + primary_deployment = deployment + logger.info( + f"Found matching deployment: status={deployment['status']}, " + f"rolloutState={deployment.get('rolloutState', 'N/A')}" + ) + if deployment["status"] != "PRIMARY" or deployment.get("rolloutState") != "COMPLETED": + is_deployment_stable = False + logger.info( + f"Deployment not yet stable: status={deployment['status']}, " + f"rolloutState={deployment.get('rolloutState', 'N/A')}" + ) + else: + logger.info("Deployment is stable and completed") + break + + if not primary_deployment: + logger.warning(f"Could not find deployment for task definition {new_task_def_arn}") + logger.warning(f"Available task definitions: {[d['taskDefinition'] for d in deployments]}") + is_deployment_stable = False + + # Check polling limits + remaining_polls = event.get("remaining_ecs_polls", 30) - 1 + if remaining_polls <= 0 and not is_deployment_stable: + logger.error(f"ECS deployment polling timeout for model '{model_id}'") + output_dict["ecs_polling_error"] = ( + f"ECS deployment did not complete within expected time for model '{model_id}'" + ) + output_dict["should_continue_ecs_polling"] = False + return output_dict + + should_continue_polling = not is_deployment_stable and remaining_polls > 0 + + output_dict["should_continue_ecs_polling"] = should_continue_polling + output_dict["remaining_ecs_polls"] = remaining_polls + + if is_deployment_stable: + logger.info(f"ECS deployment completed successfully for model '{model_id}'") + else: + logger.info(f"ECS deployment still in progress for model '{model_id}', remaining polls: {remaining_polls}") + + except Exception as e: + logger.error(f"Error polling ECS deployment for model '{model_id}': {str(e)}") + output_dict["ecs_polling_error"] = f"Error polling ECS deployment: {str(e)}" + output_dict["should_continue_ecs_polling"] = False + + return output_dict diff --git a/lambda/repository/ingestion_service.py b/lambda/repository/ingestion_service.py index b2a1bb5e1..a405c16c0 100644 --- a/lambda/repository/ingestion_service.py +++ b/lambda/repository/ingestion_service.py @@ -28,7 +28,7 @@ class IngestionAction(str, Enum): class DocumentIngestionService: def _submit_job(self, job: IngestionJob, action: IngestionAction) -> None: # Submit AWS Batch job - batch_client = boto3.client("batch") + batch_client = boto3.client("batch", region_name=os.environ["AWS_REGION"]) response = batch_client.submit_job( jobName=f"document-{action.value}-{job.id}", jobQueue=os.environ["LISA_INGESTION_JOB_QUEUE_NAME"], diff --git a/lambda/repository/lambda_functions.py b/lambda/repository/lambda_functions.py index c5ea1888f..ecf58132c 100644 --- a/lambda/repository/lambda_functions.py +++ b/lambda/repository/lambda_functions.py @@ -28,6 +28,7 @@ from repository.ingestion_service import DocumentIngestionService from repository.rag_document_repo import RagDocumentRepository from repository.vector_store_repo import VectorStoreRepository +from utilities.bedrock_kb import is_bedrock_kb_repository, retrieve_documents from utilities.common_functions import ( admin_only, api_wrapper, @@ -37,6 +38,7 @@ get_username, is_admin, retry_config, + user_has_group_access, ) from utilities.exceptions import HTTPException from utilities.validation import ValidationError @@ -50,6 +52,7 @@ iam_client = boto3.client("iam", region_name, config=retry_config) step_functions_client = boto3.client("stepfunctions", region_name, config=retry_config) ddb_client = boto3.client("dynamodb", region_name, config=retry_config) +bedrock_client = boto3.client("bedrock-agent-runtime", region_name, config=retry_config) s3 = session.client( "s3", region_name, @@ -250,7 +253,7 @@ def list_all(event: dict, context: dict) -> List[Dict[str, Any]]: return [ repo for repo in registered_repositories - if admin_override or user_has_group(user_groups, repo.get("allowedGroups", [])) + if admin_override or user_has_group_access(user_groups, repo.get("allowedGroups", [])) ] @@ -266,18 +269,6 @@ def list_status(event: dict, context: dict) -> dict[str, Any]: return cast(dict, vs_repo.get_repository_status()) -def user_has_group(user_groups: List[str], allowed_groups: List[str]) -> bool: - """Returns if user groups has at least one intersection with allowed groups. - - If allowed groups is empty this will return True. - """ - - if len(allowed_groups) > 0: - return len(set(user_groups).intersection(set(allowed_groups))) > 0 - else: - return True - - @api_wrapper def similarity_search(event: dict, context: dict) -> Dict[str, Any]: """Return documents matching the query. @@ -311,13 +302,33 @@ def similarity_search(event: dict, context: dict) -> Dict[str, Any]: id_token = get_id_token(event) - embeddings = _get_embeddings(model_name=model_name, id_token=id_token) - vs = get_vector_store_client(repository_id, index=model_name, embeddings=embeddings) - docs = vs.similarity_search( - query, - k=top_k, - ) - doc_content = [{"Document": {"page_content": doc.page_content, "metadata": doc.metadata}} for doc in docs] + docs: List[Dict[str, Any]] = [] + if is_bedrock_kb_repository(repository): + docs = retrieve_documents( + bedrock_runtime_client=bedrock_client, + repository=repository, + query=query, + top_k=int(top_k), + repository_id=repository_id, + ) + else: + embeddings = _get_embeddings(model_name=model_name, id_token=id_token) + vs = get_vector_store_client(repository_id, index=model_name, embeddings=embeddings) + results = vs.similarity_search( + query, + k=top_k, + ) + docs = [{"page_content": r.page_content, "metadata": r.metadata} for r in results] + + doc_content = [ + { + "Document": { + "page_content": doc.get("page_content", ""), + "metadata": doc.get("metadata", {}), + } + } + for doc in docs + ] doc_return = {"docs": doc_content} logger.info(f"Returning: {doc_return}") @@ -328,7 +339,7 @@ def _ensure_repository_access(event: dict[str, Any], repository: dict[str, Any]) """Ensures a user has access to the repository or else raises an HTTPException""" if is_admin(event) is False: user_groups = json.loads(event["requestContext"]["authorizer"]["groups"]) or [] - if not user_has_group(user_groups, repository.get("allowedGroups", [])): + if not user_has_group_access(user_groups, repository.get("allowedGroups", [])): raise HTTPException(status_code=403, message="User does not have permission to access this repository") @@ -486,9 +497,9 @@ def download_document(event: dict, context: dict) -> str: document_id = path_params.get("documentId") _ensure_repository_access(event, vs_repo.find_repository_by_id(repository_id)) - doc = doc_repo.find_by_id(repository_id=repository_id, document_id=document_id) + doc = doc_repo.find_by_id(document_id=document_id) - source = doc.get("source") + source = doc.source bucket, key = source.replace("s3://", "").split("/", 1) url: str = s3.generate_presigned_url( diff --git a/lambda/repository/pipeline_delete_documents.py b/lambda/repository/pipeline_delete_documents.py index 9a031fe13..452ea7835 100644 --- a/lambda/repository/pipeline_delete_documents.py +++ b/lambda/repository/pipeline_delete_documents.py @@ -20,16 +20,20 @@ from models.domain_objects import IngestionJob, IngestionStatus, IngestionType from repository.ingestion_job_repo import IngestionJobRepository from repository.pipeline_ingest_documents import remove_document_from_vectorstore +from repository.vector_store_repo import VectorStoreRepository +from utilities.bedrock_kb import delete_document_from_kb, is_bedrock_kb_repository from utilities.common_functions import retry_config from .lambda_functions import DocumentIngestionService, RagDocumentRepository ingestion_service = DocumentIngestionService() ingestion_job_repository = IngestionJobRepository() +vs_repo = VectorStoreRepository() logger = logging.getLogger(__name__) s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"], config=retry_config) +bedrock_agent = boto3.client("bedrock-agent", region_name=os.environ["AWS_REGION"], config=retry_config) rag_document_repository = RagDocumentRepository(os.environ["RAG_DOCUMENT_TABLE"], os.environ["RAG_SUB_DOCUMENT_TABLE"]) @@ -42,7 +46,16 @@ def pipeline_delete(job: IngestionJob) -> None: if rag_document: # Actually remove from vector store - remove_document_from_vectorstore(rag_document) + repository = vs_repo.find_repository_by_id(job.repository_id) + if is_bedrock_kb_repository(repository): + delete_document_from_kb( + s3_client=s3, + bedrock_agent_client=bedrock_agent, + job=job, + repository=repository, + ) + else: + remove_document_from_vectorstore(rag_document) # Remove from DDB rag_document_repository.delete_by_id(rag_document.document_id) diff --git a/lambda/repository/pipeline_ingest_documents.py b/lambda/repository/pipeline_ingest_documents.py index a2ff9828c..d53a7b0b6 100644 --- a/lambda/repository/pipeline_ingest_documents.py +++ b/lambda/repository/pipeline_ingest_documents.py @@ -23,6 +23,8 @@ from models.domain_objects import FixedChunkingStrategy, IngestionJob, IngestionStatus, IngestionType, RagDocument from repository.ingestion_job_repo import IngestionJobRepository from repository.lambda_functions import RagDocumentRepository +from repository.vector_store_repo import VectorStoreRepository +from utilities.bedrock_kb import ingest_document_to_kb, is_bedrock_kb_repository from utilities.common_functions import get_username, retry_config from utilities.file_processing import generate_chunks from utilities.vector_store import get_vector_store_client @@ -33,10 +35,12 @@ ingestion_job_table = dynamodb.Table(os.environ["LISA_INGESTION_JOB_TABLE_NAME"]) ingestion_service = DocumentIngestionService() ingestion_job_repository = IngestionJobRepository() +vs_repo = VectorStoreRepository() logger = logging.getLogger(__name__) session = boto3.Session() s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"], config=retry_config) +bedrock_agent = boto3.client("bedrock-agent", region_name=os.environ["AWS_REGION"], config=retry_config) ssm_client = boto3.client("ssm", region_name=os.environ["AWS_REGION"], config=retry_config) rag_document_repository = RagDocumentRepository(os.environ["RAG_DOCUMENT_TABLE"], os.environ["RAG_SUB_DOCUMENT_TABLE"]) @@ -44,9 +48,19 @@ def pipeline_ingest(job: IngestionJob) -> None: try: # chunk and save chunks in vector store - documents = generate_chunks(job) - texts, metadatas = prepare_chunks(documents, job.repository_id) - all_ids = store_chunks_in_vectorstore(texts, metadatas, job.repository_id, job.collection_id) + repository = vs_repo.find_repository_by_id(job.repository_id) + all_ids = [] + if is_bedrock_kb_repository(repository): + ingest_document_to_kb( + s3_client=s3, + bedrock_agent_client=bedrock_agent, + job=job, + repository=repository, + ) + else: + documents = generate_chunks(job) + texts, metadatas = prepare_chunks(documents, job.repository_id) + all_ids = store_chunks_in_vectorstore(texts, metadatas, job.repository_id, job.collection_id) # remove old for rag_document in rag_document_repository.find_by_source( @@ -56,7 +70,8 @@ def pipeline_ingest(job: IngestionJob) -> None: if prev_job: ingestion_job_repository.update_status(prev_job, IngestionStatus.DELETE_IN_PROGRESS) - remove_document_from_vectorstore(rag_document) + if not is_bedrock_kb_repository(repository): + remove_document_from_vectorstore(rag_document) rag_document_repository.delete_by_id(rag_document.document_id) if prev_job: diff --git a/lambda/repository/rag_document_repo.py b/lambda/repository/rag_document_repo.py index 19d36d60b..bf3c34bd8 100644 --- a/lambda/repository/rag_document_repo.py +++ b/lambda/repository/rag_document_repo.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import logging +import os from typing import Generator, Optional import boto3 @@ -32,7 +33,7 @@ def __init__(self, document_table_name: str, sub_document_table_name: str): dynamodb = boto3.resource("dynamodb") self.doc_table = dynamodb.Table(document_table_name) self.subdoc_table = dynamodb.Table(sub_document_table_name) - self.s3_client = boto3.client("s3") + self.s3_client = boto3.client("s3", region_name=os.environ["AWS_REGION"]) self.vs_repo = VectorStoreRepository() def delete_by_id(self, document_id: str) -> None: diff --git a/lambda/repository/state_machine/cleanup_repo_docs.py b/lambda/repository/state_machine/cleanup_repo_docs.py index 2c302bddb..23f55ac8b 100644 --- a/lambda/repository/state_machine/cleanup_repo_docs.py +++ b/lambda/repository/state_machine/cleanup_repo_docs.py @@ -16,6 +16,7 @@ import os from typing import Any, Dict +from pydantic import BaseModel from repository.rag_document_repo import RagDocumentRepository logger = logging.getLogger(__name__) @@ -42,4 +43,11 @@ def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any] | Any: doc_repo.delete_s3_docs(repository_id=repository_id, docs=docs) - return {"repositoryId": repository_id, "stackName": stack_name, "documents": docs, "lastEvaluated": last_evaluated} + # 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 docs] + return { + "repositoryId": repository_id, + "stackName": stack_name, + "documents": serializable_docs, + "lastEvaluated": last_evaluated, + } diff --git a/lambda/repository/state_machine/list_modified_objects.py b/lambda/repository/state_machine/list_modified_objects.py index 17ad6003a..9321f401e 100644 --- a/lambda/repository/state_machine/list_modified_objects.py +++ b/lambda/repository/state_machine/list_modified_objects.py @@ -15,6 +15,7 @@ """Lambda handlers for ListModifiedObjects state machine.""" import logging +import os from datetime import datetime, timedelta, timezone from typing import Any, Dict @@ -112,7 +113,7 @@ def handle_list_modified_objects(event: Dict[str, Any], context: Any) -> Dict[st validate_bucket_prefix(bucket, prefix) # Initialize S3 client - s3_client = boto3.client("s3") + s3_client = boto3.client("s3", region_name=os.environ["AWS_REGION"]) # Calculate timestamp for 24 hours ago twenty_four_hours_ago = datetime.now(timezone.utc) - timedelta(hours=24) diff --git a/lambda/session/lambda_functions.py b/lambda/session/lambda_functions.py index 4ba8a537d..56cc3d803 100644 --- a/lambda/session/lambda_functions.py +++ b/lambda/session/lambda_functions.py @@ -115,15 +115,31 @@ def _generate_presigned_image_url(key: str) -> str: def _map_session(session: dict) -> Dict[str, Any]: return { - "sessionId": session["sessionId"], - "firstHumanMessage": next( - (msg["content"] for msg in session.get("history", []) if msg.get("type") == "human"), "" - ), + "sessionId": session.get("sessionId", None), + "name": session.get("name", None), + "firstHumanMessage": _find_first_human_message(session), "startTime": session.get("startTime", None), "createTime": session.get("createTime", None), } +def _find_first_human_message(session: dict) -> str: + for msg in session.get("history", []): + if msg.get("type") == "human": + content = msg.get("content") + if isinstance(content, str): + return content + elif isinstance(content, list): + for item in content: + if isinstance(item, dict): + text: str = item.get("text", "") + if text and not text.startswith("File context:"): + return text + else: + logger.warning(f"Unhandled human message content in session {session['sessionId']}") + return "" + + @api_wrapper def list_sessions(event: dict, context: dict) -> List[Dict[str, Any]]: """List sessions by user ID from DynamoDB.""" @@ -239,6 +255,32 @@ def attach_image_to_session(event: dict, context: dict) -> dict: return {"statusCode": 400, "body": json.dumps({"error": str(e)})} +@api_wrapper +def rename_session(event: dict, context: dict) -> dict: + """Update session name in DynamoDB.""" + try: + user_id = get_username(event) + session_id = get_session_id(event) + + try: + body = json.loads(event.get("body", {}), parse_float=Decimal) + 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"})} + + table.update_item( + Key={"sessionId": session_id, "userId": user_id}, + UpdateExpression="SET #name = :name", + ExpressionAttributeNames={"#name": "name"}, + ExpressionAttributeValues={":name": body.get("name")}, + ) + return {"statusCode": 200, "body": json.dumps({"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: """Append the message to the record in DynamoDB.""" @@ -279,16 +321,18 @@ def put_session(event: dict, context: dict) -> dict: table.update_item( Key={"sessionId": session_id, "userId": user_id}, - UpdateExpression="SET #history = :history, #configuration = :configuration, #startTime = :startTime, " - + "#createTime = if_not_exists(#createTime, :createTime)", + UpdateExpression="SET #history = :history, #name = :name, #configuration = :configuration, " + + "#startTime = :startTime, #createTime = if_not_exists(#createTime, :createTime)", ExpressionAttributeNames={ "#history": "history", + "#name": "name", "#configuration": "configuration", "#startTime": "startTime", "#createTime": "createTime", }, ExpressionAttributeValues={ ":history": messages, + ":name": body.get("name", None), ":configuration": body.get("configuration", None), ":startTime": datetime.now().isoformat(), ":createTime": datetime.now().isoformat(), diff --git a/lambda/utilities/bedrock_kb.py b/lambda/utilities/bedrock_kb.py new file mode 100644 index 000000000..b5164a526 --- /dev/null +++ b/lambda/utilities/bedrock_kb.py @@ -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. + +"""Utilities for handling Bedrock Knowledge Base specific operations. + +This module centralizes logic related to repositories of type +"bedrock_knowledge_base" so that call sites can remain concise. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List + +BEDROCK_KB_TYPE = "bedrock_knowledge_base" + + +def is_bedrock_kb_repository(repository: Dict[str, Any]) -> Any: + """Return True if the repository is a Bedrock Knowledge Base.""" + return bool(repository.get("type", "") == BEDROCK_KB_TYPE) + + +def retrieve_documents( + bedrock_runtime_client: Any, + repository: Dict[str, Any], + query: str, + top_k: int, + repository_id: str, +) -> List[Dict[str, Any]]: + """Retrieve documents from Bedrock Knowledge Base. + + Args: + bedrock_runtime_client: boto3 bedrock-agent-runtime client + repository: Repository configuration dictionary + query: Text query to search + top_k: Number of results to return + repository_id: Repository identifier to include in metadata + + Returns: + List of documents in the format expected by callers + """ + bedrock_config = repository.get("bedrockKnowledgeBaseConfig", {}) + + response = bedrock_runtime_client.retrieve( + knowledgeBaseId=bedrock_config.get("bedrockKnowledgeBaseId", None), + retrievalQuery={"text": query}, + retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": int(top_k)}}, + ) + + docs: List[Dict[str, Any]] = [] + for doc in response.get("retrievalResults", []): + uri = (doc.get("location", {}) or {}).get("s3Location", {}).get("uri") + name = uri.split("/")[-1] if uri else None + docs.append( + { + "page_content": (doc.get("content", {}) or {}).get("text", ""), + "metadata": { + "source": uri, + "name": name, + "repository_id": repository_id, + }, + } + ) + + return docs + + +def ingest_document_to_kb( + s3_client: Any, + bedrock_agent_client: Any, + job: Any, + repository: Dict[str, Any], +) -> None: + """Copy the source object into the KB datasource bucket and trigger ingestion.""" + bedrock_config = repository.get("bedrockKnowledgeBaseConfig", {}) + + source_bucket = job.s3_path.split("/")[2] + s3_client.copy_object( + CopySource={"Bucket": source_bucket, "Key": job.s3_path.split(source_bucket + "/")[1]}, + Bucket=bedrock_config.get("bedrockKnowledgeDatasourceS3Bucket", None), + Key=os.path.basename(job.s3_path), + ) + bedrock_agent_client.start_ingestion_job( + knowledgeBaseId=bedrock_config.get("bedrockKnowledgeBaseId", None), + dataSourceId=bedrock_config.get("bedrockKnowledgeDatasourceId", None), + ) + + +def delete_document_from_kb( + s3_client: Any, + bedrock_agent_client: Any, + job: 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", {}) + + s3_client.delete_object( + Bucket=bedrock_config.get("bedrockKnowledgeDatasourceS3Bucket", None), + Key=os.path.basename(job.s3_path), + ) + bedrock_agent_client.start_ingestion_job( + knowledgeBaseId=bedrock_config.get("bedrockKnowledgeBaseId", None), + dataSourceId=bedrock_config.get("bedrockKnowledgeDatasourceId", None), + ) diff --git a/lambda/utilities/common_functions.py b/lambda/utilities/common_functions.py index 5ddfadddc..43746bc13 100644 --- a/lambda/utilities/common_functions.py +++ b/lambda/utilities/common_functions.py @@ -459,7 +459,7 @@ def _get_lambda_role_arn() -> str: str The full ARN of the Lambda execution role """ - sts = boto3.client("sts") + 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 @@ -480,3 +480,22 @@ def get_lambda_role_name() -> str: def get_item(response: Any) -> Any: items = response.get("Items", []) return items[0] if items else None + + +def user_has_group_access(user_groups: List[str], allowed_groups: List[str]) -> bool: + """ + Check if user has access based on group membership. + + Args: + user_groups: List of groups the user belongs to + allowed_groups: List of groups allowed to access the resource + + Returns: + True if user has access (either no restrictions or user has required group) + """ + # Public resource (no group restrictions) + if not allowed_groups: + return True + + # Check if user has at least one matching group + return len(set(user_groups).intersection(set(allowed_groups))) > 0 diff --git a/lambda/utilities/db_setup_iam_auth.py b/lambda/utilities/db_setup_iam_auth.py index da6b8acad..07a65cc7e 100644 --- a/lambda/utilities/db_setup_iam_auth.py +++ b/lambda/utilities/db_setup_iam_auth.py @@ -22,7 +22,7 @@ def get_db_credentials(secret_arn: str) -> Any: """Retrieve database credentials from Secrets Manager""" - client = boto3.client("secretsmanager") + client = boto3.client("secretsmanager", region_name=os.environ["AWS_REGION"]) try: response = client.get_secret_value(SecretId=secret_arn) diff --git a/lambda/utilities/file_processing.py b/lambda/utilities/file_processing.py index 252cb9730..f2f9c6857 100644 --- a/lambda/utilities/file_processing.py +++ b/lambda/utilities/file_processing.py @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) session = boto3.Session() -s3 = session.client("s3") +s3 = session.client("s3", region_name=os.environ["AWS_REGION"]) def _get_metadata(s3_uri: str, name: str) -> dict: diff --git a/lib/api-base/ecsCluster.ts b/lib/api-base/ecsCluster.ts index 48fe9a347..1dfc4b1e9 100644 --- a/lib/api-base/ecsCluster.ts +++ b/lib/api-base/ecsCluster.ts @@ -17,6 +17,7 @@ // ECS Cluster Construct. import { Duration, RemovalPolicy } from 'aws-cdk-lib'; import { BlockDeviceVolume, GroupMetrics, Monitoring } from 'aws-cdk-lib/aws-autoscaling'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; import { Metric, Stats } from 'aws-cdk-lib/aws-cloudwatch'; import { InstanceType, ISecurityGroup } from 'aws-cdk-lib/aws-ec2'; import { @@ -41,7 +42,7 @@ import { BaseApplicationListenerProps, SslPolicy, } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; -import { IRole, ManagedPolicy, Role } from 'aws-cdk-lib/aws-iam'; +import { Effect, IRole, ManagedPolicy, PolicyStatement, Role } from 'aws-cdk-lib/aws-iam'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; import { Construct } from 'constructs'; @@ -160,6 +161,8 @@ export class ECSCluster extends Construct { mountPoints.push(nvmeMountPoint); } + // Add CloudWatch Logs permissions to EC2 instance role for ECS logging + autoScalingGroup.role.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName('CloudWatchLogsFullAccess')); // Add permissions to use SSM in dev environment for EC2 debugging purposes only if (config.deploymentStage === 'dev') { autoScalingGroup.role.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMFullAccess')); @@ -184,6 +187,13 @@ export class ECSCluster extends Construct { environment.CURL_CA_BUNDLE = config.certificateAuthorityBundle; } + // Create CloudWatch log group with explicit retention + const logGroup = new LogGroup(this, createCdkId([ecsConfig.identifier, 'LogGroup']), { + logGroupName: `/aws/ecs/${config.deploymentName}-${ecsConfig.identifier}`, + retention: RetentionDays.ONE_WEEK, + removalPolicy: config.removalPolicy + }); + // Retrieve execution role if it has been overridden const executionRole = config.roles ? Role.fromRoleArn( this, @@ -204,6 +214,24 @@ export class ECSCluster extends Construct { ...(executionRole && { executionRole }), }); + // Grant CloudWatch logs permissions to both task role and execution role + logGroup.grantWrite(taskRole); + if (executionRole) { + logGroup.grantWrite(executionRole); + } else { + // If no custom execution role, ensure the default execution role has CloudWatch permissions + // This is critical for log stream creation during container startup + taskDefinition.addToExecutionRolePolicy(new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + 'logs:DescribeLogStreams' + ], + resources: [logGroup.logGroupArn, `${logGroup.logGroupArn}:*`] + })); + } // Add container to task definition const containerHealthCheckConfig = ecsConfig.containerConfig.healthCheckConfig; const containerHealthCheck: HealthCheck = { @@ -227,7 +255,10 @@ export class ECSCluster extends Construct { containerName: createCdkId([config.deploymentName, ecsConfig.identifier], 32, 2), image, environment, - logging: LogDriver.awsLogs({ streamPrefix: ecsConfig.identifier }), + logging: LogDriver.awsLogs({ + logGroup: logGroup, + streamPrefix: ecsConfig.identifier + }), gpuCount: Ec2Metadata.get(ecsConfig.instanceType).gpuCount, memoryReservationMiB: Ec2Metadata.get(ecsConfig.instanceType).memory - ecsConfig.containerMemoryBuffer, portMappings: [{ hostPort: 80, containerPort: 8080, protocol: Protocol.TCP }], diff --git a/lib/chat/api/configuration.ts b/lib/chat/api/configuration.ts index a0bb529a4..2ff976940 100644 --- a/lib/chat/api/configuration.ts +++ b/lib/chat/api/configuration.ts @@ -109,6 +109,7 @@ export class ConfigurationApi extends Construct { 'showRagLibrary': {'BOOL': 'True'}, 'showPromptTemplateLibrary': {'BOOL': 'True'}, 'mcpConnections': {'BOOL': 'True'}, + 'modelLibrary': {'BOOL': 'True'}, }}, 'systemBanner': {'M': { 'isEnabled': {'BOOL': 'False'}, diff --git a/lib/chat/api/session.ts b/lib/chat/api/session.ts index 5902feed9..3de4fa044 100644 --- a/lib/chat/api/session.ts +++ b/lib/chat/api/session.ts @@ -191,6 +191,13 @@ export class SessionApi extends Construct { path: 'session/{sessionId}', method: 'PUT', environment: env, + },{ + name: 'rename_session', + resource: 'session', + description: 'Updates session name', + path: 'session/{sessionId}/name', + method: 'PUT', + environment: env, }, { name: 'attach_image_to_session', diff --git a/lib/core/iam/rag.json b/lib/core/iam/rag.json index 18a1d21ff..3b99a8bec 100644 --- a/lib/core/iam/rag.json +++ b/lib/core/iam/rag.json @@ -53,6 +53,22 @@ "iam:GetServerCertificate" ], "Resource": "arn:${AWS::Partition}:iam::${AWS::AccountId}:server-certificate/*" - } + }, + { + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:DeleteObject" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "bedrock:StartIngestionJob", + "bedrock:Retrieve" + ], + "Resource": "*" + } ] } diff --git a/lib/docs/package.json b/lib/docs/package.json index fc975b442..bbb871314 100644 --- a/lib/docs/package.json +++ b/lib/docs/package.json @@ -15,6 +15,6 @@ "author": "", "license": "Apache-2.0", "devDependencies": { - "vitepress": "^1.6.3" + "vitepress": "^1.6.4" } } diff --git a/lib/models/docker-image-builder.ts b/lib/models/docker-image-builder.ts index 6e285500c..06e16ec93 100644 --- a/lib/models/docker-image-builder.ts +++ b/lib/models/docker-image-builder.ts @@ -151,6 +151,13 @@ export class DockerImageBuilder extends Construct { ], resources: ['*'], }), + new PolicyStatement({ + actions: [ + 'logs:CreateLogStream', + 'logs:PutLogEvents', + ], + resources: ['*'], + }), ], }); diff --git a/lib/models/model-api.ts b/lib/models/model-api.ts index 632937b52..d91b14fc5 100644 --- a/lib/models/model-api.ts +++ b/lib/models/model-api.ts @@ -437,6 +437,32 @@ export class ModelsApi extends Construct { ], resources: ['*'], // We do not know the ASG names in advance }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'ecs:DescribeTaskDefinition', + 'ecs:RegisterTaskDefinition', + 'ecs:UpdateService', + 'ecs:DescribeServices', + ], + resources: ['*'], // ECS resources are dynamic and created by CloudFormation + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'cloudformation:DescribeStackResources', + ], + resources: [ + 'arn:*:cloudformation:*:*:stack/*', + ], + }), + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'iam:PassRole', + ], + resources: ['*'], + }), ] }), } diff --git a/lib/models/state-machine/update-model.ts b/lib/models/state-machine/update-model.ts index a94621634..1c2b3c0dc 100644 --- a/lib/models/state-machine/update-model.ts +++ b/lib/models/state-machine/update-model.ts @@ -113,6 +113,40 @@ export class UpdateModelStateMachine extends Construct { outputPath: OUTPUT_PATH, }); + const handleEcsUpdate = new LambdaInvoke(this, 'HandleEcsUpdate', { + lambdaFunction: new Function(this, 'HandleEcsUpdateFunc', { + runtime: getDefaultRuntime(), + handler: 'models.state_machine.update_model.handle_ecs_update', + 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 handlePollEcsDeployment = new LambdaInvoke(this, 'HandlePollEcsDeployment', { + lambdaFunction: new Function(this, 'HandlePollEcsDeploymentFunc', { + runtime: getDefaultRuntime(), + handler: 'models.state_machine.update_model.handle_poll_ecs_deployment', + 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 handleFinishUpdate = new LambdaInvoke(this, 'HandleFinishUpdate', { lambdaFunction: new Function(this, 'HandleFinishUpdateFunc', { runtime: getDefaultRuntime(), @@ -134,8 +168,10 @@ export class UpdateModelStateMachine extends Construct { const successState = new Succeed(this, 'UpdateSuccess'); // choice states + const hasEcsUpdateChoice = new Choice(this, 'HasEcsUpdateChoice'); const hasCapacityUpdateChoice = new Choice(this, 'HasCapacityUpdateChoice'); const pollAsgChoice = new Choice(this, 'PollAsgChoice'); + const pollEcsDeploymentChoice = new Choice(this, 'PollEcsDeploymentChoice'); // wait states const waitBeforePollAsg = new Wait(this, 'WaitBeforePollAsg', { @@ -144,9 +180,26 @@ export class UpdateModelStateMachine extends Construct { const waitBeforeModelAvailable = new Wait(this, 'WaitBeforeModelAvailable', { time: WaitTime.secondsPath('$.model_warmup_seconds'), }); + const waitBeforePollEcsDeployment = new Wait(this, 'WaitBeforePollEcsDeployment', { + time: POLLING_TIMEOUT + }); // State Machine definition - handleJobIntake.next(hasCapacityUpdateChoice); + handleJobIntake.next(hasEcsUpdateChoice); + + // ECS update flow + hasEcsUpdateChoice + .when(Condition.booleanEquals('$.needs_ecs_update', true), handleEcsUpdate) + .otherwise(hasCapacityUpdateChoice); + + handleEcsUpdate.next(handlePollEcsDeployment); + handlePollEcsDeployment.next(pollEcsDeploymentChoice); + pollEcsDeploymentChoice + .when(Condition.booleanEquals('$.should_continue_ecs_polling', true), waitBeforePollEcsDeployment) + .otherwise(hasCapacityUpdateChoice); + waitBeforePollEcsDeployment.next(handlePollEcsDeployment); + + // Existing capacity update flow hasCapacityUpdateChoice .when(Condition.booleanEquals('$.has_capacity_update', true), handlePollCapacity) .otherwise(handleFinishUpdate); diff --git a/lib/rag/vector-store/state_machine/create-store.ts b/lib/rag/vector-store/state_machine/create-store.ts index 02765c19c..8b2d30131 100644 --- a/lib/rag/vector-store/state_machine/create-store.ts +++ b/lib/rag/vector-store/state_machine/create-store.ts @@ -53,6 +53,8 @@ export class CreateStoreStateMachine extends Construct { resultPath: '$.dynamoResult', }); + const createVectorStoreInfraChoice = new sfn.Choice(this, 'CreateVectorStoreInfraChoice'); + // Task to invoke a Lambda function to deploy the vector store const deployVectorStore = new tasks.LambdaInvoke(this, 'DeployVectorStore', { lambdaFunction: lambda.Function.fromFunctionArn(this, 'VectorStoreDeployer', vectorStoreDeployerFnArn), @@ -85,6 +87,17 @@ export class CreateStoreStateMachine extends Construct { time: sfn.WaitTime.duration(Duration.seconds(30)), }); + // Task to update the status of the vector store entry to 'COMPLETED' on successful deployment + const updateBedrockKBSuccess = new tasks.DynamoUpdateItem(this, 'UpdateBedrockKBSuccess', { + table: vectorStoreConfigTable, + key: { repositoryId: tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt('$.body.ragConfig.repositoryId')) }, + updateExpression: 'SET #status = :status', + expressionAttributeNames: { '#status': 'status' }, + expressionAttributeValues: { + ':status': tasks.DynamoAttributeValue.fromString('CREATE_COMPLETE') + }, + }); + // Task to update the status of the vector store entry to 'COMPLETED' on successful deployment const updateSuccessStatus = new tasks.DynamoUpdateItem(this, 'UpdateSuccessStatus', { table: vectorStoreConfigTable, @@ -93,7 +106,7 @@ export class CreateStoreStateMachine extends Construct { expressionAttributeNames: { '#status': 'status', '#stackName': 'stackName' }, expressionAttributeValues: { ':status': tasks.DynamoAttributeValue.fromString('CREATE_COMPLETE'), - ':stackName': tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt('$.deployResult.stackName')) + ':stackName': tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt('$.deployResult.stackName') ?? '') }, }); @@ -111,34 +124,37 @@ export class CreateStoreStateMachine extends Construct { // Define the sequence of tasks and conditions in the state machine const definition = createVectorStoreEntry - .next(deployVectorStore.addCatch(updateFailureStatus)) - .next( - checkDeploymentStatus.next( - new sfn.Choice(this, 'DeploymentComplete?') - .when( - sfn.Condition.and( - sfn.Condition.isPresent('$.deployResult.status'), - sfn.Condition.or( - sfn.Condition.stringEquals('$.deployResult.status', 'CREATE_IN_PROGRESS'), - sfn.Condition.stringEquals('$.deployResult.status', 'UPDATE_IN_PROGRESS'), - sfn.Condition.stringEquals('$.deployResult.status', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS'), - ), - ), - wait.next(checkDeploymentStatus) - ) - .when( - sfn.Condition.and( - sfn.Condition.isPresent('$.deployResult.status'), - sfn.Condition.or( - sfn.Condition.stringEquals('$.deployResult.status', 'CREATE_COMPLETE'), - sfn.Condition.stringEquals('$.deployResult.status', 'UPDATE_COMPLETE'), - ), - ), - updateSuccessStatus + .next(createVectorStoreInfraChoice + .when(sfn.Condition.and(sfn.Condition.stringEquals('$.body.ragConfig.type', 'bedrock_knowledge_base'), + sfn.Condition.isNotPresent('$.body.ragConfig.pipelines[0]')), updateBedrockKBSuccess) + .otherwise(deployVectorStore.addCatch(updateFailureStatus) + .next( + checkDeploymentStatus.next( + new sfn.Choice(this, 'DeploymentComplete?') + .when( + sfn.Condition.and( + sfn.Condition.isPresent('$.deployResult.status'), + sfn.Condition.or( + sfn.Condition.stringEquals('$.deployResult.status', 'CREATE_IN_PROGRESS'), + sfn.Condition.stringEquals('$.deployResult.status', 'UPDATE_IN_PROGRESS'), + sfn.Condition.stringEquals('$.deployResult.status', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS'), + ), + ), + wait.next(checkDeploymentStatus) + ) + .when( + sfn.Condition.and( + sfn.Condition.isPresent('$.deployResult.status'), + sfn.Condition.or( + sfn.Condition.stringEquals('$.deployResult.status', 'CREATE_COMPLETE'), + sfn.Condition.stringEquals('$.deployResult.status', 'UPDATE_COMPLETE'), + ), + ), + updateSuccessStatus + ) + .otherwise(updateFailureStatus) ) - .otherwise(updateFailureStatus) - ) - ); + ))); // Create a new state machine using the definition and roles specified this.stateMachine = new sfn.StateMachine(this, 'CreateStoreStateMachine', { diff --git a/lib/rag/vector-store/state_machine/delete-store.ts b/lib/rag/vector-store/state_machine/delete-store.ts index 9ca543530..38135d15c 100644 --- a/lib/rag/vector-store/state_machine/delete-store.ts +++ b/lib/rag/vector-store/state_machine/delete-store.ts @@ -125,6 +125,17 @@ export class DeleteStoreStateMachine extends Construct { resultPath: '$.updateDynamoDbResult', }); + const handleCleanupBedrockKnowledgeBase = new Choice(this, 'BedrockKnowledgeBase') + .when(sfn.Condition.and(sfn.Condition.stringEquals('$.ddbResult.Item.config.M.type.S', 'bedrock_knowledge_base'), + sfn.Condition.isNull('$.stackName')), deleteDynamoDbEntry) + .otherwise(deleteStack); + + const getRepoFromDdb = new tasks.DynamoGetItem(this, 'GetRepoFromDdb', { + table: ragVectorStoreTable, + key: { repositoryId: tasks.DynamoAttributeValue.fromString(sfn.JsonPath.stringAt('$.repositoryId')) }, + resultPath: '$.ddbResult', + }).next(handleCleanupBedrockKnowledgeBase); + const lambdaPath = config.lambdaPath || LAMBDA_PATH; const cleanupDocsFunc = new Function(this, 'CleanupRepositoryDocsFunc', { runtime: getDefaultRuntime(), @@ -148,7 +159,7 @@ export class DeleteStoreStateMachine extends Construct { }), outputPath: OUTPUT_PATH, })) - .otherwise(deleteStack); + .otherwise(getRepoFromDdb); const cleanupDocs = new LambdaInvoke(this, 'CleanupRepositoryDocs', { lambdaFunction: cleanupDocsFunc, @@ -161,7 +172,7 @@ export class DeleteStoreStateMachine extends Construct { const shouldSkipCleanup = new Choice(this, 'ShouldSkipCleanup') .when(Condition.and(Condition.isPresent('$.skipDocumentRemoval'), Condition.booleanEquals('$.skipDocumentRemoval', true)), - deleteStack) + handleCleanupBedrockKnowledgeBase) .otherwise(cleanupDocs.next(hasMoreDocs)); deleteStack.next(checkStackStatus.addCatch(deleteDynamoDbEntry, { diff --git a/lib/schema/configSchema.ts b/lib/schema/configSchema.ts index b175ebc26..a7b91d1b8 100644 --- a/lib/schema/configSchema.ts +++ b/lib/schema/configSchema.ts @@ -342,6 +342,13 @@ export class Ec2Metadata { maxThroughput: 400, vCpus: 96, }, + 'p4de.24xlarge': { + memory: 1152 * 1000, + gpuCount: 8, + nvmePath: '/dev/nvme1n1', + maxThroughput: 400, + vCpus: 96, + }, 'p5.48xlarge': { memory: 2000 * 1000, gpuCount: 8, diff --git a/lib/schema/ragSchema.ts b/lib/schema/ragSchema.ts index fe16d27da..c4986ba59 100644 --- a/lib/schema/ragSchema.ts +++ b/lib/schema/ragSchema.ts @@ -22,8 +22,17 @@ import { EbsDeviceVolumeType } from './cdk'; export enum RagRepositoryType { OPENSEARCH = 'opensearch', PGVECTOR = 'pgvector', + BEDROCK_KNOWLEDGE_BASE = 'bedrock_knowledge_base', } +export const BedrockKnowledgeBaseInstanceConfig = z.object({ + bedrockKnowledgeBaseName: z.string().describe('The name of the Bedrock Knowledge Base.'), + bedrockKnowledgeBaseId: z.string().describe('The id of the Bedrock Knowledge Base.'), + bedrockKnowledgeDatasourceName: z.string().describe('The name of the Bedrock Knowledge Datasource.'), + bedrockKnowledgeDatasourceId: z.string().describe('The id of the Bedrock Knowledge Datasource.'), + bedrockKnowledgeDatasourceS3Bucket: z.string().describe('The S3 bucket of the Bedrock Knowledge Base.'), +}); + export const OpenSearchNewClusterConfig = z.object({ dataNodes: z.number().min(1).default(2).describe('The number of data nodes (instances) to use in the Amazon OpenSearch Service domain.'), dataNodeInstanceType: z.string().default('r7g.large.search').describe('The instance type for your data nodes'), @@ -78,6 +87,8 @@ export const RdsInstanceConfig = z.object({ export type RdsConfig = z.infer; +export type BedrockKnowledgeBaseConfig = z.infer; + export const RagRepositoryConfigSchema = z .object({ repositoryId: z.string() @@ -89,6 +100,7 @@ export const RagRepositoryConfigSchema = z type: z.nativeEnum(RagRepositoryType).describe('The vector store designated for this repository.'), opensearchConfig: z.union([OpenSearchExistingClusterConfig, OpenSearchNewClusterConfig]).optional(), rdsConfig: RdsInstanceConfig.optional(), + bedrockKnowledgeBaseConfig: BedrockKnowledgeBaseInstanceConfig.optional(), pipelines: z.array(RagRepositoryPipeline).optional().default([]).describe('Rag ingestion pipeline for automated inclusion into a vector store from S3'), allowedGroups: z.array(z.string().nonempty()).optional().default([]).describe('The groups provided by the Identity Provider that have access to this repository. If no groups are specified, access is granted to everyone.'), }) diff --git a/lib/serve/rest-api/src/utils/rds_auth.py b/lib/serve/rest-api/src/utils/rds_auth.py index 05dd785f9..dd777b93a 100644 --- a/lib/serve/rest-api/src/utils/rds_auth.py +++ b/lib/serve/rest-api/src/utils/rds_auth.py @@ -32,7 +32,7 @@ def _get_lambda_role_arn() -> str: str The full ARN of the Lambda execution role """ - sts = boto3.client("sts") + 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 diff --git a/lib/stages.ts b/lib/stages.ts index 00680d7ce..b0a90f47f 100644 --- a/lib/stages.ts +++ b/lib/stages.ts @@ -28,6 +28,7 @@ import { StageProps, Tags, } from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; import { Construct } from 'constructs'; import { AwsSolutionsChecks, NagSuppressions, NIST80053R5Checks } from 'cdk-nag'; @@ -121,6 +122,26 @@ class RemoveSecurityGroupAspect implements IAspect { } } +/** + * Removes Tags property from all AWS::Lambda::EventSourceMapping resources in a CDK application. + * This is required for AWS GovCloud regions which don't support Tags on EventSourceMapping resources. + */ +class RemoveEventSourceMappingTagsAspect implements IAspect { + /** + * Checks if the given node is an instance of CfnResource and specifically an AWS::Lambda::EventSourceMapping resource. + * If true, it removes the Tags property to prevent deployment failures in AWS GovCloud regions. + * + * @param {Construct} node - The CDK construct being visited. + */ + public visit (node: Construct): void { + // Check if the node is a CloudFormation resource of type AWS::Lambda::EventSourceMapping + if (node instanceof lambda.CfnEventSourceMapping) { + // Remove Tags property for AWS GovCloud compatibility + node.addPropertyDeletionOverride('Tags'); + } + } +} + export type CommonStackProps = { synthesizer?: IStackSynthesizer; } & BaseProps; @@ -365,5 +386,11 @@ export class LisaServeApplicationStage extends Stage { // Enforce updates to EC2 launch templates Aspects.of(this).add(new UpdateLaunchTemplateMetadataOptions()); + + // Apply EventSourceMapping tags removal aspect for AWS GovCloud regions + // AWS GovCloud regions don't support Tags on EventSourceMapping resources + if (config.region.includes('gov')) { + Aspects.of(this).add(new RemoveEventSourceMappingTagsAspect()); + } } } diff --git a/lib/user-interface/react/package.json b/lib/user-interface/react/package.json index 89cce429f..1fe5fbedd 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": "5.0.0", + "version": "5.0.1", "type": "module", "scripts": { "dev": "vite", @@ -49,7 +49,7 @@ "typescript": "~5.1.6", "unraw": "^3.0.0", "use-mcp": "^0.0.18", - "vitepress": "^1.6.3" + "vitepress": "^1.6.4" }, "devDependencies": { "@types/markdown-it": "^14.1.2", diff --git a/lib/user-interface/react/src/App.tsx b/lib/user-interface/react/src/App.tsx index 6aa679161..116a71a27 100644 --- a/lib/user-interface/react/src/App.tsx +++ b/lib/user-interface/react/src/App.tsx @@ -28,6 +28,7 @@ import SystemBanner from './components/system-banner/system-banner'; import { useAppSelector } from './config/store'; import { selectCurrentUserIsAdmin } from './shared/reducers/user.reducer'; import ModelManagement from './pages/ModelManagement'; +import ModelLibrary from './pages/ModelLibrary'; import NotificationBanner from './shared/notification/notification'; import ConfirmationModal, { ConfirmationModalProps } from './shared/modal/confirmation-modal'; import Configuration from './pages/Configuration'; @@ -40,6 +41,7 @@ import BreadcrumbsDefaultChangeListener from './shared/breadcrumb/breadcrumbs-ch import PromptTemplatesLibrary from './pages/PromptTemplatesLibrary'; import { ConfigurationContext } from './shared/configuration.provider'; import McpServers from '@/pages/Mcp'; +import ModelComparisonPage from './pages/ModelComparison'; export type RouteProps = { @@ -80,7 +82,7 @@ function App () { const [nav, setNav] = useState(null); const confirmationModal: ConfirmationModalProps = useAppSelector((state) => state.modal.confirmationModal); const auth = useAuth(); - const [ getConfigurationQuery, {data: fullConfig} ] = useLazyGetConfigurationQuery(); + const [getConfigurationQuery, { data: fullConfig }] = useLazyGetConfigurationQuery(); const config = fullConfig?.[0]; useEffect(() => { @@ -146,6 +148,14 @@ function App () { } /> + {config?.configuration?.enabledComponents?.modelLibrary && + + + } + />} {config?.configuration?.enabledComponents?.showRagLibrary && <> } />} + {config?.configuration?.enabledComponents?.enableModelComparisonUtility && + + + } + /> + } } /> diff --git a/lib/user-interface/react/src/components/Topbar.tsx b/lib/user-interface/react/src/components/Topbar.tsx index 052a95723..57637a222 100644 --- a/lib/user-interface/react/src/components/Topbar.tsx +++ b/lib/user-interface/react/src/components/Topbar.tsx @@ -63,6 +63,15 @@ function Topbar ({ configs }: TopbarProps): ReactElement { }, []); const libraryItems = [ + ...(configs?.configuration.enabledComponents?.modelLibrary ? [{ + id: 'model-library', + type: 'button', + variant: 'link', + text: 'Model Library', + disableUtilityCollapse: false, + external: false, + href: '/model-library', + } as ButtonDropdownProps.Item] : []), ...(configs?.configuration.enabledComponents?.showRagLibrary ? [{ id: 'document-library', type: 'button', diff --git a/lib/user-interface/react/src/components/chatbot/Chat.tsx b/lib/user-interface/react/src/components/chatbot/Chat.tsx index 0cb46d537..fa675ca85 100644 --- a/lib/user-interface/react/src/components/chatbot/Chat.tsx +++ b/lib/user-interface/react/src/components/chatbot/Chat.tsx @@ -100,12 +100,14 @@ export default function Chat ({ sessionId }) { data: (state.data || []).filter((model) => (model.modelType === ModelType.textgen || model.modelType === ModelType.imagegen) && model.status === ModelStatus.InService), }) }); - const {data: userPreferences} = useGetUserPreferencesQuery(); - const { data: mcpServers } = useListMcpServersQuery(undefined, { refetchOnMountOrArgChange: true, + const { data: userPreferences } = useGetUserPreferencesQuery(); + const { data: mcpServers } = useListMcpServersQuery(undefined, { + refetchOnMountOrArgChange: true, selectFromResult: (state) => ({ isFetching: state.isFetching, data: (state.data?.Items || []).filter((server) => (server.status === McpServerStatus.Active)), - }) },); + }) + },); // State management const [userPrompt, setUserPrompt] = useState(''); @@ -143,7 +145,7 @@ export default function Chat ({ sessionId }) { if (userPreferences) { setPreferences(userPreferences); } else { - setPreferences({...DefaultUserPreferences, user: userName}); + setPreferences({ ...DefaultUserPreferences, user: userName }); } }, [userPreferences, userName]); @@ -263,7 +265,7 @@ export default function Chat ({ sessionId }) { const toggleToolAutoApproval = (toolName: string, enabled: boolean) => { - const existingMcpPrefs = preferences.preferences.mcp ?? {enabledServers: [], overrideAllApprovals: false}; + const existingMcpPrefs = preferences.preferences.mcp ?? { enabledServers: [], overrideAllApprovals: false }; const mcpPrefs: McpPreferences = { ...existingMcpPrefs, enabledServers: [...existingMcpPrefs.enabledServers] @@ -289,8 +291,10 @@ export default function Chat ({ sessionId }) { }; const updatePrefs = (mcpPrefs: McpPreferences) => { - const updated = {...preferences, - preferences: {...preferences.preferences, + const updated = { + ...preferences, + preferences: { + ...preferences.preferences, mcp: { ...preferences.preferences.mcp, ...mcpPrefs @@ -629,7 +633,7 @@ export default function Chat ({ sessionId }) { {JSON.stringify(toolApprovalModal.tool.args).replace('{', '').replace('}', '')}

Do you want to allow this tool execution?

-
+
toggleToolAutoApproval(toolApprovalModal.tool.name, detail.checked) 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 2ad53a4a1..3fdffff26 100644 --- a/lib/user-interface/react/src/components/chatbot/components/Message.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/Message.tsx @@ -252,7 +252,7 @@ export default function Message ({ message, isRunning, showMetadata, isStreaming )} - {message?.type === 'ai' && !isRunning && !callingToolName && ( + {message?.type === 'ai' && !isRunning && !callingToolName && message?.content && ( void; setVisible: (boolean) => void; @@ -40,9 +41,11 @@ export type SessionConfigurationProps = { selectedModel: IModel; isRunning: boolean; systemConfig: IConfiguration; + modelOnly?: boolean }; export default function SessionConfiguration ({ + title, chatConfiguration, setChatConfiguration, selectedModel, @@ -50,6 +53,7 @@ export default function SessionConfiguration ({ visible, setVisible, systemConfig, + modelOnly = false }: SessionConfigurationProps) { // Defaults based on https://huggingface.co/docs/transformers/main_classes/text_generation#transformers.GenerationConfig // Default stop sequences based on User/Assistant instruction prompting for Falcon, Mistral, etc. @@ -75,7 +79,7 @@ export default function SessionConfiguration ({ setVisible(false)} visible={visible} - header={
Session Configuration
} + header={
{title || 'Session Configuration'}
} footer='' size='large' > @@ -102,7 +106,7 @@ export default function SessionConfiguration ({ > Show Message Metadata } - {systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && + {systemConfig && systemConfig.configuration.enabledComponents.editChatHistoryBuffer && !isImageModel && !modelOnly && @@ -119,7 +123,7 @@ export default function SessionConfiguration ({ options={oneThroughTenOptions} /> } - {systemConfig && systemConfig.configuration.enabledComponents.editNumOfRagDocument && !isImageModel && + {systemConfig && systemConfig.configuration.enabledComponents.editNumOfRagDocument && !isImageModel && !modelOnly && @@ -316,7 +320,7 @@ export default function SessionConfiguration ({ }} /> - - { updateSessionConfiguration('imageGenerationArgs', { ...chatConfiguration.sessionConfiguration.imageGenerationArgs, @@ -430,14 +436,14 @@ export default function SessionConfiguration ({ }); }} options={[ - { label: 'Standard', value: 'standard'}, + { label: 'Standard', value: 'standard' }, { label: 'HD', value: 'hd' }, ]} /> setSearchQuery(detail.value)} + placeholder='Search sessions by message content...' + clearAriaLabel='Clear search' + type='search' + /> + {searchQuery && ( + + Found {filteredSessions.length} session{filteredSessions.length !== 1 ? 's' : ''} + + )} + + } + > + + + + ariaLabel='Refresh Sessions' + > {config?.configuration.enabledComponents.deleteSessionHistory && - } + }
} @@ -160,22 +245,22 @@ export function Sessions ({newSession}) { navigate(`ai-assistant/${item.sessionId}`)}> - {truncateText(getDisplayableMessage(item.firstHumanMessage ?? ''), 40, '...')} + {getSessionDisplay(item, 40)} { - if (e.detail.id === 'delete-session'){ + if (e.detail.id === 'delete-session') { dispatch( setConfirmationModal({ action: 'Delete', @@ -184,7 +269,7 @@ export function Sessions ({newSession}) { description: `This will delete the Session: ${item.sessionId}.` }) ); - } else if (e.detail.id === 'download-session'){ + } else if (e.detail.id === 'download-session') { getSessionById(item.sessionId).then((resp) => { const sess: LisaChatSession = resp.data; const file = new Blob([JSON.stringify(sess, null, 2)], { type: 'application/json' }); @@ -209,7 +294,7 @@ export function Sessions ({newSession}) { const imagePromises = images.map(async (imageUrl, index) => { try { const blob = await fetchImage(imageUrl); - zip.file(`image_${index + 1}.png`, blob, {binary: true}); + zip.file(`image_${index + 1}.png`, blob, { binary: true }); } catch (error) { console.error(`Error processing image ${index + 1}:`, error); } @@ -217,10 +302,12 @@ export function Sessions ({newSession}) { // Wait for all images to be processed await Promise.all(imagePromises); - const content = await zip.generateAsync({type: 'blob'}); + const content = await zip.generateAsync({ type: 'blob' }); downloadFile(URL.createObjectURL(content), `${sess.sessionId}-images.zip`); } }); + } else if (e.detail.id === 'rename-session') { + handleRenameSession(item); } }} /> @@ -229,6 +316,48 @@ export function Sessions ({newSession}) { ))} + + {/* Rename Session Modal */} + + + + + + + } + > + + + setNewSessionName(detail.value)} + placeholder='Enter session name...' + onKeyDown={(e) => { + if (e.detail.key === 'Enter' && newSessionName.trim() && !isUpdateSessionNameLoading) { + handleRenameConfirm(); + } + }} + /> + + + ); } diff --git a/lib/user-interface/react/src/components/chatbot/components/WelcomeScreen.tsx b/lib/user-interface/react/src/components/chatbot/components/WelcomeScreen.tsx index 832d1b362..073e4f751 100644 --- a/lib/user-interface/react/src/components/chatbot/components/WelcomeScreen.tsx +++ b/lib/user-interface/react/src/components/chatbot/components/WelcomeScreen.tsx @@ -15,11 +15,12 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { Button, Header, SpaceBetween, TextContent } from '@cloudscape-design/components'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Header } from '@cloudscape-design/components'; import { faFileLines, faMessage, faPenToSquare, faComment } from '@fortawesome/free-regular-svg-icons'; import { PromptTemplateType } from '@/shared/reducers/prompt-templates.reducer'; import { IConfiguration } from '@/shared/model/configuration.model'; +import { ButtonBadge } from '@/components/common/ButtonBadge'; +import { faCodeCompare } from '@fortawesome/free-solid-svg-icons'; type WelcomeScreenProps = { navigate: (path: string) => void; @@ -54,52 +55,55 @@ export const WelcomeScreen = ({
- + { + navigate(`/ai-assistant/${uuidv4()}`); + modelSelectRef?.current?.focus(); + }} + /> - {config?.configuration?.enabledComponents?.showPromptTemplateLibrary && ( - <> - - - - )} + { + refreshPromptTemplate(); + setFilterPromptTemplateType(PromptTemplateType.Persona); + openModal('promptTemplate'); + }} + show={config?.configuration?.enabledComponents?.showPromptTemplateLibrary} + /> - + { + refreshPromptTemplate(); + setFilterPromptTemplateType(PromptTemplateType.Directive); + openModal('promptTemplate'); + }} + show={config?.configuration?.enabledComponents?.showPromptTemplateLibrary} + /> + + openModal('documentSummarization')} + /> + + navigate('/model-comparison')} + show={config?.configuration?.enabledComponents?.enableModelComparisonUtility} + />
); diff --git a/lib/user-interface/react/src/components/chatbot/hooks/useModals.hooks.tsx b/lib/user-interface/react/src/components/chatbot/hooks/useModals.hooks.tsx index 30ce5c4d3..8001cbca2 100644 --- a/lib/user-interface/react/src/components/chatbot/hooks/useModals.hooks.tsx +++ b/lib/user-interface/react/src/components/chatbot/hooks/useModals.hooks.tsx @@ -22,6 +22,7 @@ export type ModalState = { ragUpload: boolean; documentSummarization: boolean; promptTemplate: boolean; + modelComparison: boolean }; export const useModals = () => { @@ -31,6 +32,7 @@ export const useModals = () => { ragUpload: false, documentSummarization: false, promptTemplate: false, + modelComparison: false, }); const [promptTemplateKey, setPromptTemplateKey] = useState(new Date().toISOString()); 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 27cf4bc49..f587ef649 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 @@ -14,7 +14,7 @@ limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { v4 as uuidv4 } from 'uuid'; import { LisaChatSession } from '@/components/types'; @@ -23,10 +23,15 @@ import { RagConfig } from '../components/RagOptions'; import { IModel } from '@/shared/model/model-management.model'; import { useAppDispatch } from '@/config/store'; import { setBreadcrumbs } from '@/shared/reducers/breadcrumbs.reducer'; +import ConfigurationContext from '@/shared/configuration.provider'; +import { IConfiguration } from '@/shared/model/configuration.model'; +import { useGetAllModelsQuery } from '@/shared/reducers/model-management.reducer'; export const useSession = (sessionId: string, getSessionById: any) => { const dispatch = useAppDispatch(); const auth = useAuth(); + const config: IConfiguration = useContext(ConfigurationContext); + const { data: allModels } = useGetAllModelsQuery(); const [session, setSession] = useState({ history: [], @@ -39,6 +44,7 @@ export const useSession = (sessionId: string, getSessionById: any) => { const [chatConfiguration, setChatConfiguration] = useState(baseConfig); const [selectedModel, setSelectedModel] = useState(); const [ragConfig, setRagConfig] = useState({} as RagConfig); + const [hasUserInteractedWithModel, setHasUserInteractedWithModel] = useState(false); useEffect(() => { // always hide breadcrumbs @@ -63,6 +69,10 @@ export const useSession = (sessionId: string, getSessionById: any) => { setSession(sess); setChatConfiguration(sess.configuration ?? baseConfig); setSelectedModel(sess.configuration?.selectedModel ?? undefined); + // If session has a pre-selected model, consider it as user interaction + if (sess.configuration?.selectedModel) { + setHasUserInteractedWithModel(true); + } setRagConfig(sess.configuration?.ragConfig ?? {} as RagConfig); setLoadingSession(false); }); @@ -80,6 +90,22 @@ export const useSession = (sessionId: string, getSessionById: any) => { } }, [sessionId, dispatch, auth.user?.profile.sub, getSessionById]); + // Set default model if none is selected, default model is configured, and user hasn't interacted with model selection + useEffect(() => { + if (!selectedModel && !hasUserInteractedWithModel && config?.configuration?.global?.defaultModel && allModels) { + const defaultModel = allModels.find((model) => model.modelId === config.configuration.global.defaultModel); + if (defaultModel) { + setSelectedModel(defaultModel); + } + } + }, [selectedModel, hasUserInteractedWithModel, config?.configuration?.global?.defaultModel, allModels]); + + // Wrapper function to track user interaction with model selection + const handleSetSelectedModel = (model: IModel | undefined) => { + setHasUserInteractedWithModel(true); + setSelectedModel(model); + }; + return { session, setSession, @@ -89,7 +115,7 @@ export const useSession = (sessionId: string, getSessionById: any) => { chatConfiguration, setChatConfiguration, selectedModel, - setSelectedModel, + setSelectedModel: handleSetSelectedModel, ragConfig, setRagConfig, }; diff --git a/lib/user-interface/react/src/components/common/ButtonBadge.tsx b/lib/user-interface/react/src/components/common/ButtonBadge.tsx new file mode 100644 index 000000000..a764cd340 --- /dev/null +++ b/lib/user-interface/react/src/components/common/ButtonBadge.tsx @@ -0,0 +1,41 @@ +/** + 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 { Button, SpaceBetween, TextContent } from '@cloudscape-design/components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +type ButtonBadgeProps = { + text: string; + icon: IconDefinition; + onClick: () => void; + show?: boolean; +}; + +export const ButtonBadge = ({ text, icon, onClick, show = true }: ButtonBadgeProps) => { + if (!show) { + return null; + } + + return ( + + ); +}; diff --git a/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx b/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx index 5af443ea1..37256e881 100644 --- a/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx +++ b/lib/user-interface/react/src/components/configuration/ActivatedUserComponents.tsx @@ -24,6 +24,11 @@ const ragOptions = { editNumOfRagDocument: 'Edit number of referenced documents', }; +const libraryOptions = { + modelLibrary: 'Show Model Library', + showPromptTemplateLibrary: 'Show Prompt Template Library' +}; + const inContextOptions = { uploadContextDocs: 'Allow document upload to context', documentSummarization: 'Allow Document Summarization', @@ -36,13 +41,18 @@ const advancedOptions = { viewMetaData: 'View chat meta-data', deleteSessionHistory: 'Delete Session History', editChatHistoryBuffer: 'Edit chat history buffer', - showPromptTemplateLibrary: 'Show Prompt Template Library' + showPromptTemplateLibrary: 'Show Prompt Template Library', + enableModelComparisonUtility: 'Enable Model Comparison Utility' }; const configurableOperations = [{ header: 'RAG Components', items: ragOptions }, +{ + header: 'Library Components', + items: libraryOptions +}, { header: 'In-Context Components', items: inContextOptions diff --git a/lib/user-interface/react/src/components/configuration/RepositoryActions.tsx b/lib/user-interface/react/src/components/configuration/RepositoryActions.tsx index a456b1162..8539b335a 100644 --- a/lib/user-interface/react/src/components/configuration/RepositoryActions.tsx +++ b/lib/user-interface/react/src/components/configuration/RepositoryActions.tsx @@ -88,18 +88,22 @@ function RepositoryActionButton (dispatch: ThunkDispatch, noti useEffect(() => { if (!isDeleteLoading && isDeleteSuccess && selectedRepo) { - notificationService.generateNotification(`Successfully deleted repository: ${selectedRepo.repositoryId}`, 'success'); + notificationService.generateNotification(`Successfully deleted repository: ${selectedRepo?.repositoryId}`, 'success'); setSelectedItems([]); + setDisabledModel(false); + setShowModal(false); } else if (!isDeleteLoading && isDeleteError && selectedRepo) { notificationService.generateNotification(`Error deleting repository: ${deleteError.data?.message ?? deleteError.data}`, 'error'); setSelectedItems([]); + setDisabledModel(false); + setShowModal(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDeleteSuccess, isDeleteError, deleteError, isDeleteLoading]); useEffect(() => { if (!isUpdating && isUpdateSuccess && selectedRepo) { - notificationService.generateNotification(`Successfully updated repository: ${selectedRepo.repositoryId}`, 'success'); + notificationService.generateNotification(`Successfully updated repository: ${selectedRepo?.repositoryId}`, 'success'); setSelectedItems([]); } else if (!isUpdating && isUpdateError && selectedRepo) { notificationService.generateNotification(`Error updating repository: ${updateError.data?.message ?? updateError.data}`, 'error'); @@ -113,14 +117,14 @@ function RepositoryActionButton (dispatch: ThunkDispatch, noti dispatch(setConfirmationModal({ action: 'Delete', resourceName: 'Repository', - onConfirm: () => deleteMutation(selectedRepo.repositoryId), + onConfirm: () => deleteMutation(selectedRepo?.repositoryId), onDismiss: () => { setDisabledModel(false); setShowModal(false); }, description: ( -

This will delete the following repository: {selectedRepo.repositoryId}.

+

This will delete the following repository: {selectedRepo?.repositoryId}.

{selectedRepo?.legacy && & BedrockKnowledgeBaseConfigProps): ReactElement { + const { item, touchFields, setFields, formErrors, isEdit } = props; + + return ( + Bedrock Knowledge Base Config}> + + + touchFields(['bedrockKnowledgeBaseConfig.bedrockKnowledgeBaseName'])} + onChange={({ detail }) => setFields({ 'bedrockKnowledgeBaseConfig.bedrockKnowledgeBaseName': detail.value })} + placeholder='Knowledge Base Name' disabled={isEdit} /> + + + touchFields(['bedrockKnowledgeBaseConfig.bedrockKnowledgeBaseId'])} + onChange={({ detail }) => setFields({ 'bedrockKnowledgeBaseConfig.bedrockKnowledgeBaseId': detail.value })} + placeholder='Knowledge Base ID' disabled={isEdit} /> + + + touchFields(['bedrockKnowledgeBaseConfig.bedrockKnowledgeDatasourceName'])} + onChange={({ detail }) => setFields({ 'bedrockKnowledgeBaseConfig.bedrockKnowledgeDatasourceName': detail.value })} + placeholder='Knowledge Base Datasource Name' disabled={isEdit} /> + + + touchFields(['bedrockKnowledgeBaseConfig.bedrockKnowledgeDatasourceId'])} + onChange={({ detail }) => setFields({ 'bedrockKnowledgeBaseConfig.bedrockKnowledgeDatasourceId': detail.value })} + placeholder='Knowledge Base Datasource ID' disabled={isEdit} /> + + + touchFields(['bedrockKnowledgeBaseConfig.bedrockKnowledgeDatasourceS3Bucket'])} + onChange={({ detail }) => setFields({ 'bedrockKnowledgeBaseConfig.bedrockKnowledgeDatasourceS3Bucket': detail.value })} + placeholder='Knowledge Base Datasource S3 Bucket' disabled={isEdit} /> + + + + ); +} diff --git a/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx b/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx index a3adeb0a2..c38151b1b 100644 --- a/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx +++ b/lib/user-interface/react/src/components/configuration/createRepository/RepositoryConfigForm.tsx @@ -26,11 +26,13 @@ import { RagRepositoryConfigSchema, RagRepositoryType, RdsInstanceConfig, + BedrockKnowledgeBaseInstanceConfig } from '#root/lib/schema'; import { getDefaults } from '#root/lib/schema/zodUtil'; import { ArrayInputField } from '../../../shared/form/array-input'; import { RdsConfigForm } from './RdsConfigForm'; import { OpenSearchConfigForm } from './OpenSearchConfigForm'; +import { BedrockKnowledgeBaseConfigForm } from './BedrockKnowledgeBaseConfigForm'; export type RepositoryConfigProps = { isEdit: boolean @@ -73,12 +75,21 @@ export function RepositoryConfigForm (props: FormProps & Re setFields({ 'rdsConfig': getDefaults(RdsInstanceConfig) }); } setFields({ 'opensearchConfig': undefined }); + setFields({ 'bedrockKnowledgeBaseConfig': undefined }); } if (detail.selectedOption.value === RagRepositoryType.OPENSEARCH) { if (item.opensearchConfig === undefined) { setFields({ 'opensearchConfig': getDefaults(OpenSearchNewClusterConfig) }); } setFields({ 'rdsConfig': undefined }); + setFields({ 'bedrockKnowledgeBaseConfig': undefined }); + } + if (detail.selectedOption.value === RagRepositoryType.BEDROCK_KNOWLEDGE_BASE) { + if (item.bedrockKnowledgeBaseConfig === undefined) { + setFields({ 'bedrockKnowledgeBaseConfig': getDefaults(BedrockKnowledgeBaseInstanceConfig) }); + } + setFields({ 'rdsConfig': undefined }); + setFields({ 'opensearchConfig': undefined }); } setFields({ 'type': detail.selectedOption.value }); }} @@ -99,6 +110,10 @@ export function RepositoryConfigForm (props: FormProps & Re } + {item.type === RagRepositoryType.BEDROCK_KNOWLEDGE_BASE && + + } void; +}; + +export function EcsRestartWarning (props: EcsRestartWarningProps): ReactElement { + const { acknowledged, onAcknowledge } = props; + + return ( + onAcknowledge(detail.checked)} + > + I understand that this update will cause a temporary service outage. + + } + > + +

+ Your container configuration changes require restarting the ECS container that is hosting the model. + This will cause a temporary outage for users. Users will be unable to prompt the model until the container has fully restarted, and may receive errors. + You can move forward with the restart, or cancel and make these changes later. +

+

+ Expected Impact: Brief service interruption during container deployment. +

+
+
+ ); +} + +export default EcsRestartWarning; diff --git a/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx b/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx new file mode 100644 index 000000000..aa16ba933 --- /dev/null +++ b/lib/user-interface/react/src/components/model-management/ModelLibraryActions.tsx @@ -0,0 +1,37 @@ +/** + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React, { ReactElement } from 'react'; +import { Button, Icon } from '@cloudscape-design/components'; +import { useAppDispatch } from '../../config/store'; +import { modelManagementApi } from '../../shared/reducers/model-management.reducer'; + +function ModelLibraryActions (): ReactElement { + const dispatch = useAppDispatch(); + + return ( + + ); +} + +export { ModelLibraryActions }; diff --git a/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx b/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx new file mode 100644 index 000000000..9a6fde52a --- /dev/null +++ b/lib/user-interface/react/src/components/model-management/ModelLibraryComponent.tsx @@ -0,0 +1,125 @@ +/** + 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, useEffect, useState } from 'react'; +import { Box, Cards, CollectionPreferences, Header, Pagination, TextFilter } from '@cloudscape-design/components'; +import SpaceBetween from '@cloudscape-design/components/space-between'; +import { useGetAllModelsQuery } from '../../shared/reducers/model-management.reducer'; +import { + CARD_DEFINITIONS, + DEFAULT_PREFERENCES, + PAGE_SIZE_OPTIONS, + VISIBLE_CONTENT_OPTIONS, +} from './ModelManagementUtils'; +import { IModel, ModelStatus } from '../../shared/model/model-management.model'; +import { useLocalStorage } from '../../shared/hooks/use-local-storage'; +import { Duration } from 'luxon'; +import { ModelLibraryActions } from './ModelLibraryActions'; + +export function ModelLibraryComponent () : ReactElement { + const [shouldPoll, setShouldPoll] = useState(true); + const { data: allModels, isFetching: fetchingModels } = useGetAllModelsQuery(undefined, { + refetchOnMountOrArgChange: true, + pollingInterval: shouldPoll ? Duration.fromObject({seconds: 30}) : undefined + }); + const [matchedModels, setMatchedModels] = useState([]); + const [searchText, setSearchText] = useState(''); + const [numberOfPages, setNumberOfPages] = useState(1); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + const [preferences, setPreferences] = useLocalStorage('ModelLibraryPreferences', DEFAULT_PREFERENCES); + const [count, setCount] = useState(''); + + useEffect(() => { + const finalStatePredicate = (model) => [ModelStatus.InService, ModelStatus.Failed, ModelStatus.Stopped].includes(model.status); + if (allModels?.every(finalStatePredicate)) { + setShouldPoll(false); + } + }, [allModels, setShouldPoll]); + + 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'); + } + + if (newPageCount < numberOfPages){ + setCurrentPageIndex(1); + } + setNumberOfPages(newPageCount); + }, [allModels, searchText, preferences, currentPageIndex, numberOfPages]); + + return ( + } + > + Model Library + + } + filter={ { + setSearchText(detail.filteringText); + }} />} + pagination={ setCurrentPageIndex(detail.currentPageIndex)} pagesCount={numberOfPages} />} + preferences={ + setPreferences(detail)} + pageSizePreference={{ + title: 'Page size', + options: PAGE_SIZE_OPTIONS, + }} + visibleContentPreference={{ + title: 'Select visible columns', + options: VISIBLE_CONTENT_OPTIONS, + }} + /> + } + empty={ + + + No models + + + } + /> + ); +} + +export default ModelLibraryComponent; 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 cd7b1d951..d01841811 100644 --- a/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx +++ b/lib/user-interface/react/src/components/model-management/ModelManagementActions.tsx @@ -14,25 +14,30 @@ limitations under the License. */ -import React, { ReactElement, useEffect } from 'react'; +import { ReactElement, useEffect } from 'react'; import { Button, ButtonDropdown, Icon, SpaceBetween } from '@cloudscape-design/components'; -import { useAppDispatch } from '../../config/store'; -import { IModel, ModelStatus } from '../../shared/model/model-management.model'; -import { useNotificationService } from '../../shared/util/hooks'; -import { INotificationService } from '../../shared/notification/notification.service'; +import { useAppDispatch, useAppSelector } from '@/config/store'; +import { IModel, ModelStatus } from '@/shared/model/model-management.model'; +import { useNotificationService } from '@/shared/util/hooks'; +import { INotificationService } from '@/shared/notification/notification.service'; import { modelManagementApi, useDeleteModelMutation, useUpdateModelMutation, -} from '../../shared/reducers/model-management.reducer'; +} from '@/shared/reducers/model-management.reducer'; import { MutationTrigger } from '@reduxjs/toolkit/dist/query/react/buildHooks'; import { Action, ThunkDispatch } from '@reduxjs/toolkit'; -import { setConfirmationModal } from '../../shared/reducers/modal.reducer'; +import { setConfirmationModal } from '@/shared/reducers/modal.reducer'; +import { IConfiguration, SystemConfiguration } from '@/shared/model/configuration.model'; +import { selectCurrentUsername } from '@/shared/reducers/user.reducer'; export type ModelActionProps = { selectedItems: IModel[]; setSelectedItems: (items: IModel[]) => void; setNewModelModelVisible: (state: boolean) => void; setEdit: (state: boolean) => void; + updateConfigMutation?: any; + currentDefaultModel?: string; + currentConfig?: any; }; function ModelActions (props: ModelActionProps): ReactElement { @@ -50,11 +55,15 @@ function ModelActions (props: ModelActionProps): ReactElement { > + {ModelActionButton(dispatch, notificationService, props)} -
@@ -63,6 +72,7 @@ function ModelActions (props: ModelActionProps): ReactElement { function ModelActionButton (dispatch: ThunkDispatch, notificationService: INotificationService, props?: any): ReactElement { const selectedModel: IModel = props?.selectedItems[0]; + const currentUsername = useAppSelector(selectCurrentUsername); const [ deleteMutation, { isSuccess: isDeleteSuccess, isError: isDeleteError, error: deleteError, isLoading: isDeleteLoading }, @@ -78,10 +88,11 @@ function ModelActionButton (dispatch: ThunkDispatch, notificat notificationService.generateNotification(`Successfully deleted model: ${selectedModel.modelId}`, 'success'); props.setSelectedItems([]); } else if (!isDeleteLoading && isDeleteError && selectedModel) { - notificationService.generateNotification(`Error deleting model: ${deleteError.data?.message ?? deleteError.data}`, 'error'); + const errorMessage = deleteError && 'data' in deleteError ? deleteError.data?.message ?? deleteError.data : 'Unknown error occurred'; + notificationService.generateNotification(`Error deleting model: ${errorMessage}`, 'error'); props.setSelectedItems([]); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDeleteSuccess, isDeleteError, deleteError, isDeleteLoading]); useEffect(() => { @@ -89,7 +100,8 @@ function ModelActionButton (dispatch: ThunkDispatch, notificat notificationService.generateNotification(`Successfully updated model: ${selectedModel.modelId}`, 'success'); props.setSelectedItems([]); } else if (!isUpdating && isUpdateError && selectedModel) { - notificationService.generateNotification(`Error updating model: ${updateError.data?.message ?? updateError.data}`, 'error'); + const errorMessage = updateError && 'data' in updateError ? updateError.data?.message ?? updateError.data : 'Unknown error occurred'; + notificationService.generateNotification(`Error updating model: ${errorMessage}`, 'error'); props.setSelectedItems([]); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -119,8 +131,14 @@ function ModelActionButton (dispatch: ThunkDispatch, notificat items.push({ text: 'Update', id: 'editModel', - disabled: externalModel || ![ModelStatus.InService, ModelStatus.Stopped].includes(selectedModel.status), - disabledReason: externalModel ? 'Unable to stop a model that is not hosted in LISA' : ![ModelStatus.InService, ModelStatus.Stopped].includes(selectedModel.status) ? 'Unable to update a model that is in a pending or failed state' : '', + disabled: ![ModelStatus.InService, ModelStatus.Stopped].includes(selectedModel.status), + disabledReason: ![ModelStatus.InService, ModelStatus.Stopped].includes(selectedModel.status) ? 'Unable to update a model that is in a pending or failed state' : '', + }); + items.push({ + text: 'Set Default', + id: 'setDefault', + disabled: selectedModel.modelId === props?.currentDefaultModel, + disabledReason: selectedModel.modelId === props?.currentDefaultModel ? 'This model is already the default' : '', }); } @@ -131,7 +149,7 @@ function ModelActionButton (dispatch: ThunkDispatch, notificat disabled={!selectedModel} loading={isDeleteLoading || isUpdating} onItemClick={(e) => - ModelActionHandler(e, selectedModel, dispatch, deleteMutation, updateModelMutation, props.setNewModelModelVisible, props.setEdit) + ModelActionHandler(e, selectedModel, dispatch, deleteMutation, updateModelMutation, props.setNewModelModelVisible, props.setEdit, props.updateConfigMutation, notificationService, props.currentConfig, currentUsername) } > Actions @@ -146,7 +164,11 @@ const ModelActionHandler = async ( deleteMutation: MutationTrigger, updateMutation: MutationTrigger, setNewModelModelVisible: (boolean) => void, - setEdit: (boolean) => void + setEdit: (boolean) => void, + updateConfigMutation?: any, + notificationService?: INotificationService, + currentConfig?: any, + currentUsername?: string ) => { switch (e.detail.id) { case 'startModel': @@ -189,6 +211,38 @@ const ModelActionHandler = async ( }) ); break; + case 'setDefault': + if (updateConfigMutation && notificationService && currentConfig) { + dispatch( + setConfirmationModal({ + action: 'Set Default', + resourceName: 'Model', + onConfirm: async () => { + try { + const updatedConfiguration: SystemConfiguration = { + ...currentConfig[0]?.configuration, + global: { defaultModel: selectedModel.modelId } + }; + ''; + const configUpdate: IConfiguration = { + configuration: updatedConfiguration, + configScope: 'global', + versionId: Number(currentConfig[0]?.versionId) + 1, + changedBy: currentUsername ?? 'Admin', + changeReason: `Set default model to: ${selectedModel.modelId}`, + }; + + await updateConfigMutation(configUpdate).unwrap(); + notificationService.generateNotification(`Successfully set ${selectedModel.modelId} as default model`, 'success'); + } catch (error) { + notificationService.generateNotification(`Error setting default model: ${error}`, 'error'); + } + }, + description: `This will set ${selectedModel.modelId} as the default model.` + }) + ); + } + break; default: return; } diff --git a/lib/user-interface/react/src/components/model-management/ModelManagementComponent.tsx b/lib/user-interface/react/src/components/model-management/ModelManagementComponent.tsx index 01d749366..aad3b1bd4 100644 --- a/lib/user-interface/react/src/components/model-management/ModelManagementComponent.tsx +++ b/lib/user-interface/react/src/components/model-management/ModelManagementComponent.tsx @@ -20,7 +20,7 @@ import SpaceBetween from '@cloudscape-design/components/space-between'; import { useGetAllModelsQuery } from '../../shared/reducers/model-management.reducer'; import CreateModelModal from './create-model/CreateModelModal'; import { - CARD_DEFINITIONS, + createCardDefinitions, DEFAULT_PREFERENCES, PAGE_SIZE_OPTIONS, VISIBLE_CONTENT_OPTIONS, @@ -28,13 +28,14 @@ import { import { ModelActions } from './ModelManagementActions'; import { IModel, ModelStatus } from '../../shared/model/model-management.model'; import { useLocalStorage } from '../../shared/hooks/use-local-storage'; -import { Duration } from 'luxon'; +import { useGetConfigurationQuery, useUpdateConfigurationMutation } from '@/shared/reducers/configuration.reducer'; -export function ModelManagementComponent () : ReactElement { +export function ModelManagementComponent (): ReactElement { const [shouldPoll, setShouldPoll] = useState(true); - const { data: allModels, isFetching: fetchingModels } = useGetAllModelsQuery(undefined, { + const { data: allModels, isFetching: fetchingModels, refetch } = useGetAllModelsQuery(undefined, { refetchOnMountOrArgChange: true, - pollingInterval: shouldPoll ? Duration.fromObject({seconds: 30}) : undefined + refetchOnFocus: false, // Prevent unnecessary refetches + pollingInterval: shouldPoll ? 30000 : undefined // 30 seconds in milliseconds }); const [matchedModels, setMatchedModels] = useState([]); const [searchText, setSearchText] = useState(''); @@ -47,8 +48,22 @@ export function ModelManagementComponent () : ReactElement { const [isEdit, setEdit] = useState(false); const [count, setCount] = useState(''); + const { + data: config, + } = useGetConfigurationQuery('global', { refetchOnMountOrArgChange: true }); + const [updateConfigMutation] = useUpdateConfigurationMutation(); + + // Ensure models are fetched on component mount + useEffect(() => { + // Force refetch on mount if no data is available + if (!allModels && !fetchingModels) { + console.log('No models data available, triggering refetch...'); + refetch(); + } + }, [allModels, fetchingModels, refetch]); + useEffect(() => { - const finalStatePredicate = (model) => [ModelStatus.InService, ModelStatus.Failed, ModelStatus.Stopped].includes(model.status); + const finalStatePredicate = (model: IModel) => [ModelStatus.InService, ModelStatus.Failed, ModelStatus.Stopped].includes(model.status); if (allModels?.every(finalStatePredicate)) { setShouldPoll(false); } @@ -56,7 +71,7 @@ export function ModelManagementComponent () : ReactElement { useEffect(() => { let newPageCount = 0; - if (searchText){ + 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); @@ -67,7 +82,7 @@ export function ModelManagementComponent () : ReactElement { setCount(allModels ? allModels.length.toString() : '0'); } - if (newPageCount < numberOfPages){ + if (newPageCount < numberOfPages) { setCurrentPageIndex(1); } setNumberOfPages(newPageCount); @@ -75,15 +90,15 @@ export function ModelManagementComponent () : ReactElement { return ( <> - + setSelectedItems(detail?.selectedItems ?? [])} selectedItems={selectedItems} ariaLabels={{ - itemSelectionLabel: (e, t) => `select ${t.modelName}`, + itemSelectionLabel: (_, t) => `select ${t.modelName}`, selectionGroupLabel: 'Model selection', }} - cardDefinition={CARD_DEFINITIONS} + cardDefinition={createCardDefinitions(config?.[0]?.configuration?.global?.defaultModel)} visibleSections={preferences.visibleContent} loadingText='Loading models' items={matchedModels} @@ -101,6 +116,9 @@ export function ModelManagementComponent () : ReactElement { setSelectedItems={setSelectedItems} setNewModelModelVisible={setNewModelModelVisible} setEdit={setEdit} + updateConfigMutation={updateConfigMutation} + currentDefaultModel={config?.[0]?.configuration?.global?.defaultModel} + currentConfig={config} /> } > 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 8e92337cb..805caa4c6 100644 --- a/lib/user-interface/react/src/components/model-management/ModelManagementUtils.tsx +++ b/lib/user-interface/react/src/components/model-management/ModelManagementUtils.tsx @@ -17,6 +17,7 @@ import { IModel, ModelStatus } from '../../shared/model/model-management.model'; import { StatusIndicatorProps } from '@cloudscape-design/components/status-indicator'; import { CollectionPreferencesProps, StatusIndicator } from '@cloudscape-design/components'; import { DEFAULT_PAGE_SIZE_OPTIONS } from '../../shared/preferences/common-preferences'; +import Badge from '@cloudscape-design/components/badge'; type EnumDictionary = { [K in T]: U; @@ -33,8 +34,8 @@ export const MODEL_STATUS_LOOKUP: EnumDictionary
{model.modelId}
, +export const createCardDefinitions = (defaultModelId?: string) => ({ + header: (model: IModel) =>
{model.modelId} {model.modelId === defaultModelId && DEFAULT}
, sections: [ { id: 'modelName', @@ -44,7 +45,7 @@ export const CARD_DEFINITIONS = { { id: 'modelFeatures', header: 'Model Features', - content: (model: IModel) => model.features ? model.features.map((feat) => feat.name).join(', ') : 'Model doesn\'t have any special features', + content: (model: IModel) => model.features ? model.features.map((feat) => feat.name).join(', ') : '-', }, { id: 'modelType', @@ -54,7 +55,7 @@ export const CARD_DEFINITIONS = { { id: 'modelUrl', header: 'URL', - content: (model: IModel) => model.modelUrl ? model.modelUrl : 'Model URL not defined', + content: (model: IModel) => model.modelUrl ? model.modelUrl : '-', }, { id: 'streaming', @@ -69,7 +70,17 @@ export const CARD_DEFINITIONS = { { id: 'instanceType', header: 'Instance Type', - content: (model: IModel) => model.instanceType ? model.instanceType : 'Instance Type not defined', + content: (model: IModel) => model.instanceType ? model.instanceType : '-', + }, + { + id: 'modelDescription', + header: 'Description', + content: (model: IModel) => model.modelDescription ? model.modelDescription : '-', + }, + { + id: 'allowedGroups', + header: 'Allowed Groups', + content: (model: IModel) => model?.allowedGroups?.length > 0 ? `${model.allowedGroups.join(', ')}` : (public), }, { id: 'modelStatus', @@ -79,13 +90,16 @@ export const CARD_DEFINITIONS = { ), }, ], -}; +}); + +// Keep the original export for backward compatibility +export const CARD_DEFINITIONS = createCardDefinitions(); export const PAGE_SIZE_OPTIONS = DEFAULT_PAGE_SIZE_OPTIONS('Models'); export const DEFAULT_PREFERENCES: CollectionPreferencesProps.Preferences = { pageSize: 12, - visibleContent: ['modelName', 'modelFeatures', 'modelType', 'modelUrl', 'streaming', 'hosting', 'instanceType', 'modelStatus'], + visibleContent: ['modelName', 'modelFeatures', 'modelType', 'modelUrl', 'streaming', 'hosting', 'instanceType', 'modelDescription', 'allowedGroups', 'modelStatus'], }; export const VISIBLE_CONTENT_OPTIONS = [ @@ -99,6 +113,8 @@ export const VISIBLE_CONTENT_OPTIONS = [ { id: 'streaming', label: 'Streaming' }, { id: 'hosting', label: 'LISA-Hosted Infrastructure' }, { id: 'instanceType', label: 'Instance Type' }, + { id: 'modelDescription', label: 'Description' }, + { id: 'allowedGroups', label: 'Allowed Groups' }, { id: 'modelStatus', label: 'Status' }, ], }, diff --git a/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx b/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx new file mode 100644 index 000000000..63ef4697b --- /dev/null +++ b/lib/user-interface/react/src/components/model-management/components/ModelComparisonComponents.tsx @@ -0,0 +1,269 @@ +/** + 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, memo } from 'react'; +import { + Box, + SpaceBetween, + Header, + Select, + Button, + Container, + ColumnLayout, + Alert, + SelectProps, + PromptInput +} from '@cloudscape-design/components'; +import { IModel } from '../../../shared/model/model-management.model'; +import { ComparisonResponse, ModelSelection } from '../hooks/useModelComparison.hook'; +import { + MODEL_COMPARISON_CONFIG, + UI_CONFIG, + PLACEHOLDERS, + ARIA_LABELS +} from '../config/modelComparison.config'; +import { LisaChatMessage, MessageTypes } from '../../types'; +import Message from '../../chatbot/components/Message'; +import { IChatConfiguration } from '../../../shared/model/chat.configurations.model'; +import { downloadFile } from '@/shared/util/downloader'; + +type ModelSelectionSectionProps = { + modelSelections: ModelSelection[]; + availableModels: SelectProps.Option[]; + onAddModel: () => void; + onRemoveModel: (id: string) => void; + onUpdateSelection: (id: string, selectedModel: SelectProps.Option | null) => void; + getAvailableModelsForSelection: (id: string) => SelectProps.Option[]; +}; + +export const ModelSelectionSection = memo(function ModelSelectionSection ({ + modelSelections, + onAddModel, + onRemoveModel, + onUpdateSelection, + getAvailableModelsForSelection +}: ModelSelectionSectionProps): ReactElement { + return ( + = MODEL_COMPARISON_CONFIG.MAX_MODELS} + ariaLabel={ARIA_LABELS.ADD_MODEL} + /> + } + > + Model Selection & Prompt + + } + > + + {modelSelections.map((selection, index) => ( + + + + Model {index + 1} + + {modelSelections.length > MODEL_COMPARISON_CONFIG.MIN_MODELS && ( + + + } + > + Model Comparison + + + } + > + + + + + + + {responses.length > 0 && ( + + )} + + {availableModels.length < 2 && ( + + {MESSAGES.INSUFFICIENT_MODELS} + + )} + + + + + + + ); +} diff --git a/lib/user-interface/react/src/pages/ModelLibrary.tsx b/lib/user-interface/react/src/pages/ModelLibrary.tsx new file mode 100644 index 000000000..80d1a2bb1 --- /dev/null +++ b/lib/user-interface/react/src/pages/ModelLibrary.tsx @@ -0,0 +1,28 @@ +/** + 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, useEffect } from 'react'; +import ModelLibraryComponent from '../components/model-management/ModelLibraryComponent'; + +export function ModelLibrary ({ setNav }): ReactElement { + useEffect(() => { + setNav(null); + }, [setNav]); + + return ; +} + +export default ModelLibrary; diff --git a/lib/user-interface/react/src/shared/modal/ReviewChanges.tsx b/lib/user-interface/react/src/shared/modal/ReviewChanges.tsx index 78642b254..f91797797 100644 --- a/lib/user-interface/react/src/shared/modal/ReviewChanges.tsx +++ b/lib/user-interface/react/src/shared/modal/ReviewChanges.tsx @@ -44,6 +44,15 @@ export function ReviewChanges (props: ReviewChangesProps): ReactElement { for (const key in json) { const value = json[key]; + + // Special handling for allowedGroups - show "Public" when empty + if (key === 'allowedGroups' && _.isArray(value) && _.isEmpty(value)) { + output.push(( +
  • {_.startCase(key)}: Public

    +
  • )); + continue; + } + const isNested = _.isObject(value); output.push((
  • {_.startCase(key)}{isNested ? '' : `: ${value}`}

    diff --git a/lib/user-interface/react/src/shared/model/configuration.model.ts b/lib/user-interface/react/src/shared/model/configuration.model.ts index 59c02a156..5b365ccf8 100644 --- a/lib/user-interface/react/src/shared/model/configuration.model.ts +++ b/lib/user-interface/react/src/shared/model/configuration.model.ts @@ -17,7 +17,8 @@ import { z } from 'zod'; export type SystemConfiguration = { systemBanner: ISystemBannerConfiguration, - enabledComponents: IEnabledComponents + enabledComponents: IEnabledComponents, + global: IGlobalConfiguration }; export type IEnabledComponents = { @@ -32,7 +33,9 @@ export type IEnabledComponents = { documentSummarization: boolean; showRagLibrary: boolean; showPromptTemplateLibrary: boolean; + enableModelComparisonUtility: boolean; mcpConnections: boolean; + modelLibrary: boolean; }; export type ISystemBannerConfiguration = { @@ -42,6 +45,10 @@ export type ISystemBannerConfiguration = { backgroundColor: string; }; +export type IGlobalConfiguration = { + defaultModel: string; +}; + export type BaseConfiguration = { configScope: string; versionId: number; @@ -73,10 +80,20 @@ export const enabledComponentsSchema = z.object({ editNumOfRagDocument: z.boolean().default(true), uploadRagDocs: z.boolean().default(true), uploadContextDocs: z.boolean().default(true), - documentSummarization: z.boolean().default(true) + documentSummarization: z.boolean().default(true), + showRagLibrary: z.boolean().default(true), + showPromptTemplateLibrary: z.boolean().default(true), + mcpConnections: z.boolean().default(true), + modelLibrary: z.boolean().default(true), + enableModelComparisonUtility: z.boolean().default(false) +}); + +export const globalConfigSchema = z.object({ + defaultModel: z.string().optional() }); export const SystemConfigurationSchema = z.object({ systemBanner: systemBannerConfigSchema.default(systemBannerConfigSchema.parse({})), enabledComponents: enabledComponentsSchema.default(enabledComponentsSchema.parse({})), + global: globalConfigSchema.default(globalConfigSchema.parse({})), }); 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 fbff018b1..61a42273e 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 @@ -15,6 +15,7 @@ */ import { z } from 'zod'; import { AttributeEditorSchema } from '../form/environment-variables'; +import { IChatConfiguration } from './chat.configurations.model'; export enum ModelStatus { Creating = 'Creating', @@ -94,7 +95,9 @@ export type IModel = { features?: ModelFeature[]; modelId: string; modelName: string; + modelDescription?: string; modelUrl: string; + modelConfig: IChatConfiguration; streaming: boolean; modelType: ModelType; instanceType: string; @@ -102,6 +105,7 @@ export type IModel = { containerConfig: IContainerConfig; autoScalingConfig: IAutoScalingConfig; loadBalancerConfig: ILoadBalancerConfig; + allowedGroups?: string[]; }; export type IModelListResponse = { @@ -112,6 +116,7 @@ export type IModelRequest = { features: ModelFeature[]; modelId: string; modelName: string; + modelDescription?: string; modelUrl: string; streaming: boolean; multiModal: boolean; @@ -122,6 +127,7 @@ export type IModelRequest = { autoScalingConfig: IAutoScalingConfig; loadBalancerConfig: ILoadBalancerConfig; lisaHostedModel: boolean; + allowedGroups?: string[]; }; export type ModelFeature = { @@ -129,12 +135,24 @@ export type ModelFeature = { overview: string; }; +export type IAutoScalingInstanceConfig = { + minCapacity?: number; + maxCapacity?: number; + desiredCapacity?: number; + cooldown?: number; + defaultInstanceWarmup?: number; +}; + export type IModelUpdateRequest = { modelId: string; streaming?: boolean; enabled?: boolean; modelType?: ModelType; - autoScalingInstanceConfig?: IAutoScalingConfig; + modelDescription?: string; + allowedGroups?: string[]; + features?: ModelFeature[]; + autoScalingInstanceConfig?: IAutoScalingInstanceConfig; + containerConfig?: IContainerConfig; }; const containerHealthCheckConfigSchema = z.object({ @@ -207,6 +225,7 @@ export const ModelRequestSchema = z.object({ .regex(/^[a-z0-9].*[a-z0-9]$/i, {message: 'Must start and end with an alphanumeric character.'}) .default(''), modelName: z.string().min(1).default(''), + modelDescription: z.string().default(''), modelUrl: z.string().default(''), streaming: z.boolean().default(false), features: z.array(z.object({ @@ -220,6 +239,7 @@ export const ModelRequestSchema = z.object({ containerConfig: containerConfigSchema.default(containerConfigSchema.parse({})), autoScalingConfig: autoScalingConfigSchema.default(autoScalingConfigSchema.parse({})), loadBalancerConfig: loadBalancerConfigSchema.default(loadBalancerConfigSchema.parse({})), + allowedGroups: z.array(z.string()).default([]), }).superRefine((value, context) => { if (value.lisaHostedModel) { const instanceTypeValidator = z.string().min(1, {message: 'Required for LISA hosted models.'}); 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 eb1358c31..80e314596 100644 --- a/lib/user-interface/react/src/shared/reducers/session.reducer.ts +++ b/lib/user-interface/react/src/shared/reducers/session.reducer.ts @@ -61,7 +61,8 @@ export const sessionApi = createApi({ }; return message; }), - configuration: session.configuration + configuration: session.configuration, + name: session.name } }), transformErrorResponse: (baseQueryReturnValue) => { @@ -73,6 +74,23 @@ export const sessionApi = createApi({ }, invalidatesTags: ['sessions'], }), + updateSessionName: builder.mutation({ + query: (session) => ({ + url: `/session/${session.sessionId}/name`, + method: 'PUT', + data: { + name: session.name + } + }), + invalidatesTags: ['sessions'], + transformErrorResponse: (baseQueryReturnValue) => { + // transform into SerializedError + return { + name: 'Rename Session Error', + message: baseQueryReturnValue.data?.type === 'RequestValidationError' ? baseQueryReturnValue.data.detail.map((error) => error.msg).join(', ') : baseQueryReturnValue.data.message + }; + }, + }), attachImageToSession: builder.mutation({ query: (attachImageRequest) => ({ url: `/session/${attachImageRequest.sessionId}/attachImage`, @@ -126,6 +144,7 @@ export const { useDeleteSessionByIdMutation, useDeleteAllSessionsForUserMutation, useUpdateSessionMutation, + useUpdateSessionNameMutation, useLazyGetSessionByIdQuery, useGetSessionHealthQuery, useAttachImageToSessionMutation diff --git a/lisa-sdk/pyproject.toml b/lisa-sdk/pyproject.toml index 20cb69947..031610fbe 100644 --- a/lisa-sdk/pyproject.toml +++ b/lisa-sdk/pyproject.toml @@ -3,7 +3,7 @@ requires-python = ">=3.11" [tool.poetry] name = "lisapy" -version = "5.0.0" +version = "5.0.1" 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" diff --git a/package-lock.json b/package-lock.json index 83acbae24..a4e5b354d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "version": "1.0.0", "license": "Apache-2.0", "devDependencies": { - "vitepress": "^1.6.3" + "vitepress": "^1.6.4" } }, "lib/user-interface/react": { @@ -137,7 +137,7 @@ "typescript": "~5.1.6", "unraw": "^3.0.0", "use-mcp": "^0.0.18", - "vitepress": "^1.6.3" + "vitepress": "^1.6.4" }, "devDependencies": { "@types/markdown-it": "^14.1.2", @@ -165,6 +165,21 @@ "vite": "^6.3.4" } }, + "node_modules/@algolia/abtesting": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.1.0.tgz", + "integrity": "sha512-sEyWjw28a/9iluA37KLGu8vjxEIlb60uxznfTUmXImy7H5NvbpSO6yYgmgH5KiD7j+zTUUihiST0jEP12IoXow==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/@algolia/autocomplete-core": { "version": "1.17.7", "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", @@ -211,180 +226,180 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.34.0.tgz", - "integrity": "sha512-d6ardhDtQsnMpyr/rPrS3YuIE9NYpY4rftkC7Ap9tyuhZ/+V3E/LH+9uEewPguKzVqduApdwJzYq2k+vAXVEbQ==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.35.0.tgz", + "integrity": "sha512-uUdHxbfHdoppDVflCHMxRlj49/IllPwwQ2cQ8DLC4LXr3kY96AHBpW0dMyi6ygkn2MtFCc6BxXCzr668ZRhLBQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.34.0.tgz", - "integrity": "sha512-WXIByjHNA106JO1Dj6b4viSX/yMN3oIB4qXr2MmyEmNq0MgfuPfPw8ayLRIZPa9Dp27hvM3G8MWJ4RG978HYFw==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.35.0.tgz", + "integrity": "sha512-SunAgwa9CamLcRCPnPHx1V2uxdQwJGqb1crYrRWktWUdld0+B2KyakNEeVn5lln4VyeNtW17Ia7V7qBWyM/Skw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.34.0.tgz", - "integrity": "sha512-JeN1XJLZIkkv6yK0KT93CIXXk+cDPUGNg5xeH4fN9ZykYFDWYRyqgaDo+qvg4RXC3WWkdQ+hogQuuCk4Y3Eotw==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.35.0.tgz", + "integrity": "sha512-ipE0IuvHu/bg7TjT2s+187kz/E3h5ssfTtjpg1LbWMgxlgiaZIgTTbyynM7NfpSJSKsgQvCQxWjGUO51WSCu7w==", "license": "MIT", "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-insights": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.34.0.tgz", - "integrity": "sha512-gdFlcQa+TWXJUsihHDlreFWniKPFIQ15i5oynCY4m9K3DCex5g5cVj9VG4Hsquxf2t6Y0yv8w6MvVTGDO8oRLw==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.35.0.tgz", + "integrity": "sha512-UNbCXcBpqtzUucxExwTSfAe8gknAJ485NfPN6o1ziHm6nnxx97piIbcBQ3edw823Tej2Wxu1C0xBY06KgeZ7gA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.34.0.tgz", - "integrity": "sha512-g91NHhIZDkh1IUeNtsUd8V/ZxuBc2ByOfDqhCkoQY3Z/mZszhpn3Czn6AR5pE81fx793vMaiOZvQVB5QttArkQ==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.35.0.tgz", + "integrity": "sha512-/KWjttZ6UCStt4QnWoDAJ12cKlQ+fkpMtyPmBgSS2WThJQdSV/4UWcqCUqGH7YLbwlj3JjNirCu3Y7uRTClxvA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.34.0.tgz", - "integrity": "sha512-cvRApDfFrlJ3Vcn37U4Nd/7S6T8cx7FW3mVLJPqkkzixv8DQ/yV+x4VLirxOtGDdq3KohcIbIGWbg1QuyOZRvQ==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.35.0.tgz", + "integrity": "sha512-8oCuJCFf/71IYyvQQC+iu4kgViTODbXDk3m7yMctEncRSRV+u2RtDVlpGGfPlJQOrAY7OONwJlSHkmbbm2Kp/w==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.34.0.tgz", - "integrity": "sha512-m9tK4IqJmn+flEPRtuxuHgiHmrKV0su5fuVwVpq8/es4DMjWMgX1a7Lg1PktvO8AbKaTp9kTtBAPnwXpuCwmEg==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.35.0.tgz", + "integrity": "sha512-FfmdHTrXhIduWyyuko1YTcGLuicVbhUyRjO3HbXE4aP655yKZgdTIfMhZ/V5VY9bHuxv/fGEh3Od1Lvv2ODNTg==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/ingestion": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.34.0.tgz", - "integrity": "sha512-2rxy4XoeRtIpzxEh5u5UgDC5HY4XbNdjzNgFx1eDrfFkSHpEVjirtLhISMy2N5uSFqYu1uUby5/NC1Soq8J7iw==", + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.35.0.tgz", + "integrity": "sha512-gPzACem9IL1Co8mM1LKMhzn1aSJmp+Vp434An4C0OBY4uEJRcqsLN3uLBlY+bYvFg8C8ImwM9YRiKczJXRk0XA==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.34.0.tgz", - "integrity": "sha512-OJiDhlJX8ZdWAndc50Z6aUEW/YmnhFK2ul3rahMw5/c9Damh7+oY9SufoK2LimJejy+65Qka06YPG29v2G/vww==", + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.35.0.tgz", + "integrity": "sha512-w9MGFLB6ashI8BGcQoVt7iLgDIJNCn4OIu0Q0giE3M2ItNrssvb8C0xuwJQyTy1OFZnemG0EB1OvXhIHOvQwWw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.34.0.tgz", - "integrity": "sha512-fzNQZAdVxu/Gnbavy8KW5gurApwdYcPW6+pjO7Pw8V5drCR3eSqnOxSvp79rhscDX8ezwqMqqK4F3Hsq+KpRzg==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.35.0.tgz", + "integrity": "sha512-AhrVgaaXAb8Ue0u2nuRWwugt0dL5UmRgS9LXe0Hhz493a8KFeZVUE56RGIV3hAa6tHzmAV7eIoqcWTQvxzlJeQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "@algolia/client-common": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.34.0.tgz", - "integrity": "sha512-gEI0xjzA/xvMpEdYmgQnf6AQKllhgKRtnEWmwDrnct+YPIruEHlx1dd7nRJTy/33MiYcCxkB4khXpNrHuqgp3Q==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.35.0.tgz", + "integrity": "sha512-diY415KLJZ6x1Kbwl9u96Jsz0OstE3asjXtJ9pmk1d+5gPuQ5jQyEsgC+WmEXzlec3iuVszm8AzNYYaqw6B+Zw==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0" + "@algolia/client-common": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.34.0.tgz", - "integrity": "sha512-5SwGOttpbACT4jXzfSJ3mnTcF46SVNSnZ1JjxC3qBa3qKi4U0CJGzuVVy3L798u8dG5H0SZ2MAB5v7180Gnqew==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.35.0.tgz", + "integrity": "sha512-uydqnSmpAjrgo8bqhE9N1wgcB98psTRRQXcjc4izwMB7yRl9C8uuAQ/5YqRj04U0mMQ+fdu2fcNF6m9+Z1BzDQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0" + "@algolia/client-common": "5.35.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.34.0.tgz", - "integrity": "sha512-409XlyIyEXrxyGjWxd0q5RASizHSRVUU0AXPCEdqnbcGEzbCgL1n7oYI8YxzE/RqZLha+PNwWCcTVn7EE5tyyQ==", + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.35.0.tgz", + "integrity": "sha512-RgLX78ojYOrThJHrIiPzT4HW3yfQa0D7K+MQ81rhxqaNyNBu4F1r+72LNHYH/Z+y9I1Mrjrd/c/Ue5zfDgAEjQ==", "license": "MIT", "dependencies": { - "@algolia/client-common": "5.34.0" + "@algolia/client-common": "5.35.0" }, "engines": { "node": ">= 14.0.0" @@ -2987,9 +3002,9 @@ "license": "BSD-3-Clause" }, "node_modules/@iconify-json/simple-icons": { - "version": "1.2.43", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.43.tgz", - "integrity": "sha512-JERgKGFRfZdyjGyTvVBVW5rftahy9tNUX+P+0QUnbaAEWvEMexXHE9863YVMVrIRhoj/HybGsibg8ZWieo/NDg==", + "version": "1.2.48", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.48.tgz", + "integrity": "sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==", "license": "CC0-1.0", "dependencies": { "@iconify/types": "*" @@ -5744,24 +5759,25 @@ } }, "node_modules/algoliasearch": { - "version": "5.34.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.34.0.tgz", - "integrity": "sha512-wioVnf/8uuG8Bmywhk5qKIQ3wzCCtmdvicPRb0fa3kKYGGoewfgDqLEaET1MV2NbTc3WGpPv+AgauLVBp1nB9A==", - "license": "MIT", - "dependencies": { - "@algolia/client-abtesting": "5.34.0", - "@algolia/client-analytics": "5.34.0", - "@algolia/client-common": "5.34.0", - "@algolia/client-insights": "5.34.0", - "@algolia/client-personalization": "5.34.0", - "@algolia/client-query-suggestions": "5.34.0", - "@algolia/client-search": "5.34.0", - "@algolia/ingestion": "1.34.0", - "@algolia/monitoring": "1.34.0", - "@algolia/recommend": "5.34.0", - "@algolia/requester-browser-xhr": "5.34.0", - "@algolia/requester-fetch": "5.34.0", - "@algolia/requester-node-http": "5.34.0" + "version": "5.35.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.35.0.tgz", + "integrity": "sha512-Y+moNhsqgLmvJdgTsO4GZNgsaDWv8AOGAaPeIeHKlDn/XunoAqYbA+XNpBd1dW8GOXAUDyxC9Rxc7AV4kpFcIg==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.1.0", + "@algolia/client-abtesting": "5.35.0", + "@algolia/client-analytics": "5.35.0", + "@algolia/client-common": "5.35.0", + "@algolia/client-insights": "5.35.0", + "@algolia/client-personalization": "5.35.0", + "@algolia/client-query-suggestions": "5.35.0", + "@algolia/client-search": "5.35.0", + "@algolia/ingestion": "1.35.0", + "@algolia/monitoring": "1.35.0", + "@algolia/recommend": "5.35.0", + "@algolia/requester-browser-xhr": "5.35.0", + "@algolia/requester-fetch": "5.35.0", + "@algolia/requester-node-http": "5.35.0" }, "engines": { "node": ">= 14.0.0" @@ -6938,7 +6954,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001699", + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "dev": true, "funding": [ { @@ -8438,7 +8456,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9522,11 +9539,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -14151,9 +14172,9 @@ "license": "MIT" }, "node_modules/preact": { - "version": "10.26.9", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.9.tgz", - "integrity": "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA==", + "version": "10.27.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.1.tgz", + "integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==", "license": "MIT", "funding": { "type": "opencollective", @@ -16142,10 +16163,11 @@ "dev": true }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.14" } @@ -16941,9 +16963,9 @@ } }, "node_modules/vitepress": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.3.tgz", - "integrity": "sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", "license": "MIT", "dependencies": { "@docsearch/css": "3.8.2", diff --git a/package.json b/package.json index fbd93c547..e802df98c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@awslabs/lisa", - "version": "5.0.0", + "version": "5.0.1", "description": "A scalable infrastructure-as-code solution for self-hosting and orchestrating LLM inference with RAG capabilities, providing low-latency access to generative AI and embedding models across multiple providers.", "homepage": "https://awslabs.github.io/LISA/", "license": "Apache-2.0", diff --git a/test/cdk/stacks/nag.test.ts b/test/cdk/stacks/nag.test.ts index 5934eca8e..3800ef201 100644 --- a/test/cdk/stacks/nag.test.ts +++ b/test/cdk/stacks/nag.test.ts @@ -33,14 +33,14 @@ enum NagType { const nagResults: NagResult = { LisaApiBase: [1,7,0,7], LisaApiDeployment: [0,0,0,0], - LisaChat: [4,60,0,67], + LisaChat: [4,62,0,69], LisaCore: [0,1,0,6], LisaDocs: [1,22,0,12], LisaIAM: [0,14,0,0], - LisaModels: [1,73,0,60], + LisaModels: [1,77,0,64], LisaNetworking: [1,2,3,5], LisaRAG: [3,53,0,50], - LisaServe: [1,22,0,33], + LisaServe: [1,24,0,32], LisaUI: [0,15,0,7], LisaMetrics: [1,11,0,12] }; diff --git a/test/lambda/test_db_setup_iam_auth.py b/test/lambda/test_db_setup_iam_auth.py index 49df00ef5..796429108 100644 --- a/test/lambda/test_db_setup_iam_auth.py +++ b/test/lambda/test_db_setup_iam_auth.py @@ -90,7 +90,7 @@ def test_get_db_credentials_success(): assert result == secret_value # Verify the client was called correctly - mock_client.assert_called_once_with("secretsmanager") + mock_client.assert_called_once_with("secretsmanager", region_name="us-east-1") mock_secretsmanager.get_secret_value.assert_called_once_with(SecretId=secret_arn) diff --git a/test/lambda/test_dockerimagebuilder.py b/test/lambda/test_dockerimagebuilder.py index f27ac9398..5c679be78 100644 --- a/test/lambda/test_dockerimagebuilder.py +++ b/test/lambda/test_dockerimagebuilder.py @@ -44,7 +44,9 @@ @pytest.fixture def lambda_context(): """Create a mock Lambda context.""" - return MagicMock() + context = MagicMock() + context.log_group_name = "/aws/lambda/test-function" + return context def test_handler_success(lambda_context): @@ -178,7 +180,7 @@ def test_handler_ssm_error(lambda_context): handler(event, lambda_context) -def test_user_data_template_rendering(): +def test_user_data_template_rendering(lambda_context): """Test that user data template is properly rendered.""" event = {"base_image": "python:3.9-slim", "layer_to_add": "test-layer"} diff --git a/test/lambda/test_repository_lambda.py b/test/lambda/test_repository_lambda.py index 72db09439..a12abeb28 100644 --- a/test/lambda/test_repository_lambda.py +++ b/test/lambda/test_repository_lambda.py @@ -223,7 +223,6 @@ def mock_boto3_client(service_name, region_name=None, config=None): "create_env_variables": mock_create_env, "repository.vector_store_repo": mock_vs_repo, "repository.rag_document_repo": mock_doc_repo, - "utilities.common_functions": mock_common, "lisapy": mock_lisapy, "lisapy.langchain": mock_lisapy_langchain, "langchain_community": mock_langchain_community, @@ -974,7 +973,7 @@ def test_repository_access_validation(): } repository = {"allowedGroups": ["admin-group"]} - with patch("utilities.common_functions.is_admin", return_value=True): + with patch("repository.lambda_functions.is_admin", return_value=True): # Admin should always have access assert _ensure_repository_access(event, repository) is None @@ -984,7 +983,7 @@ def test_repository_access_validation(): } repository = {"allowedGroups": ["test-group"]} - with patch("utilities.common_functions.is_admin", return_value=False): + with patch("repository.lambda_functions.is_admin", return_value=False): # User has the right group assert _ensure_repository_access(event, repository) is None @@ -994,7 +993,7 @@ def test_repository_access_validation(): } repository = {"allowedGroups": ["test-group"]} - with patch("utilities.common_functions.is_admin", return_value=False): + with patch("repository.lambda_functions.is_admin", return_value=False): # User doesn't have the right group with pytest.raises(HTTPException) as exc_info: _ensure_repository_access(event, repository) @@ -1341,20 +1340,20 @@ def test_get_embeddings_pipeline(): def test_user_has_group(): - """Test user_has_group helper function""" - from repository.lambda_functions import user_has_group + """Test user_has_group_access helper function""" + from utilities.common_functions import user_has_group_access # Test user has group - assert user_has_group(["group1", "group2"], ["group2", "group3"]) is True + assert user_has_group_access(["group1", "group2"], ["group2", "group3"]) is True # Test user doesn't have group - assert user_has_group(["group1", "group2"], ["group3", "group4"]) is False + assert user_has_group_access(["group1", "group2"], ["group3", "group4"]) is False # Test empty user groups - assert user_has_group([], ["group1"]) is False + assert user_has_group_access([], ["group1"]) is False # Test empty allowed groups - this returns True according to the actual implementation - assert user_has_group(["group1"], []) is True + assert user_has_group_access(["group1"], []) is True def test_real_list_all_function(): @@ -1563,7 +1562,11 @@ def test_real_download_document_function(): mock_vs_repo.find_repository_by_id.return_value = {"allowedGroups": ["test-group"], "status": "active"} - mock_doc_repo.find_by_id.return_value = {"username": "test-user", "source": "s3://test-bucket/test-key"} + # Create a mock RagDocument object + mock_doc = MagicMock() + mock_doc.source = "s3://test-bucket/test-key" + mock_doc.username = "test-user" + mock_doc_repo.find_by_id.return_value = mock_doc mock_s3.generate_presigned_url.return_value = "https://test-url" @@ -1578,8 +1581,9 @@ def test_real_download_document_function(): # The function is wrapped by api_wrapper, so we get an HTTP response assert result["statusCode"] == 200 - body = json.loads(result["body"]) - assert body == "https://test-url" + # The function returns a string URL, which gets JSON serialized by api_wrapper + # So the body is a JSON-encoded string + assert result["body"] == '"https://test-url"' def test_real_list_docs_function(): @@ -1754,3 +1758,52 @@ def test_ensure_document_ownership_edge_cases(): with pytest.raises(ValueError): _ensure_document_ownership(event, docs) + + +def test_real_similarity_search_bedrock_kb_function(): + """Test the actual similarity_search function for Bedrock Knowledge Base repositories""" + from repository.lambda_functions import similarity_search + + with patch("repository.lambda_functions.vs_repo") as mock_vs_repo, patch( + "repository.lambda_functions.bedrock_client" + ) as mock_bedrock, patch("repository.lambda_functions.get_groups") as mock_get_groups: + + mock_get_groups.return_value = ["test-group"] + mock_vs_repo.find_repository_by_id.return_value = { + "type": "bedrock_knowledge_base", + "allowedGroups": ["test-group"], + "bedrockKnowledgeBaseConfig": {"bedrockKnowledgeBaseId": "kb-123"}, + "status": "active", + } + + mock_bedrock.retrieve.return_value = { + "retrievalResults": [ + { + "content": {"text": "KB doc content"}, + "location": {"s3Location": {"uri": "s3://bucket/path/doc1.pdf"}}, + }, + { + "content": {"text": "Second"}, + "location": {"s3Location": {"uri": "s3://bucket/path/doc2.txt"}}, + }, + ] + } + + event = { + "requestContext": { + "authorizer": {"claims": {"username": "test-user"}, "groups": json.dumps(["test-group"])} + }, + "pathParameters": {"repositoryId": "test-repo"}, + "queryStringParameters": {"modelName": "test-model", "query": "test query", "topK": "2"}, + } + + result = similarity_search(event, SimpleNamespace()) + + assert result["statusCode"] == 200 + body = json.loads(result["body"]) + assert "docs" in body + assert len(body["docs"]) == 2 + first_doc = body["docs"][0]["Document"] + assert first_doc["page_content"] == "KB doc content" + assert first_doc["metadata"]["source"] == "s3://bucket/path/doc1.pdf" + assert first_doc["metadata"]["name"] == "doc1.pdf" diff --git a/test/lambda/test_update_model_state_machine.py b/test/lambda/test_update_model_state_machine.py index c5d7c5de3..b586db8a4 100644 --- a/test/lambda/test_update_model_state_machine.py +++ b/test/lambda/test_update_model_state_machine.py @@ -38,7 +38,7 @@ os.environ["MANAGEMENT_KEY_NAME"] = "test-management-key" os.environ["LITELLM_CONFIG_OBJ"] = '{"litellm_settings": {"drop_params": true}}' -# Create a real retry config +# Create retry config retry_config = Config(retries=dict(max_attempts=3), defaults_mode="standard") # Create mock modules @@ -52,7 +52,7 @@ mock_litellm_client.add_model.return_value = {"model_info": {"id": "test-litellm-id"}} mock_litellm_client.delete_model.return_value = {"status": "deleted"} -# First, patch sys.modules +# Patch sys.modules patch.dict( "sys.modules", { @@ -60,7 +60,7 @@ }, ).start() -# Then patch the specific functions +# Patch functions patch("utilities.common_functions.get_cert_path", mock_common.get_cert_path).start() patch("utilities.common_functions.get_rest_api_container_endpoint", mock_common.get_rest_api_container_endpoint).start() patch("utilities.common_functions.retry_config", retry_config).start() @@ -70,6 +70,8 @@ mock_autoscaling = MagicMock() mock_iam = MagicMock() mock_secrets = MagicMock() +mock_ecs = MagicMock() +mock_cfn = MagicMock() mock_autoscaling.update_auto_scaling_group.return_value = {} mock_autoscaling.describe_auto_scaling_groups.return_value = { @@ -79,8 +81,62 @@ } mock_secrets.get_secret_value.return_value = {"SecretString": "test-secret"} +# Mock ECS client responses +mock_ecs.describe_services.return_value = { + "services": [ + { + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "deployments": [ + { + "status": "PRIMARY", + "rolloutState": "COMPLETED", + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2", + } + ], + } + ] +} +mock_ecs.describe_task_definition.return_value = { + "taskDefinition": { + "family": "test-task-def", + "taskRoleArn": "arn:aws:iam::123456789012:role/test-role", + "executionRoleArn": "arn:aws:iam::123456789012:role/test-execution-role", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "256", + "memory": "512", + "containerDefinitions": [ + { + "name": "test-container", + "environment": [ + {"name": "EXISTING_VAR", "value": "existing_value"}, + {"name": "TO_UPDATE", "value": "old_value"}, + ], + } + ], + } +} +mock_ecs.register_task_definition.return_value = { + "taskDefinition": {"taskDefinitionArn": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2"} +} +mock_ecs.update_service.return_value = {} + +# Mock CloudFormation client responses +mock_cfn.describe_stack_resources.return_value = { + "StackResources": [ + { + "ResourceType": "AWS::ECS::Service", + "PhysicalResourceId": "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service", + }, + { + "ResourceType": "AWS::ECS::Cluster", + "PhysicalResourceId": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + }, + ] +} + -# Create comprehensive mock for boto3.client to handle all possible service requests +# Mock boto3.client def mock_boto3_client(service, **kwargs): if service == "autoscaling": return mock_autoscaling @@ -88,6 +144,10 @@ def mock_boto3_client(service, **kwargs): return mock_iam elif service == "secretsmanager": return mock_secrets + elif service == "ecs": + return mock_ecs + elif service == "cloudformation": + return mock_cfn elif service == "ssm": # Return a basic mock for SSM mock_ssm = MagicMock() @@ -106,8 +166,21 @@ def mock_boto3_client(service, **kwargs): from models.domain_objects import ModelStatus -# Now import the state machine functions -from models.state_machine.update_model import handle_finish_update, handle_job_intake, handle_poll_capacity +# Import state machine functions +from models.state_machine.update_model import ( + _get_metadata_update_handlers, + _process_metadata_updates, + _update_container_config, + _update_simple_field, + create_updated_task_definition, + get_ecs_resources_from_stack, + handle_ecs_update, + handle_finish_update, + handle_job_intake, + handle_poll_capacity, + handle_poll_ecs_deployment, + update_ecs_service, +) @pytest.fixture @@ -152,12 +225,16 @@ def sample_model(model_table): "auto_scaling_group": "test-asg", "litellm_id": "test-litellm-id", "model_url": "https://test-model.example.com/v1", + "cloudformation_stack_name": "test-stack", "model_config": { "modelId": "test-model", "modelName": "test-model-name", "modelType": "textgen", "streaming": True, "autoScalingConfig": {"minCapacity": 1, "maxCapacity": 3, "metricConfig": {"estimatedInstanceWarmup": 300}}, + "containerConfig": { + "environment": {"EXISTING_VAR": "existing_value", "TO_UPDATE": "old_value"}, + }, }, } model_table.put_item(Item=item) @@ -202,475 +279,477 @@ def litellm_only_model(model_table): return item -def test_handle_job_intake_enable_model(model_table, stopped_model, lambda_context): - """Test enabling a stopped model.""" - event = {"model_id": "stopped-model", "update_payload": {"enabled": True}} - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_job_intake(event, lambda_context) - - assert result["has_capacity_update"] is True - assert result["is_disable"] is False - assert result["asg_name"] == "test-asg" - assert result["model_warmup_seconds"] == 300 - assert result["current_model_status"] == ModelStatus.STARTING - - # Verify DDB update - item = model_table.get_item(Key={"model_id": "stopped-model"})["Item"] - assert item["model_status"] == ModelStatus.STARTING - - # Verify ASG update call - mock_autoscaling.update_auto_scaling_group.assert_called_once() - call_args = mock_autoscaling.update_auto_scaling_group.call_args - assert call_args[1]["AutoScalingGroupName"] == "test-asg" - assert call_args[1]["MinSize"] == 1 - assert call_args[1]["MaxSize"] == 3 - - -def test_handle_job_intake_disable_model(model_table, sample_model, lambda_context): - """Test disabling a running model.""" - # Reset mocks for this test +def test_handle_job_intake_comprehensive(model_table, sample_model, stopped_model, litellm_only_model, lambda_context): + """Comprehensive test covering multiple job intake scenarios.""" + # Reset mocks mock_autoscaling.reset_mock() mock_litellm_client.reset_mock() - event = {"model_id": "test-model", "update_payload": {"enabled": False}} - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_job_intake(event, lambda_context) - - assert result["has_capacity_update"] is False - assert result["is_disable"] is True - assert result["asg_name"] == "test-asg" - assert result["current_model_status"] == ModelStatus.STOPPING - - # Verify DDB update - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_status"] == ModelStatus.STOPPING - assert item["litellm_id"] is None + # Test 1: Enable stopped model + event1 = {"model_id": "stopped-model", "update_payload": {"enabled": True}} + result1 = handle_job_intake(event1, lambda_context) + assert result1["has_capacity_update"] is True + assert result1["current_model_status"] == ModelStatus.STARTING - # Verify LiteLLM deletion call + # Test 2: Disable running model + mock_autoscaling.reset_mock() + mock_litellm_client.reset_mock() + event2 = {"model_id": "test-model", "update_payload": {"enabled": False}} + result2 = handle_job_intake(event2, lambda_context) + assert result2["is_disable"] is True + assert result2["current_model_status"] == ModelStatus.STOPPING mock_litellm_client.delete_model.assert_called_once_with(identifier="test-litellm-id") - # Verify ASG update call - mock_autoscaling.update_auto_scaling_group.assert_called_once() - call_args = mock_autoscaling.update_auto_scaling_group.call_args - assert call_args[1]["AutoScalingGroupName"] == "test-asg" - assert call_args[1]["MinSize"] == 0 - assert call_args[1]["MaxSize"] == 0 - assert call_args[1]["DesiredCapacity"] == 0 - - -def test_handle_job_intake_autoscaling_update_running_model(model_table, sample_model, lambda_context): - """Test updating autoscaling configuration for a running model.""" - # Reset mocks for this test - mock_autoscaling.reset_mock() - - event = { - "model_id": "test-model", - "update_payload": {"autoScalingInstanceConfig": {"minCapacity": 2, "maxCapacity": 5, "desiredCapacity": 3}}, - } - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_job_intake(event, lambda_context) - - assert result["has_capacity_update"] is False - assert result["is_disable"] is False - assert result["current_model_status"] == ModelStatus.UPDATING - - # Verify model config update - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_config"]["autoScalingConfig"]["minCapacity"] == 2 - assert item["model_config"]["autoScalingConfig"]["maxCapacity"] == 5 - - # Verify ASG update call - mock_autoscaling.update_auto_scaling_group.assert_called_once() + # Test 3: Autoscaling with all parameters (cooldown, warmup, capacity) + # Reset model status back to IN_SERVICE for autoscaling test + model_table.update_item( + Key={"model_id": "test-model"}, + UpdateExpression="SET model_status = :ms", + ExpressionAttributeValues={":ms": ModelStatus.IN_SERVICE}, + ) + mock_autoscaling.reset_mock() + event3 = { + "model_id": "test-model", + "update_payload": { + "autoScalingInstanceConfig": { + "minCapacity": 2, + "maxCapacity": 5, + "desiredCapacity": 3, + "cooldown": 600, + "defaultInstanceWarmup": 400, + } + }, + } + result3 = handle_job_intake(event3, lambda_context) + assert result3["current_model_status"] == ModelStatus.UPDATING + # Verify autoscaling was called since the model is IN_SERVICE + assert mock_autoscaling.update_auto_scaling_group.called call_args = mock_autoscaling.update_auto_scaling_group.call_args assert call_args[1]["MinSize"] == 2 assert call_args[1]["MaxSize"] == 5 - assert call_args[1]["DesiredCapacity"] == 3 - - -def test_handle_job_intake_autoscaling_update_stopped_model(model_table, stopped_model, lambda_context): - """Test updating autoscaling configuration for a stopped model.""" - # Reset mocks for this test - mock_autoscaling.reset_mock() - - event = { - "model_id": "stopped-model", - "update_payload": {"autoScalingInstanceConfig": {"minCapacity": 2, "maxCapacity": 5}}, - } - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_job_intake(event, lambda_context) - - assert result["has_capacity_update"] is False - assert result["is_disable"] is False - assert result["current_model_status"] == ModelStatus.UPDATING - - # Verify model config update - item = model_table.get_item(Key={"model_id": "stopped-model"})["Item"] - assert item["model_config"]["autoScalingConfig"]["minCapacity"] == 2 - assert item["model_config"]["autoScalingConfig"]["maxCapacity"] == 5 - - # ASG should not be updated for stopped model - mock_autoscaling.update_auto_scaling_group.assert_not_called() - - -def test_handle_job_intake_metadata_update(model_table, sample_model, lambda_context): - """Test updating model metadata only.""" - event = {"model_id": "test-model", "update_payload": {"streaming": False, "modelType": "embedding"}} - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_job_intake(event, lambda_context) - - assert result["has_capacity_update"] is False - assert result["is_disable"] is False - assert result["current_model_status"] == ModelStatus.UPDATING - assert result["initial_model_status"] == ModelStatus.IN_SERVICE + assert call_args[1]["DefaultCooldown"] == 600 + assert call_args[1]["DefaultInstanceWarmup"] == 400 + + # Test 4: Container config with all features (env vars, health check, shared memory, deletion) + # Reset model status to IN_SERVICE for ECS update test + model_table.update_item( + Key={"model_id": "test-model"}, + UpdateExpression="SET model_status = :ms", + ExpressionAttributeValues={":ms": ModelStatus.IN_SERVICE}, + ) + event4 = { + "model_id": "test-model", + "update_payload": { + "containerConfig": { + "environment": { + "NEW_VAR": "new_value", + "TO_UPDATE": "LISA_MARKED_FOR_DELETION", # Test deletion + }, + "sharedMemorySize": 2048, + "healthCheckCommand": ["CMD-SHELL", "curl -f http://localhost:8080/health"], + "healthCheckInterval": 60, + "healthCheckTimeout": 10, + "healthCheckStartPeriod": 120, + "healthCheckRetries": 5, + } + }, + } + result4 = handle_job_intake(event4, lambda_context) + assert result4["needs_ecs_update"] is True + assert "container_metadata" in result4 + assert "TO_UPDATE" in result4["container_metadata"]["env_vars_to_delete"] - # Verify model config update + # Test 5: Multiple metadata updates + event5 = { + "model_id": "test-model", + "update_payload": { + "streaming": False, + "modelType": "embedding", + "modelDescription": "Updated description", + "allowedGroups": ["group1", "group2"], + "features": [{"name": "feature1", "overview": "overview1"}], + }, + } + result5 = handle_job_intake(event5, lambda_context) + assert result5["current_model_status"] == ModelStatus.UPDATING item = model_table.get_item(Key={"model_id": "test-model"})["Item"] assert item["model_config"]["streaming"] is False assert item["model_config"]["modelType"] == "embedding" -def test_handle_job_intake_model_not_found(model_table, lambda_context): - """Test handling when model is not found.""" - event = {"model_id": "nonexistent-model", "update_payload": {"enabled": True}} - +def test_handle_job_intake_errors(model_table, litellm_only_model, sample_model, lambda_context): + """Test error conditions in job intake.""" with patch("models.state_machine.update_model.model_table", model_table): + # Test 1: Model not found with pytest.raises(RuntimeError, match="Requested model 'nonexistent-model' was not found"): - handle_job_intake(event, lambda_context) + handle_job_intake({"model_id": "nonexistent-model", "update_payload": {"enabled": True}}, lambda_context) - -def test_handle_job_intake_litellm_only_model_activation_error(model_table, litellm_only_model, lambda_context): - """Test error when trying to activate/deactivate LiteLLM-only model.""" - event = {"model_id": "litellm-model", "update_payload": {"enabled": False}} - - with patch("models.state_machine.update_model.model_table", model_table): + # Test 2: LiteLLM-only model activation error with pytest.raises( RuntimeError, match="Cannot request AutoScaling updates to models that are not hosted by LISA" ): - handle_job_intake(event, lambda_context) + handle_job_intake({"model_id": "litellm-model", "update_payload": {"enabled": False}}, lambda_context) - -def test_handle_job_intake_concurrent_activation_and_autoscaling_error(model_table, sample_model, lambda_context): - """Test error when trying to do activation and autoscaling updates simultaneously.""" - event = { - "model_id": "test-model", - "update_payload": {"enabled": False, "autoScalingInstanceConfig": {"minCapacity": 2}}, - } - - with patch("models.state_machine.update_model.model_table", model_table): + # Test 3: Concurrent activation and autoscaling error with pytest.raises( RuntimeError, match="Cannot request AutoScaling updates at the same time as an enable or disable operation" ): - handle_job_intake(event, lambda_context) - - -def test_handle_poll_capacity_healthy_instances(lambda_context): - """Test polling capacity when instances are healthy.""" - event = {"model_id": "test-model", "asg_name": "test-asg", "remaining_capacity_polls": 20} - - result = handle_poll_capacity(event, lambda_context) - - assert result["should_continue_capacity_polling"] is False - assert result["remaining_capacity_polls"] == 19 - assert "polling_error" not in result - - -def test_handle_poll_capacity_unhealthy_instances(lambda_context): - """Test polling capacity when instances are not yet healthy.""" - event = {"model_id": "test-model", "asg_name": "test-asg", "remaining_capacity_polls": 20} + handle_job_intake( + { + "model_id": "test-model", + "update_payload": {"enabled": False, "autoScalingInstanceConfig": {"minCapacity": 2}}, + }, + lambda_context, + ) + + +def test_handle_poll_capacity_scenarios(lambda_context): + """Test all capacity polling scenarios.""" + # Test 1: Healthy instances + result1 = handle_poll_capacity( + {"model_id": "test-model", "asg_name": "test-asg", "remaining_capacity_polls": 20}, lambda_context + ) + assert result1["should_continue_capacity_polling"] is False + assert result1["remaining_capacity_polls"] == 19 - # Mock unhealthy instances + # Test 2: Unhealthy instances (continue polling) mock_autoscaling.describe_auto_scaling_groups.return_value = { "AutoScalingGroups": [ {"DesiredCapacity": 2, "Instances": [{"HealthStatus": "Healthy"}, {"HealthStatus": "Unhealthy"}]} ] } + result2 = handle_poll_capacity( + {"model_id": "test-model", "asg_name": "test-asg", "remaining_capacity_polls": 20}, lambda_context + ) + assert result2["should_continue_capacity_polling"] is True - result = handle_poll_capacity(event, lambda_context) - - assert result["should_continue_capacity_polling"] is True - assert result["remaining_capacity_polls"] == 19 - assert "polling_error" not in result - - -def test_handle_poll_capacity_max_polls_exceeded(lambda_context): - """Test polling capacity when max polls exceeded.""" - event = {"model_id": "test-model", "asg_name": "test-asg", "remaining_capacity_polls": 1} + # Test 3: Max polls exceeded (timeout) + result3 = handle_poll_capacity( + {"model_id": "test-model", "asg_name": "test-asg", "remaining_capacity_polls": 1}, lambda_context + ) + assert result3["should_continue_capacity_polling"] is False + assert "polling_error" in result3 - # Mock unhealthy instances + # Reset mock mock_autoscaling.describe_auto_scaling_groups.return_value = { "AutoScalingGroups": [ - {"DesiredCapacity": 2, "Instances": [{"HealthStatus": "Healthy"}, {"HealthStatus": "Unhealthy"}]} + {"DesiredCapacity": 2, "Instances": [{"HealthStatus": "Healthy"}, {"HealthStatus": "Healthy"}]} ] } - result = handle_poll_capacity(event, lambda_context) - - assert result["should_continue_capacity_polling"] is False - assert result["remaining_capacity_polls"] == 0 - assert "polling_error" in result - assert "did not start healthy instances" in result["polling_error"] - - -def test_handle_finish_update_enable_success(model_table, lambda_context): - """Test finishing update for successful model enable.""" - # Create model without litellm_id (enabled model should get one) - item = { - "model_id": "test-model", - "model_status": ModelStatus.STARTING, - "auto_scaling_group": "test-asg", - "model_url": "https://test-model.example.com/v1", - "model_config": {"modelName": "test-model-name"}, - } - model_table.put_item(Item=item) - - event = { - "model_id": "test-model", - "asg_name": "test-asg", - "has_capacity_update": True, - "is_disable": False, - "initial_model_status": ModelStatus.STOPPED, - } +def test_handle_finish_update_scenarios(model_table, lambda_context): + """Test all finish update scenarios.""" with patch("models.state_machine.update_model.model_table", model_table): - result = handle_finish_update(event, lambda_context) - - assert result["litellm_id"] == "test-litellm-id" - assert result["current_model_status"] == ModelStatus.IN_SERVICE - - # Verify DDB update - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_status"] == ModelStatus.IN_SERVICE - assert item["litellm_id"] == "test-litellm-id" - - # Verify LiteLLM add call - mock_litellm_client.add_model.assert_called_once() - call_args = mock_litellm_client.add_model.call_args - assert call_args[1]["model_name"] == "test-model" + # Test 1: Successful enable + item1 = { + "model_id": "enable-model", + "model_status": ModelStatus.STARTING, + "auto_scaling_group": "test-asg", + "model_url": "https://test-model.example.com/v1", + "model_config": {"modelName": "test-model-name"}, + } + model_table.put_item(Item=item1) + result1 = handle_finish_update( + { + "model_id": "enable-model", + "asg_name": "test-asg", + "has_capacity_update": True, + "is_disable": False, + "initial_model_status": ModelStatus.STOPPED, + }, + lambda_context, + ) + assert result1["current_model_status"] == ModelStatus.IN_SERVICE + assert result1["litellm_id"] == "test-litellm-id" + + # Test 2: Successful disable + item2 = { + "model_id": "disable-model", + "model_status": ModelStatus.STOPPING, + "auto_scaling_group": "test-asg", + "model_url": "https://test-model.example.com/v1", + "model_config": {"modelName": "test-model-name"}, + } + model_table.put_item(Item=item2) + result2 = handle_finish_update( + { + "model_id": "disable-model", + "asg_name": "test-asg", + "has_capacity_update": False, + "is_disable": True, + "initial_model_status": ModelStatus.IN_SERVICE, + }, + lambda_context, + ) + assert result2["current_model_status"] == ModelStatus.STOPPED + # Test 3: Metadata-only update + item3 = { + "model_id": "metadata-model", + "model_status": ModelStatus.UPDATING, + "auto_scaling_group": "test-asg", + "model_url": "https://test-model.example.com/v1", + "model_config": {"modelName": "test-model-name"}, + } + model_table.put_item(Item=item3) + result3 = handle_finish_update( + { + "model_id": "metadata-model", + "asg_name": "test-asg", + "has_capacity_update": False, + "is_disable": False, + "initial_model_status": ModelStatus.IN_SERVICE, + }, + lambda_context, + ) + assert result3["current_model_status"] == ModelStatus.IN_SERVICE -def test_handle_finish_update_disable_success(model_table, lambda_context): - """Test finishing update for successful model disable.""" - item = { - "model_id": "test-model", - "model_status": ModelStatus.STOPPING, - "auto_scaling_group": "test-asg", - "model_url": "https://test-model.example.com/v1", - "model_config": {"modelName": "test-model-name"}, - } - model_table.put_item(Item=item) + # Test 4: Polling error handling + mock_autoscaling.reset_mock() + item4 = { + "model_id": "error-model", + "model_status": ModelStatus.STARTING, + "auto_scaling_group": "test-asg", + "model_url": "https://test-model.example.com/v1", + "model_config": {"modelName": "test-model-name"}, + } + model_table.put_item(Item=item4) + result4 = handle_finish_update( + { + "model_id": "error-model", + "asg_name": "test-asg", + "has_capacity_update": True, + "is_disable": False, + "polling_error": "Model did not start in time", + "initial_model_status": ModelStatus.STOPPED, + }, + lambda_context, + ) + assert result4["current_model_status"] == ModelStatus.STOPPED + # Verify ASG scaled down on error + call_args = mock_autoscaling.update_auto_scaling_group.call_args + assert call_args[1]["MinSize"] == 0 - event = { - "model_id": "test-model", - "asg_name": "test-asg", - "has_capacity_update": False, - "is_disable": True, - "initial_model_status": ModelStatus.IN_SERVICE, - } +def test_ecs_update_and_polling(model_table, sample_model, lambda_context): + """Test ECS update and deployment polling.""" with patch("models.state_machine.update_model.model_table", model_table): - result = handle_finish_update(event, lambda_context) - - assert result["current_model_status"] == ModelStatus.STOPPED - - # Verify DDB update - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_status"] == ModelStatus.STOPPED + # Test 1: ECS update success + result1 = handle_ecs_update( + {"model_id": "test-model", "container_metadata": {"env_vars_to_delete": ["TO_DELETE"]}}, lambda_context + ) + assert ( + result1["new_task_definition_arn"] == "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2" + ) + assert result1["ecs_service_arn"] == "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service" + + # Test 2: ECS update error (no stack) + item = { + "model_id": "no-stack-model", + "model_status": ModelStatus.IN_SERVICE, + "model_config": {"containerConfig": {"environment": {"VAR": "value"}}}, + } + model_table.put_item(Item=item) + result2 = handle_ecs_update({"model_id": "no-stack-model"}, lambda_context) + assert "ecs_update_error" in result2 + # Test 3: ECS deployment completed + event = { + "model_id": "test-model", + "ecs_cluster_arn": "cluster", + "ecs_service_arn": "service", + "new_task_definition_arn": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2", + "remaining_ecs_polls": 20, + } + result3 = handle_poll_ecs_deployment(event, lambda_context) + assert result3["should_continue_ecs_polling"] is False + + # Test 4: ECS deployment in progress + mock_ecs.describe_services.return_value = { + "services": [ + { + "deployments": [ + { + "status": "PRIMARY", + "rolloutState": "IN_PROGRESS", + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2", + } + ] + } + ] + } + result4 = handle_poll_ecs_deployment(event, lambda_context) + assert result4["should_continue_ecs_polling"] is True + + # Test 5: ECS deployment timeout + result5 = handle_poll_ecs_deployment({**event, "remaining_ecs_polls": 1}, lambda_context) + assert result5["should_continue_ecs_polling"] is False + assert "ecs_polling_error" in result5 + + # Reset mock + mock_ecs.describe_services.return_value = { + "services": [ + { + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1", + "deployments": [ + { + "status": "PRIMARY", + "rolloutState": "COMPLETED", + "taskDefinition": "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2", + } + ], + } + ] + } -def test_handle_finish_update_metadata_only(model_table, lambda_context): - """Test finishing update for metadata-only update.""" - item = { - "model_id": "test-model", - "model_status": ModelStatus.UPDATING, - "auto_scaling_group": "test-asg", - "model_url": "https://test-model.example.com/v1", - "model_config": {"modelName": "test-model-name"}, - } - model_table.put_item(Item=item) - event = { - "model_id": "test-model", - "asg_name": "test-asg", - "has_capacity_update": False, - "is_disable": False, - "initial_model_status": ModelStatus.IN_SERVICE, +def test_helper_functions(): + """Test helper functions for maximum coverage.""" + # Test _update_simple_field + model_config = {"streaming": True} + _update_simple_field(model_config, "streaming", False, "test-model") + assert model_config["streaming"] is False + + # Test _update_container_config with all features + model_config = {"containerConfig": {"environment": {"EXISTING": "value"}}} + container_config = { + "environment": {"NEW_VAR": "new_value", "DELETE_ME": "LISA_MARKED_FOR_DELETION"}, + "sharedMemorySize": 1024, + "healthCheckCommand": ["CMD-SHELL", "curl -f http://localhost:8080/health"], + "healthCheckInterval": 30, + "healthCheckTimeout": 5, + "healthCheckStartPeriod": 60, + "healthCheckRetries": 3, } - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_finish_update(event, lambda_context) - - assert result["current_model_status"] == ModelStatus.IN_SERVICE - - # Verify DDB update - should restore initial status - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_status"] == ModelStatus.IN_SERVICE - - -def test_handle_finish_update_polling_error(model_table, lambda_context): - """Test finishing update when there was a polling error.""" - # Reset mocks for this test - mock_autoscaling.reset_mock() - - item = { - "model_id": "test-model", - "model_status": ModelStatus.STARTING, - "auto_scaling_group": "test-asg", - "model_url": "https://test-model.example.com/v1", - "model_config": {"modelName": "test-model-name"}, + metadata = _update_container_config(model_config, container_config, "test-model") + assert model_config["containerConfig"]["environment"]["NEW_VAR"] == "new_value" + assert "DELETE_ME" not in model_config["containerConfig"]["environment"] + assert model_config["containerConfig"]["sharedMemorySize"] == 1024 + assert metadata["env_vars_to_delete"] == ["DELETE_ME"] + + # Test _process_metadata_updates + model_config = {"streaming": True, "modelType": "textgen", "containerConfig": {"environment": {"VAR": "value"}}} + update_payload = { + "streaming": False, + "modelDescription": "Updated", + "containerConfig": {"environment": {"NEW": "value"}}, } - model_table.put_item(Item=item) - - event = { - "model_id": "test-model", - "asg_name": "test-asg", - "has_capacity_update": True, - "is_disable": False, - "polling_error": "Model did not start in time", - "initial_model_status": ModelStatus.STOPPED, + has_updates, metadata = _process_metadata_updates(model_config, update_payload, "test-model") + assert has_updates is True + assert model_config["streaming"] is False + assert model_config["modelDescription"] == "Updated" + + # Test _get_metadata_update_handlers + handlers = _get_metadata_update_handlers(model_config, "test-model") + expected_handlers = ["modelType", "streaming", "modelDescription", "allowedGroups", "features", "containerConfig"] + for handler_name in expected_handlers: + assert handler_name in handlers + assert callable(handlers[handler_name]) + + # Test get_ecs_resources_from_stack + service_arn, cluster_arn, task_def_arn = get_ecs_resources_from_stack("test-stack") + assert service_arn == "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service" + assert cluster_arn == "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster" + assert task_def_arn == "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1" + + # Test get_ecs_resources_from_stack error case + mock_cfn.describe_stack_resources.return_value = { + "StackResources": [{"ResourceType": "AWS::ECS::Cluster", "PhysicalResourceId": "cluster"}] } - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_finish_update(event, lambda_context) - - assert result["current_model_status"] == ModelStatus.STOPPED - - # Verify DDB update - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_status"] == ModelStatus.STOPPED - - # Verify ASG is scaled down due to error - mock_autoscaling.update_auto_scaling_group.assert_called_once() - call_args = mock_autoscaling.update_auto_scaling_group.call_args - assert call_args[1]["MinSize"] == 0 - assert call_args[1]["MaxSize"] == 0 - assert call_args[1]["DesiredCapacity"] == 0 - - -def test_handle_finish_update_json_decode_error(model_table, lambda_context): - """Test finishing update with invalid LiteLLM config JSON.""" - item = { - "model_id": "test-model", - "model_status": ModelStatus.STARTING, - "auto_scaling_group": "test-asg", - "model_url": "https://test-model.example.com/v1", - "model_config": {"modelName": "test-model-name"}, + with pytest.raises(RuntimeError, match="Failed to get ECS resources from CloudFormation stack"): + get_ecs_resources_from_stack("test-stack") + + # Reset mock + mock_cfn.describe_stack_resources.return_value = { + "StackResources": [ + { + "ResourceType": "AWS::ECS::Service", + "PhysicalResourceId": "arn:aws:ecs:us-east-1:123456789012:service/test-cluster/test-service", + }, + { + "ResourceType": "AWS::ECS::Cluster", + "PhysicalResourceId": "arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster", + }, + ] } - model_table.put_item(Item=item) - event = { - "model_id": "test-model", - "asg_name": "test-asg", - "has_capacity_update": True, - "is_disable": False, - "initial_model_status": ModelStatus.STOPPED, + # Test create_updated_task_definition + task_def_arn = "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:1" + updated_env_vars = {"NEW_VAR": "new_value", "UPDATED_VAR": "updated_value"} + env_vars_to_delete = ["TO_DELETE"] + updated_container_config = { + "sharedMemorySize": 1024, + "healthCheckConfig": { + "command": ["CMD-SHELL", "health"], + "interval": 30, + "timeout": 5, + "startPeriod": 60, + "retries": 3, + }, } + new_task_def_arn = create_updated_task_definition( + task_def_arn, updated_env_vars, env_vars_to_delete, updated_container_config + ) + assert new_task_def_arn == "arn:aws:ecs:us-east-1:123456789012:task-definition/test-task-def:2" - with patch("os.environ.get") as mock_env: - mock_env.return_value = "invalid-json" - - with patch("models.state_machine.update_model.model_table", model_table): - result = handle_finish_update(event, lambda_context) - - assert result["litellm_id"] == "test-litellm-id" - - # Should fallback to empty dict when JSON parsing fails - call_args = mock_litellm_client.add_model.call_args - assert call_args[1]["litellm_params"]["api_key"] == "ignored" - - -def test_end_to_end_enable_workflow(model_table, stopped_model, lambda_context): - """Test complete enable workflow end-to-end.""" - # Reset mocks for this test - mock_autoscaling.reset_mock() - mock_litellm_client.reset_mock() + # Test update_ecs_service + update_ecs_service("cluster-arn", "service-arn", "task-def-arn") + mock_ecs.update_service.assert_called_with( + cluster="cluster-arn", service="service-arn", taskDefinition="task-def-arn" + ) - # Ensure the autoscaling mock returns the expected values for this test - mock_autoscaling.describe_auto_scaling_groups.return_value = { - "AutoScalingGroups": [ - {"DesiredCapacity": 2, "Instances": [{"HealthStatus": "Healthy"}, {"HealthStatus": "Healthy"}]} - ] - } +def test_edge_cases_and_json_error(model_table, lambda_context): + """Test edge cases and JSON decoding error for complete coverage.""" with patch("models.state_machine.update_model.model_table", model_table): - # Step 1: Job intake for enable - event1 = {"model_id": "stopped-model", "update_payload": {"enabled": True}} - result1 = handle_job_intake(event1, lambda_context) - assert result1["has_capacity_update"] is True - assert result1["current_model_status"] == ModelStatus.STARTING - - # Step 2: Poll capacity (healthy) - event2 = {"model_id": "stopped-model", "asg_name": "test-asg", "remaining_capacity_polls": 20} - result2 = handle_poll_capacity(event2, lambda_context) - assert result2["should_continue_capacity_polling"] is False - - # Step 3: Finish update - event3 = { - "model_id": "stopped-model", - "asg_name": "test-asg", - "has_capacity_update": True, - "is_disable": False, - "initial_model_status": ModelStatus.STOPPED, + # Test JSON decode error in handle_finish_update + item = { + "model_id": "json-error-model", + "model_status": ModelStatus.STARTING, + "auto_scaling_group": "test-asg", + "model_url": "https://test-model.example.com/v1", + "model_config": {"modelName": "test-model-name"}, } - result3 = handle_finish_update(event3, lambda_context) - assert result3["current_model_status"] == ModelStatus.IN_SERVICE - - # Verify final state - item = model_table.get_item(Key={"model_id": "stopped-model"})["Item"] - assert item["model_status"] == ModelStatus.IN_SERVICE - - -def test_end_to_end_disable_workflow(model_table, sample_model, lambda_context): - """Test complete disable workflow end-to-end.""" - with patch("models.state_machine.update_model.model_table", model_table): - # Step 1: Job intake for disable - event1 = {"model_id": "test-model", "update_payload": {"enabled": False}} - result1 = handle_job_intake(event1, lambda_context) - assert result1["is_disable"] is True - assert result1["current_model_status"] == ModelStatus.STOPPING + model_table.put_item(Item=item) + + with patch("os.environ.get") as mock_env: + mock_env.return_value = "invalid-json" + result = handle_finish_update( + { + "model_id": "json-error-model", + "asg_name": "test-asg", + "has_capacity_update": True, + "is_disable": False, + "initial_model_status": ModelStatus.STOPPED, + }, + lambda_context, + ) + assert result["litellm_id"] == "test-litellm-id" - # Step 2: Finish update (no polling needed for disable) - event2 = { - "model_id": "test-model", - "asg_name": "test-asg", - "has_capacity_update": False, - "is_disable": True, - "initial_model_status": ModelStatus.IN_SERVICE, + # Test ECS deployment with previous error + result_with_error = handle_poll_ecs_deployment( + {"model_id": "test-model", "ecs_update_error": "Previous error"}, lambda_context + ) + assert result_with_error["should_continue_ecs_polling"] is False + assert "ecs_update_error" in result_with_error + + # Test container config on LiteLLM-only model (should not fail but not trigger ECS update) + litellm_item = { + "model_id": "litellm-container-model", + "model_status": ModelStatus.IN_SERVICE, + "model_config": { + "modelName": "test-model", + "containerConfig": {"environment": {"EXISTING_VAR": "existing_value"}}, + }, } - result2 = handle_finish_update(event2, lambda_context) - assert result2["current_model_status"] == ModelStatus.STOPPED - - # Verify final state - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_status"] == ModelStatus.STOPPED - - -def test_handle_job_intake_partial_autoscaling_config(model_table, sample_model, lambda_context): - """Test updating only some autoscaling parameters.""" - # Reset mocks for this test - mock_autoscaling.reset_mock() - - event = { - "model_id": "test-model", - "update_payload": {"autoScalingInstanceConfig": {"maxCapacity": 5}}, # Only update max capacity - } - - with patch("models.state_machine.update_model.model_table", model_table): - handle_job_intake(event, lambda_context) - - # Verify model config update - item = model_table.get_item(Key={"model_id": "test-model"})["Item"] - assert item["model_config"]["autoScalingConfig"]["minCapacity"] == 1 # Unchanged - assert item["model_config"]["autoScalingConfig"]["maxCapacity"] == 5 # Updated - - # Verify ASG update call only includes maxCapacity - mock_autoscaling.update_auto_scaling_group.assert_called_once() - call_args = mock_autoscaling.update_auto_scaling_group.call_args - assert "MaxSize" in call_args[1] - assert call_args[1]["MaxSize"] == 5 - assert "MinSize" not in call_args[1] - assert "DesiredCapacity" not in call_args[1] + model_table.put_item(Item=litellm_item) + result_litellm = handle_job_intake( + { + "model_id": "litellm-container-model", + "update_payload": {"containerConfig": {"environment": {"NEW_VAR": "new_value"}}}, + }, + lambda_context, + ) + assert result_litellm["needs_ecs_update"] is False diff --git a/vector_store_deployer/src/lib/bedrock_knowledge_base.ts b/vector_store_deployer/src/lib/bedrock_knowledge_base.ts new file mode 100644 index 000000000..38f019b7d --- /dev/null +++ b/vector_store_deployer/src/lib/bedrock_knowledge_base.ts @@ -0,0 +1,37 @@ +/** + 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 { StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { RagRepositoryConfig, PartialConfig } from '../../../lib/schema'; +import { PipelineStack } from './pipeline-stack'; + +// Type definition for BedrockKnowledgeBaseStack properties +type BedrockKnowledgeBaseStackProps = StackProps & { + config: PartialConfig, + ragConfig: RagRepositoryConfig, +}; + +// BedrockKnowledgeBaseStack class, extending PipelineStack +export class BedrockKnowledgeBaseStack extends PipelineStack { + constructor (scope: Construct, id: string, props: BedrockKnowledgeBaseStackProps) { + super(scope, id, props); + + // Destructure the configuration properties + const { config, ragConfig } = props; + + this.createPipelineRules(config, ragConfig); + } +} diff --git a/vector_store_deployer/src/lib/index.ts b/vector_store_deployer/src/lib/index.ts index 4ed73a213..660c4cb72 100644 --- a/vector_store_deployer/src/lib/index.ts +++ b/vector_store_deployer/src/lib/index.ts @@ -18,6 +18,7 @@ import { AddPermissionBoundary } from '@cdklabs/cdk-enterprise-iac'; import { OpenSearchVectorStoreStack } from './opensearch'; import { PGVectorStoreStack } from './pgvector'; import { RagRepositoryConfigSchema, RagRepositoryType,PartialConfigSchema } from '../../../lib/schema'; +import { BedrockKnowledgeBaseStack } from './bedrock_knowledge_base'; const app = new App(); @@ -46,6 +47,12 @@ if (ragConfig.type === RagRepositoryType.OPENSEARCH) { stack = new PGVectorStoreStack(app, stackName, { ...vectorStoreProps, }); +} else if (ragConfig.type === RagRepositoryType.BEDROCK_KNOWLEDGE_BASE) { + if (ragConfig.pipelines){ + stack = new BedrockKnowledgeBaseStack(app, stackName, { + ...vectorStoreProps, + }); + } } else { console.error(`Unsupported repository type: ${ragConfig.type}`); throw new Error(`Unsupported repository type: ${ragConfig.type}`);