Multitenant platform demo (#859)
* Multitenant platform demo
* linting fixes
* fix(multitenant-agentic-platform): Improve security and configuration flexibility
- Fix typo in README ("cusotm" → "custom")
- Replace hardcoded AWS region with environment variable support in main.py
- Refactor calculator tool to use AST-based safe evaluation instead of regex validation
- Add support for unary operators and improve operator/function/constant whitelisting
- Update database-query tool to use environment variables for RDS configuration
- Add AWS_REGION environment variable support to email-sender tool
- Update deploy.sh with improved deployment configuration handling
- Enhance frontend index.html with better error handling and user feedback
- Improves security posture by eliminating eval() usage and hardcoded credentials
- Enables flexible multi-region deployments through environment configuration
* fix(multitenant-agentic-platform): Remove redundant agent runtime ID validation
- Remove unnecessary validation check for agent_runtime_id in delete_agent handler
- Simplify error handling flow by eliminating duplicate validation logic
- Agent runtime ID is already validated in prior steps, making this check redundant
* docs(multitenant-agentic-platform): Add security considerations and warnings
- Add comprehensive Security Considerations section to README documenting API key exposure risks
- Document suitable use cases (demos, development, internal tools) and production recommendations
- Add security warnings to config_injector Lambda handler with alternative authentication approaches
- Update deployment documentation with security notes about client-side API key embedding
- Pass account_id and region parameters to DatabaseConstruct and MessagingConstruct for improved configuration
- Add security reminders in frontend development section referencing production deployment guidance
- Clarify that current implementation is suitable for demos and internal use only, not production
* fix(multitenant-agentic-platform): Add API key headers to frontend requests and improve security documentation
- Add 'x-api-key' header to all axios requests in frontend (delete, post, get operations)
- Update README security note to emphasize not embedding long-lived credentials in public files
- Recommend authenticated callers (Cognito/IAM/JWT) or backend proxy/BFF for production
- Clarify config.js generation to exclude API Gateway keys from public configuration
- Fix deploy.sh region comment from us-west-2 to us-east-1
- Remove emoji from deploy.sh output for better compatibility
- Refactor query parameter and body parsing in async_deploy_agent handler for clarity
- Add environment variable definitions for DynamoDB table names in build_deploy_agent handler
- Ensure consistent API authentication across all frontend API calls for improved security
* docs(multitenant-agentic-platform): Remove security limitations section from README
- Remove detailed API key exposure warnings and limitations documentation
- Remove suitable use cases section for demonstration deployments
- Remove production recommendations for authentication mechanisms
- Simplify README by consolidating security guidance into main documentation
* fix(multitenant-agentic-platform): Enforce required environment variables and optimize DynamoDB queries
- Replace optional environment variable defaults with required configuration in build_deploy_agent handler
- Add validation to fail fast if AGENT_CONFIG_TABLE_NAME or AGENT_DETAILS_TABLE_NAME are not set
- Add AGGREGATION_TABLE_NAME validation in infrastructure_costs handler with clear error messaging
- Optimize DynamoDB scan operations to use server-side FilterExpression instead of client-side filtering
- Add ProjectionExpression to reduce data transfer and improve query performance in token_usage handler
- Use ExpressionAttributeNames to handle reserved words (timestamp) in DynamoDB queries
- Improve configuration reliability by ensuring all Lambda functions have required environment variables set before execution
* fix(multitenant-agentic-platform): Remove unused import from token usage handler
- Remove unused boto3.dynamodb.conditions Attr import
- Simplify handler.py by eliminating unnecessary dependency
- Reduce code clutter and improve maintainability
* fix(multitenant-agentic-platform): Update agent template naming and enhance token limit validation
- Rename base-agent.py to main.py in agent-tools-repo templates for consistency
- Update documentation references to reflect new template filename
- Add Attr import from boto3.dynamodb.conditions for improved query filtering
- Enhance check_token_limit function with configurable fail-closed behavior via FAIL_CLOSED environment variable
- Add get_tenant_id_from_agent function to look up tenant ID from agent details table, preventing token limit bypass
- Improve error handling in token limit checks with detailed logging for fail-closed vs fail-open modes
- Add documentation notes explaining fail-open default behavior and fail-closed option
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
# AWS Bedrock Agent Core Multitenant Platform
|
||||
|
||||
A multi-tenant SaaS platform for deploying, managing, and invoking AI agents powered by AWS Bedrock Agent Core.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/main.png" alt="Dashboard Main View" width="600">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **Deploy Custom AI Agents**: Configure agents with custom models (Claude Sonnet 4.5), system prompts, and modular tools
|
||||
- **Modular Tool Composition**: Select from 6+ tools (web search, calculator, database query, email, web crawler, etc.)
|
||||
- **Multi-Tenant Isolation**: Each agent tagged with tenant ID for cost tracking
|
||||
- **Token Limits per Tenant**: Set and enforce token usage limits with automatic 429 responses when exceeded
|
||||
- **Real-Time Token Usage Tracking**: Monitor inference costs per tenant with aggregated metrics and usage percentages
|
||||
- **Infrastructure Cost Tracking**: Track AWS infrastructure costs per tenant using cost allocation tags via AWS Cost Explorer
|
||||
- **Total Cost Dashboard**: View combined inference + infrastructure costs per tenant with detailed breakdowns
|
||||
- **Agent Management**: List, invoke, update, and delete agents via REST API
|
||||
- **Interactive Dashboard**: React-based UI with dark mode, charts, auto-refresh, and real-time updates
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/custom.png" alt="Agent Deployment" width="400">
|
||||
</p>
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
The system architecture consists of the following components:
|
||||
|
||||
|
||||
### Component Overview
|
||||
|
||||
| Component | Service | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| Frontend | CloudFront + S3 | React dashboard with HeroUI v3 |
|
||||
| API | API Gateway | REST API with 9 endpoints |
|
||||
| Agent Ops | Lambda (5) | Deploy, invoke, list, get, delete agents |
|
||||
| Token Tracking | Lambda (3) + SQS | Real-time usage aggregation |
|
||||
| Cost Tracking | Lambda (1) + Cost Explorer | Infrastructure cost per tenant |
|
||||
| Storage | DynamoDB (4) | Agents, tokens, config, aggregates |
|
||||
| AI | Bedrock Agent Core | Claude Sonnet 4.5 model |
|
||||
|
||||
### Data Flow
|
||||
|
||||
**Agent Deployment**:
|
||||
```
|
||||
Frontend → API Gateway → async_deploy → build_deploy → S3 + Bedrock → DynamoDB
|
||||
```
|
||||
|
||||
**Agent Invocation with Token Limits**:
|
||||
```
|
||||
Frontend → API Gateway → invoke_agent → Check Limit → Bedrock → SQS → Aggregate
|
||||
```
|
||||
|
||||
**Token Tracking Pipeline**:
|
||||
```
|
||||
SQS → sqs_to_dynamodb → DynamoDB → Stream → Aggregation → Dashboard
|
||||
```
|
||||
|
||||
**Infrastructure Cost Tracking**:
|
||||
```
|
||||
Frontend → API Gateway → infrastructure_costs → Cost Explorer (tenantId tag) → Dashboard
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── agent-tools-repo/ # Modular tool repository for agent composition
|
||||
├── docs/ # Architecture diagrams and documentation
|
||||
├── frontend/ # React dashboard application
|
||||
├── src/ # Backend infrastructure and Lambda functions
|
||||
│ ├── lambda_functions/ # Lambda function handlers
|
||||
│ ├── stacks/ # CDK stack definitions
|
||||
│ └── cdk_app.py # CDK application entry point
|
||||
├── deploy.sh # One-command deployment script
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AWS CLI configured with credentials
|
||||
- Node.js 18+ and npm
|
||||
- Python 3.10+
|
||||
- AWS CDK CLI (`npm install -g aws-cdk`)
|
||||
- CDK bootstrapped in your AWS account/region
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deployment (Default Region: us-east-1)
|
||||
|
||||
```bash
|
||||
chmod a+x deploy.sh
|
||||
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Install frontend dependencies
|
||||
2. Build the React application
|
||||
3. Deploy the entire CDK stack (infrastructure + frontend)
|
||||
4. **Automatically generate config.js** with the API endpoint and other non‑secret configuration (⚠️ See Security Considerations below)
|
||||
5. Deploy frontend to S3 and CloudFront
|
||||
6. Invalidate CloudFront cache
|
||||
|
||||
> **⚠️ Security Note**: Do **not** embed API Gateway keys or other long‑lived credentials in publicly accessible files such as `config.js`. For production deployments, use authenticated callers (for example, Cognito/IAM/JWT) or a backend proxy/BFF that keeps credentials server‑side and issues short‑lived, scoped tokens to the frontend instead. See the [Security Considerations](#security-considerations) section for detailed production recommendations.
|
||||
|
||||
### Deploy to a Specific Region
|
||||
|
||||
The deployment script respects the `AWS_DEFAULT_REGION` environment variable. To deploy to a different AWS region:
|
||||
|
||||
```bash
|
||||
# Deploy to eu-central-1 (Frankfurt)
|
||||
AWS_DEFAULT_REGION=eu-central-1 ./deploy.sh
|
||||
|
||||
# Deploy to us-west-2 (Oregon)
|
||||
AWS_DEFAULT_REGION=us-west-2 ./deploy.sh
|
||||
|
||||
# Deploy to ap-southeast-1 (Singapore)
|
||||
AWS_DEFAULT_REGION=ap-southeast-1 ./deploy.sh
|
||||
```
|
||||
|
||||
**Region Configuration:**
|
||||
- Default region: `us-east-1` (if not specified)
|
||||
- The script reads from `AWS_DEFAULT_REGION` environment variable
|
||||
- The region is displayed during deployment: "Deploying to region: [region]"
|
||||
- The script internally sets `CDK_DEFAULT_REGION` for CDK commands
|
||||
|
||||
**Important Notes:**
|
||||
- The region must support AWS Bedrock Agent Core
|
||||
- You must bootstrap CDK in the target region first (see below)
|
||||
- Each region deployment creates a separate, independent stack
|
||||
- Resource names include the region to avoid conflicts
|
||||
|
||||
### Bootstrap a New Region
|
||||
|
||||
Before deploying to a new region for the first time:
|
||||
|
||||
```bash
|
||||
# Bootstrap the target region (replace with your account ID)
|
||||
AWS_DEFAULT_REGION=eu-central-1 cdk bootstrap aws://YOUR_ACCOUNT_ID/eu-central-1
|
||||
|
||||
# Or let CDK auto-detect your account
|
||||
AWS_DEFAULT_REGION=eu-central-1 cdk bootstrap
|
||||
```
|
||||
|
||||
### Manual CDK Deployment (Without Frontend Build)
|
||||
|
||||
```bash
|
||||
# Deploy to default region (us-east-1)
|
||||
cd src && cdk deploy --app "python3 cdk_app.py"
|
||||
|
||||
# Deploy to specific region
|
||||
cd src && AWS_DEFAULT_REGION=eu-central-1 cdk deploy --app "python3 cdk_app.py"
|
||||
```
|
||||
|
||||
### What Gets Deployed
|
||||
|
||||
- **API Gateway**: REST API with 7 endpoints
|
||||
- **Lambda Functions**: 12 functions for agent operations
|
||||
- **DynamoDB Tables**: 4 tables for agents, config, and token tracking
|
||||
- **SQS Queue**: For asynchronous token usage processing
|
||||
- **S3 Buckets**: For agent code and frontend hosting
|
||||
- **CloudFront**: CDN for frontend distribution
|
||||
- **IAM Roles**: With least-privilege permissions
|
||||
|
||||
## Stack Outputs
|
||||
|
||||
After deployment, you'll see outputs including:
|
||||
|
||||
- `FrontendUrl`: CloudFront URL for the dashboard
|
||||
- `ApiEndpoint`: API Gateway endpoint
|
||||
- `ApiKeyId`: API key ID (value is auto-injected into frontend)
|
||||
- `CodeBucket`: S3 bucket for agent code
|
||||
- `QueueUrl`: SQS queue for token tracking
|
||||
|
||||
## Usage
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**⚠️ IMPORTANT: This blueprint is designed for demonstration and development purposes.**
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Visit the CloudFront URL from the stack outputs
|
||||
2. Enter a tenant ID
|
||||
3. Configure your agent (model, system prompt, tools)
|
||||
4. Click "Deploy Agent"
|
||||
5. Once deployed, invoke the agent from the dashboard
|
||||
6. Monitor token usage and costs in real-time
|
||||
|
||||
## Development
|
||||
|
||||
### Local Frontend Development
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will run on `http://localhost:3000` and use the deployed API.
|
||||
|
||||
### Redeploy After Changes
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
The config.js will be automatically regenerated with the latest API credentials.
|
||||
|
||||
> **⚠️ Security Reminder**: The API key is embedded in the frontend config.js file. This architecture is suitable for demos and internal tools only. For production deployments, implement proper authentication (see [Security Considerations](#security-considerations)).
|
||||
|
||||
## Cost Tracking
|
||||
|
||||
The platform provides comprehensive cost tracking with two components:
|
||||
|
||||
### Inference Costs
|
||||
- Tracks token usage per agent invocation
|
||||
- Calculates costs based on input/output token pricing
|
||||
- Aggregates costs by tenant ID
|
||||
- Displays usage percentages against token limits
|
||||
|
||||
### Infrastructure Costs
|
||||
- Queries AWS Cost Explorer for infrastructure costs
|
||||
- Filters by `tenantId` cost allocation tag
|
||||
- Retrieves current month costs per tenant
|
||||
- Only tracks costs for currently configured tenants
|
||||
|
||||
### Dashboard Display
|
||||
- **Inference Cost**: Token-based costs from Bedrock usage
|
||||
- **Infra Cost**: AWS infrastructure costs (Lambda, DynamoDB, etc.)
|
||||
- **Total Cost**: Combined inference + infrastructure costs
|
||||
- **Cost Chart**: Visual breakdown with detailed tooltips
|
||||
- **Auto-refresh**: Updates every 10 seconds
|
||||
|
||||
> **Note**: Infrastructure costs from AWS Cost Explorer may be delayed up to 24 hours for new resources.
|
||||
|
||||
## Cleanup
|
||||
|
||||
To delete all resources:
|
||||
|
||||
```bash
|
||||
# Delete from default region (us-east-1)
|
||||
cdk destroy --app "python3 src/cdk_app.py"
|
||||
|
||||
# Delete from specific region
|
||||
AWS_DEFAULT_REGION=eu-central-1 cdk destroy --app "python3 src/cdk_app.py"
|
||||
```
|
||||
|
||||
**Note:** All resources are configured with automatic deletion policies:
|
||||
- S3 buckets are automatically emptied before deletion
|
||||
- CloudWatch log groups are automatically removed
|
||||
- DynamoDB tables are deleted with their data
|
||||
- No manual cleanup required
|
||||
|
||||
## Adding your custom tool into deployment (Optional)
|
||||
|
||||
To deploy agents with your own custom tools, create a custom tool repository based on the included `agent-tools-repo` template. During agent deployment, provide the URL to your repository, load your tool definitions, select the applicable tools, and deploy the agent with them.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/custom_tools.png" alt="Custom Tools Configuration" width="600">
|
||||
</p>
|
||||
|
||||
### 1. Clone the Agent Tools Repository
|
||||
|
||||
```bash
|
||||
# Create a new repository from the agent-tools-repo template
|
||||
cp -r agent-tools-repo/ac_tools my-custom-tools
|
||||
cd my-custom-tools
|
||||
|
||||
# Initialize as a new git repository
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial commit: Custom agent tools"
|
||||
|
||||
# Push to your GitHub repository
|
||||
git remote add origin https://github.com/YOUR_USERNAME/my-custom-tools.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 2. Add Your Custom Tools
|
||||
|
||||
Create a new tool in the `tools/` directory:
|
||||
|
||||
```bash
|
||||
mkdir tools/my-custom-tool
|
||||
```
|
||||
|
||||
Create `tools/my-custom-tool/tool.py`:
|
||||
```python
|
||||
from strands import tool
|
||||
|
||||
@tool
|
||||
def my_custom_tool(input: str) -> str:
|
||||
"""Description of what your tool does"""
|
||||
# Your tool implementation
|
||||
return f"Processed: {input}"
|
||||
```
|
||||
|
||||
Create `tools/my-custom-tool/config.json`:
|
||||
```json
|
||||
{
|
||||
"id": "my-custom-tool",
|
||||
"name": "My Custom Tool",
|
||||
"description": "Description of your custom tool",
|
||||
"category": "utility",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update the Tool Catalog
|
||||
|
||||
Edit `catalog.json` to include your new tool:
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"id": "my-custom-tool",
|
||||
"name": "My Custom Tool",
|
||||
"description": "Description of your custom tool",
|
||||
"category": "utility",
|
||||
"path": "tools/my-custom-tool"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Push Changes to GitHub
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add custom tool"
|
||||
git push
|
||||
```
|
||||
|
||||
### 5. Use Your Custom Tools in Agent Deployment
|
||||
|
||||
When deploying an agent through the dashboard:
|
||||
|
||||
1. Click "Deploy New Agent"
|
||||
2. Expand "Advanced Configuration"
|
||||
3. Check "Use Custom Template from GitHub"
|
||||
4. Enter your repository: `YOUR_USERNAME/my-custom-tools`
|
||||
5. Click "Load Available Tools"
|
||||
6. Select your custom tools
|
||||
7. Deploy the agent
|
||||
|
||||
The deployment system will fetch your `catalog.json`, display your custom tools, and bundle them with the agent code.
|
||||
|
||||
> **Tip**: You can make your repository private and provide a GitHub Personal Access Token in the deployment form for private tool repositories.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Agent Tools Repository
|
||||
|
||||
This repository contains modular tools for composing AI agents with various capabilities.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
agent-tools-repo/
|
||||
├── catalog.json # Tool catalog
|
||||
├── templates/
|
||||
│ └── main.py # Base agent template
|
||||
└── tools/
|
||||
├── web-search/ # Web search tool
|
||||
├── calculator/ # Calculator tool
|
||||
├── database-query/ # Database query tool
|
||||
└── email-sender/ # Email sender tool
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### 🔍 Web Search
|
||||
Search the web using DuckDuckGo to retrieve information.
|
||||
|
||||
### 🧮 Calculator
|
||||
Perform mathematical calculations and evaluations.
|
||||
|
||||
### 🗄️ Database Query
|
||||
Query databases using SQL with AWS RDS Data API.
|
||||
|
||||
### 📧 Email Sender
|
||||
Send emails using AWS SES.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Reference this repository in your agent deployment UI
|
||||
2. Load the tool catalog
|
||||
3. Select desired tools
|
||||
4. Deploy your agent with the selected tools
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
1. Create a new directory under `tools/`
|
||||
2. Add `tool.py` with your tool implementation
|
||||
3. Add `config.json` with tool metadata
|
||||
4. Update `catalog.json` to include your new tool
|
||||
|
||||
## Tool Implementation
|
||||
|
||||
Tools use the `@tool` decorator from the strands library:
|
||||
|
||||
```python
|
||||
from strands import tool
|
||||
|
||||
@tool
|
||||
def my_tool(param: str) -> str:
|
||||
"""Tool description"""
|
||||
# Implementation
|
||||
return result
|
||||
```
|
||||
|
||||
See individual tool directories for examples.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Setup Instructions
|
||||
|
||||
## Push to GitHub
|
||||
|
||||
This repository is ready to be pushed to GitHub. Follow these steps:
|
||||
|
||||
### 1. Create a new repository on GitHub
|
||||
|
||||
Go to https://github.com/new and create a new repository (e.g., `agent-tools`)
|
||||
|
||||
**Important:** Do NOT initialize with README, .gitignore, or license (we already have these)
|
||||
|
||||
### 2. Push this repository
|
||||
|
||||
```bash
|
||||
cd agent-tools-repo
|
||||
|
||||
# Add your GitHub repository as remote
|
||||
git remote add origin https://github.com/YOUR_USERNAME/agent-tools.git
|
||||
|
||||
# Push to GitHub
|
||||
git branch -M main
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 3. Verify the upload
|
||||
|
||||
Visit your repository on GitHub and verify all files are present:
|
||||
- ✅ catalog.json
|
||||
- ✅ README.md
|
||||
- ✅ templates/main.py
|
||||
- ✅ tools/web-search/
|
||||
- ✅ tools/calculator/
|
||||
- ✅ tools/database-query/
|
||||
- ✅ tools/email-sender/
|
||||
|
||||
### 4. Use in your agent deployment
|
||||
|
||||
Once pushed to GitHub, you can use this repository in your agent deployment:
|
||||
|
||||
1. Open the agent deployment UI
|
||||
2. Check "Use Custom Template from GitHub"
|
||||
3. Enter repository: `YOUR_USERNAME/agent-tools`
|
||||
4. Enter template path: `templates/base-agent.py`
|
||||
5. Enter branch: `main`
|
||||
6. Click "Load Available Tools"
|
||||
7. Select desired tools
|
||||
8. Deploy your agent!
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
agent-tools-repo/
|
||||
├── catalog.json # Tool catalog (required)
|
||||
├── README.md # Documentation
|
||||
├── .gitignore # Git ignore rules
|
||||
├── templates/
|
||||
│ └── base-agent.py # Base agent template
|
||||
└── tools/
|
||||
├── web-search/ # 🔍 Web search tool
|
||||
│ ├── tool.py
|
||||
│ └── config.json
|
||||
├── calculator/ # 🧮 Calculator tool
|
||||
│ ├── tool.py
|
||||
│ └── config.json
|
||||
├── database-query/ # 🗄️ Database query tool
|
||||
│ ├── tool.py
|
||||
│ └── config.json
|
||||
└── email-sender/ # 📧 Email sender tool
|
||||
├── tool.py
|
||||
└── config.json
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### 🔍 Web Search
|
||||
- Search the web using DuckDuckGo
|
||||
- No API key required
|
||||
- Dependencies: requests, beautifulsoup4
|
||||
|
||||
### 🧮 Calculator
|
||||
- Perform mathematical calculations
|
||||
- Supports common math functions (sqrt, sin, cos, etc.)
|
||||
- No dependencies
|
||||
|
||||
### 🗄️ Database Query
|
||||
- Query databases using SQL
|
||||
- Uses AWS RDS Data API
|
||||
- Dependencies: boto3
|
||||
- **Note:** Update resource ARN and secret ARN in tool.py before use
|
||||
|
||||
### 📧 Email Sender
|
||||
- Send emails using AWS SES
|
||||
- Dependencies: boto3
|
||||
- **Note:** Sender email must be verified in AWS SES
|
||||
|
||||
## Adding More Tools
|
||||
|
||||
To add a new tool:
|
||||
|
||||
1. Create directory: `tools/your-tool-name/`
|
||||
2. Add `tool.py` with your implementation
|
||||
3. Add `config.json` with metadata
|
||||
4. Update `catalog.json` to include your tool
|
||||
5. Commit and push changes
|
||||
|
||||
## Testing
|
||||
|
||||
Test individual tools before adding to catalog:
|
||||
|
||||
```python
|
||||
from tools.calculator.tool import calculator
|
||||
|
||||
result = calculator("2 + 2")
|
||||
print(result) # Should print: Result: 4
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, refer to the main documentation or create an issue in the repository.
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Agent tools catalog for modular agent composition",
|
||||
"tools": [
|
||||
{
|
||||
"id": "web-search",
|
||||
"name": "Web Search",
|
||||
"description": "Search the web and retrieve information using DuckDuckGo",
|
||||
"category": "information",
|
||||
"icon": "🔍",
|
||||
"path": "tools/web-search/tool.py",
|
||||
"configPath": "tools/web-search/config.json",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "calculator",
|
||||
"name": "Calculator",
|
||||
"description": "Perform mathematical calculations and evaluations",
|
||||
"category": "utility",
|
||||
"icon": "🧮",
|
||||
"path": "tools/calculator/tool.py",
|
||||
"configPath": "tools/calculator/config.json",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "datetime",
|
||||
"name": "Date & Time",
|
||||
"description": "Get current date, time, and timezone information",
|
||||
"category": "utility",
|
||||
"icon": "🕐",
|
||||
"path": "tools/datetime/tool.py",
|
||||
"configPath": "tools/datetime/config.json",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "database-query",
|
||||
"name": "Database Query",
|
||||
"description": "Query databases using SQL with AWS RDS Data API",
|
||||
"category": "data",
|
||||
"icon": "🗄️",
|
||||
"path": "tools/database-query/tool.py",
|
||||
"configPath": "tools/database-query/config.json",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "email-sender",
|
||||
"name": "Email Sender",
|
||||
"description": "Send emails using AWS SES",
|
||||
"category": "communication",
|
||||
"icon": "📧",
|
||||
"path": "tools/email-sender/tool.py",
|
||||
"configPath": "tools/email-sender/config.json",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"id": "web-crawler",
|
||||
"name": "Web Crawler",
|
||||
"description": "Crawl and extract content from websites using Crawl4AI",
|
||||
"category": "information",
|
||||
"icon": "🕷️",
|
||||
"path": "tools/web-crawler/tool.py",
|
||||
"configPath": "tools/web-crawler/config.json",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
from bedrock_agentcore import BedrockAgentCoreApp
|
||||
from strands import Agent
|
||||
import boto3
|
||||
import os
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import json
|
||||
|
||||
app = BedrockAgentCoreApp(debug=True)
|
||||
|
||||
# Configuration placeholders (replaced during deployment)
|
||||
QUEUE_URL = 'QUEUE_URL_VALUE'
|
||||
TENANT_ID = 'TENANT_ID_VALUE'
|
||||
AGENT_RUNTIME_ID = 'AGENT_RUNTIME_ID_VALUE'
|
||||
MODEL_ID = 'MODEL_ID_VALUE'
|
||||
SYSTEM_PROMPT = '''SYSTEM_PROMPT_VALUE'''
|
||||
|
||||
# Initialize AWS clients
|
||||
region = os.environ.get('AWS_REGION', 'us-west-2')
|
||||
sqs = boto3.client('sqs', region_name=region)
|
||||
dynamodb = boto3.resource('dynamodb', region_name=region)
|
||||
config_table = dynamodb.Table('agent-configurations')
|
||||
|
||||
# Tools will be injected here by the build system
|
||||
|
||||
# Initialize agent with configuration
|
||||
agent = Agent(model=MODEL_ID, system_prompt=SYSTEM_PROMPT)
|
||||
|
||||
def send_usage_to_sqs(input_tokens, output_tokens, total_tokens, user_message, response_message, tenant_id):
|
||||
"""Send token usage metrics to SQS for tracking and billing"""
|
||||
try:
|
||||
message_body = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'tenant_id': tenant_id,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'user_message': user_message,
|
||||
'response_message': response_message
|
||||
}
|
||||
|
||||
sqs.send_message(
|
||||
QueueUrl=QUEUE_URL,
|
||||
MessageBody=json.dumps(message_body)
|
||||
)
|
||||
|
||||
app.logger.info(f"Sent usage to SQS for tenant {tenant_id}: {input_tokens} input, {output_tokens} output tokens")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to send usage to SQS: {str(e)}")
|
||||
|
||||
@app.entrypoint
|
||||
def invoke(payload):
|
||||
"""
|
||||
Agent entrypoint - handles incoming requests and returns responses.
|
||||
|
||||
Supports tool usage when tools are selected during deployment.
|
||||
"""
|
||||
# Extract message from payload (supports both 'message' and 'prompt' keys)
|
||||
user_message = payload.get("message") or payload.get("prompt", "Hello!")
|
||||
|
||||
app.logger.info(f"Received request from tenant {TENANT_ID}")
|
||||
app.logger.info(f"User message: {user_message}")
|
||||
app.logger.info(f"Full payload: {json.dumps(payload)}")
|
||||
|
||||
try:
|
||||
# Invoke agent (tools are automatically available if injected)
|
||||
result = agent(user_message)
|
||||
|
||||
# Extract response
|
||||
response_message = result.message
|
||||
|
||||
# Send usage metrics to SQS
|
||||
if hasattr(result, 'metrics'):
|
||||
input_tokens = result.metrics.accumulated_usage.get('inputTokens', 0)
|
||||
output_tokens = result.metrics.accumulated_usage.get('outputTokens', 0)
|
||||
total_tokens = result.metrics.accumulated_usage.get('totalTokens', 0)
|
||||
|
||||
app.logger.info(f"Token usage - Input: {input_tokens}, Output: {output_tokens}, Total: {total_tokens}")
|
||||
|
||||
send_usage_to_sqs(
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
user_message,
|
||||
response_message,
|
||||
TENANT_ID
|
||||
)
|
||||
else:
|
||||
app.logger.warning("No metrics available in result")
|
||||
|
||||
return {"result": response_message}
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error processing request: {str(e)}"
|
||||
app.logger.error(error_message)
|
||||
return {"error": error_message}
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "calculator",
|
||||
"displayName": "Calculator",
|
||||
"description": "Perform mathematical calculations and evaluations",
|
||||
"category": "utility",
|
||||
"version": "1.0.0",
|
||||
"dependencies": [],
|
||||
"parameters": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "Mathematical expression to evaluate"
|
||||
}
|
||||
},
|
||||
"permissions": [],
|
||||
"icon": "🧮",
|
||||
"author": "Agent Tools Team",
|
||||
"license": "MIT",
|
||||
"supportedFunctions": [
|
||||
"abs", "round", "min", "max", "sum", "pow",
|
||||
"sqrt", "sin", "cos", "tan", "log", "log10",
|
||||
"exp", "floor", "ceil", "pi", "e"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
from strands import tool
|
||||
import math
|
||||
import ast
|
||||
import operator
|
||||
|
||||
@tool
|
||||
def calculator(expression: str) -> str:
|
||||
"""
|
||||
Perform mathematical calculations.
|
||||
|
||||
This tool evaluates mathematical expressions safely, supporting
|
||||
basic arithmetic and common mathematical functions.
|
||||
|
||||
Args:
|
||||
expression: Mathematical expression to evaluate (e.g., "2 + 2", "sqrt(16)", "sin(pi/2)")
|
||||
|
||||
Returns:
|
||||
Calculation result as string
|
||||
|
||||
Example:
|
||||
result = calculator("2 * (3 + 4)")
|
||||
result = calculator("sqrt(144)")
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation using AST parsing
|
||||
allowed_operators = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
allowed_functions = {
|
||||
'abs': abs, 'round': round, 'min': min, 'max': max,
|
||||
'pow': pow,
|
||||
'sqrt': math.sqrt, 'sin': math.sin, 'cos': math.cos,
|
||||
'tan': math.tan, 'log': math.log, 'log10': math.log10,
|
||||
'exp': math.exp, 'floor': math.floor, 'ceil': math.ceil,
|
||||
}
|
||||
|
||||
allowed_constants = {
|
||||
'pi': math.pi, 'e': math.e
|
||||
}
|
||||
|
||||
def safe_eval(node):
|
||||
if isinstance(node, ast.Constant): # Numbers
|
||||
return node.value
|
||||
elif isinstance(node, ast.BinOp): # Binary operations
|
||||
op_type = type(node.op)
|
||||
if op_type not in allowed_operators:
|
||||
raise ValueError(f"Operator {op_type.__name__} not allowed")
|
||||
left = safe_eval(node.left)
|
||||
right = safe_eval(node.right)
|
||||
return allowed_operators[op_type](left, right)
|
||||
elif isinstance(node, ast.UnaryOp): # Unary operations
|
||||
op_type = type(node.op)
|
||||
if op_type not in allowed_operators:
|
||||
raise ValueError(f"Operator {op_type.__name__} not allowed")
|
||||
operand = safe_eval(node.operand)
|
||||
return allowed_operators[op_type](operand)
|
||||
elif isinstance(node, ast.Call): # Function calls
|
||||
if not isinstance(node.func, ast.Name):
|
||||
raise ValueError("Only simple function calls are allowed")
|
||||
func_name = node.func.id
|
||||
if func_name not in allowed_functions:
|
||||
raise ValueError(f"Function '{func_name}' not allowed")
|
||||
args = [safe_eval(arg) for arg in node.args]
|
||||
return allowed_functions[func_name](*args)
|
||||
elif isinstance(node, ast.Name): # Constants like pi, e
|
||||
if node.id not in allowed_constants:
|
||||
raise ValueError(f"Name '{node.id}' not allowed")
|
||||
return allowed_constants[node.id]
|
||||
else:
|
||||
raise ValueError(f"Unsupported expression type: {type(node).__name__}")
|
||||
|
||||
# Parse the expression
|
||||
tree = ast.parse(expression, mode='eval')
|
||||
result = safe_eval(tree.body)
|
||||
|
||||
# Format result
|
||||
if isinstance(result, float):
|
||||
# Round to reasonable precision
|
||||
if result.is_integer():
|
||||
return f"Result: {int(result)}"
|
||||
else:
|
||||
return f"Result: {round(result, 10)}"
|
||||
else:
|
||||
return f"Result: {result}"
|
||||
|
||||
except ZeroDivisionError:
|
||||
return "Error: Division by zero"
|
||||
except SyntaxError:
|
||||
return f"Error: Invalid syntax in expression: '{expression}'"
|
||||
except ValueError as e:
|
||||
return f"Error: {str(e)}"
|
||||
except Exception as e:
|
||||
return f"Error calculating expression: {str(e)}"
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "database-query",
|
||||
"displayName": "Database Query",
|
||||
"description": "Query databases using SQL with AWS RDS Data API",
|
||||
"category": "data",
|
||||
"version": "1.0.0",
|
||||
"dependencies": ["boto3"],
|
||||
"parameters": {
|
||||
"sql": {
|
||||
"type": "string",
|
||||
"description": "SQL query to execute"
|
||||
},
|
||||
"database": {
|
||||
"type": "string",
|
||||
"default": "default",
|
||||
"description": "Database name to query"
|
||||
}
|
||||
},
|
||||
"permissions": ["rds_data_api"],
|
||||
"icon": "🗄️",
|
||||
"author": "Agent Tools Team",
|
||||
"license": "MIT",
|
||||
"requirements": [
|
||||
"AWS RDS Data API enabled",
|
||||
"Database cluster ARN configured",
|
||||
"Secrets Manager secret ARN configured",
|
||||
"IAM permissions for rds-data:ExecuteStatement"
|
||||
]
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
from strands import tool
|
||||
import boto3
|
||||
import os
|
||||
|
||||
@tool
|
||||
def database_query(sql: str, database: str = "default") -> str:
|
||||
"""
|
||||
Query a database using SQL via AWS RDS Data API.
|
||||
|
||||
This tool allows the agent to execute SQL queries against
|
||||
configured databases using AWS RDS Data API.
|
||||
|
||||
Args:
|
||||
sql: SQL query to execute
|
||||
database: Database name (default: "default")
|
||||
|
||||
Returns:
|
||||
Query results as formatted text
|
||||
|
||||
Example:
|
||||
result = database_query("SELECT * FROM users LIMIT 10")
|
||||
result = database_query("SELECT COUNT(*) FROM orders", database="analytics")
|
||||
|
||||
Note:
|
||||
Requires AWS RDS Data API to be configured. Set these environment variables:
|
||||
- RDS_RESOURCE_ARN: ARN of your RDS cluster
|
||||
- RDS_SECRET_ARN: ARN of your Secrets Manager secret
|
||||
- AWS_REGION: AWS region (optional, defaults to us-west-2)
|
||||
"""
|
||||
try:
|
||||
# Get configuration from environment variables
|
||||
resource_arn = os.environ.get('RDS_RESOURCE_ARN')
|
||||
secret_arn = os.environ.get('RDS_SECRET_ARN')
|
||||
region = os.environ.get('AWS_REGION', 'us-west-2')
|
||||
|
||||
# Validate configuration
|
||||
if not resource_arn or not secret_arn:
|
||||
return (
|
||||
"Error: Database not configured. Please set the following environment variables:\n"
|
||||
"- RDS_RESOURCE_ARN: ARN of your RDS cluster (e.g., arn:aws:rds:region:account:cluster:name)\n"
|
||||
"- RDS_SECRET_ARN: ARN of your Secrets Manager secret (e.g., arn:aws:secretsmanager:region:account:secret:name)\n"
|
||||
"- AWS_REGION: AWS region (optional, defaults to us-west-2)"
|
||||
)
|
||||
|
||||
# Initialize RDS Data API client
|
||||
rds_client = boto3.client('rds-data', region_name=region)
|
||||
|
||||
# Execute SQL statement
|
||||
response = rds_client.execute_statement(
|
||||
resourceArn=resource_arn,
|
||||
secretArn=secret_arn,
|
||||
database=database,
|
||||
sql=sql
|
||||
)
|
||||
|
||||
# Check if query returned records
|
||||
records = response.get('records', [])
|
||||
column_metadata = response.get('columnMetadata', [])
|
||||
|
||||
if not records:
|
||||
rows_updated = response.get('numberOfRecordsUpdated', 0)
|
||||
if rows_updated > 0:
|
||||
return f"Query executed successfully. {rows_updated} row(s) affected."
|
||||
else:
|
||||
return "Query executed successfully. No results returned."
|
||||
|
||||
# Format results as table
|
||||
result_text = f"Query returned {len(records)} row(s):\n\n"
|
||||
|
||||
# Add column headers
|
||||
if column_metadata:
|
||||
headers = [col.get('label', col.get('name', f'col_{i}'))
|
||||
for i, col in enumerate(column_metadata)]
|
||||
result_text += " | ".join(headers) + "\n"
|
||||
result_text += "-" * (len(" | ".join(headers))) + "\n"
|
||||
|
||||
# Add rows (limit to first 20 for readability)
|
||||
for i, record in enumerate(records[:20], 1):
|
||||
row_values = []
|
||||
for field in record:
|
||||
# Extract value from field (handles different data types)
|
||||
if 'stringValue' in field:
|
||||
row_values.append(field['stringValue'])
|
||||
elif 'longValue' in field:
|
||||
row_values.append(str(field['longValue']))
|
||||
elif 'doubleValue' in field:
|
||||
row_values.append(str(field['doubleValue']))
|
||||
elif 'booleanValue' in field:
|
||||
row_values.append(str(field['booleanValue']))
|
||||
elif 'isNull' in field and field['isNull']:
|
||||
row_values.append('NULL')
|
||||
else:
|
||||
row_values.append(str(field))
|
||||
|
||||
result_text += " | ".join(row_values) + "\n"
|
||||
|
||||
if len(records) > 20:
|
||||
result_text += f"\n... and {len(records) - 20} more row(s)"
|
||||
|
||||
return result_text
|
||||
|
||||
except rds_client.exceptions.BadRequestException as e:
|
||||
return f"Invalid SQL query: {str(e)}"
|
||||
except rds_client.exceptions.StatementTimeoutException:
|
||||
return "Query timed out. Try simplifying your query or adding appropriate indexes."
|
||||
except Exception as e:
|
||||
return f"Error executing database query: {str(e)}"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "datetime",
|
||||
"displayName": "Date & Time",
|
||||
"description": "Get current date, time, and timezone information",
|
||||
"category": "utility",
|
||||
"version": "1.0.0",
|
||||
"dependencies": ["pytz"],
|
||||
"parameters": {
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"default": "UTC",
|
||||
"description": "IANA timezone name (e.g., 'America/New_York', 'Europe/London', 'Asia/Tokyo')"
|
||||
}
|
||||
},
|
||||
"permissions": [],
|
||||
"icon": "🕐",
|
||||
"author": "Agent Tools Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
from strands import tool
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
@tool
|
||||
def get_datetime(timezone: str = "UTC") -> str:
|
||||
"""
|
||||
Get the current date, time, and timezone information.
|
||||
|
||||
This tool returns the current date and time in the specified timezone.
|
||||
Useful for time-aware responses and scheduling tasks.
|
||||
|
||||
Args:
|
||||
timezone: Timezone name (e.g., "UTC", "America/New_York", "Europe/London", "Asia/Tokyo")
|
||||
Default is "UTC". Use standard IANA timezone names.
|
||||
|
||||
Returns:
|
||||
Formatted string with current date, time, day of week, and timezone information
|
||||
|
||||
Example:
|
||||
result = get_datetime() # Returns UTC time
|
||||
result = get_datetime("America/New_York") # Returns New York time
|
||||
result = get_datetime("Europe/London") # Returns London time
|
||||
"""
|
||||
try:
|
||||
# Get timezone object
|
||||
try:
|
||||
tz = pytz.timezone(timezone)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
# If invalid timezone, default to UTC and note the error
|
||||
tz = pytz.UTC
|
||||
timezone_note = f" (Note: '{timezone}' is not a valid timezone, using UTC instead)"
|
||||
else:
|
||||
timezone_note = ""
|
||||
|
||||
# Get current time in specified timezone
|
||||
now = datetime.now(tz)
|
||||
|
||||
# Format the response
|
||||
result = []
|
||||
result.append(f"**Current Date & Time Information**{timezone_note}")
|
||||
result.append("")
|
||||
result.append(f"📅 **Date**: {now.strftime('%A, %B %d, %Y')}")
|
||||
result.append(f"🕐 **Time**: {now.strftime('%I:%M:%S %p')}")
|
||||
result.append(f"⏰ **24-Hour Time**: {now.strftime('%H:%M:%S')}")
|
||||
result.append(f"🌍 **Timezone**: {timezone} ({now.strftime('%Z')})")
|
||||
result.append(f"📍 **UTC Offset**: {now.strftime('%z')}")
|
||||
result.append(f"📊 **ISO Format**: {now.isoformat()}")
|
||||
result.append(f"🗓️ **Day of Year**: Day {now.strftime('%j')} of {now.year}")
|
||||
result.append(f"📆 **Week Number**: Week {now.strftime('%U')}")
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting datetime information: {str(e)}"
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "email-sender",
|
||||
"displayName": "Email Sender",
|
||||
"description": "Send emails using AWS SES",
|
||||
"category": "communication",
|
||||
"version": "1.0.0",
|
||||
"dependencies": ["boto3"],
|
||||
"parameters": {
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Recipient email address"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Email subject line"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Email body content"
|
||||
},
|
||||
"from_email": {
|
||||
"type": "string",
|
||||
"default": "noreply@example.com",
|
||||
"description": "Sender email address (must be verified in SES)"
|
||||
}
|
||||
},
|
||||
"permissions": ["ses_send_email"],
|
||||
"icon": "📧",
|
||||
"author": "Agent Tools Team",
|
||||
"license": "MIT",
|
||||
"requirements": [
|
||||
"AWS SES configured",
|
||||
"Sender email verified in SES",
|
||||
"IAM permissions for ses:SendEmail",
|
||||
"For production: Move out of SES sandbox"
|
||||
]
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
from strands import tool
|
||||
import boto3
|
||||
import os
|
||||
import re
|
||||
|
||||
@tool
|
||||
def send_email(to: str, subject: str, body: str, from_email: str = "noreply@example.com") -> str:
|
||||
"""
|
||||
Send an email using AWS SES (Simple Email Service).
|
||||
|
||||
This tool allows the agent to send emails to users or systems.
|
||||
|
||||
Args:
|
||||
to: Recipient email address
|
||||
subject: Email subject line
|
||||
body: Email body content (plain text)
|
||||
from_email: Sender email address (default: noreply@example.com)
|
||||
|
||||
Returns:
|
||||
Success message with message ID or error message
|
||||
|
||||
Example:
|
||||
result = send_email(
|
||||
to="user@example.com",
|
||||
subject="Welcome!",
|
||||
body="Thank you for signing up."
|
||||
)
|
||||
|
||||
Note:
|
||||
- Sender email must be verified in AWS SES
|
||||
- For production use, move out of SES sandbox
|
||||
- Update from_email default to your verified domain
|
||||
"""
|
||||
try:
|
||||
# Validate email addresses
|
||||
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
|
||||
if not re.match(email_pattern, to):
|
||||
return f"Error: Invalid recipient email address: {to}"
|
||||
|
||||
if not re.match(email_pattern, from_email):
|
||||
return f"Error: Invalid sender email address: {from_email}"
|
||||
|
||||
# Validate subject and body
|
||||
if not subject or not subject.strip():
|
||||
return "Error: Email subject cannot be empty"
|
||||
|
||||
if not body or not body.strip():
|
||||
return "Error: Email body cannot be empty"
|
||||
|
||||
# Initialize SES client with region from environment
|
||||
region = os.environ.get('AWS_REGION', 'us-west-2')
|
||||
ses_client = boto3.client('ses', region_name=region)
|
||||
|
||||
# Send email
|
||||
response = ses_client.send_email(
|
||||
Source=from_email,
|
||||
Destination={
|
||||
'ToAddresses': [to]
|
||||
},
|
||||
Message={
|
||||
'Subject': {
|
||||
'Data': subject,
|
||||
'Charset': 'UTF-8'
|
||||
},
|
||||
'Body': {
|
||||
'Text': {
|
||||
'Data': body,
|
||||
'Charset': 'UTF-8'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
message_id = response['MessageId']
|
||||
return f"Email sent successfully!\nTo: {to}\nSubject: {subject}\nMessage ID: {message_id}"
|
||||
|
||||
except ses_client.exceptions.MessageRejected as e:
|
||||
return f"Email rejected: {str(e)}\n\nNote: Ensure sender email is verified in AWS SES."
|
||||
except ses_client.exceptions.MailFromDomainNotVerifiedException:
|
||||
return f"Error: Sender domain not verified in AWS SES. Please verify {from_email} in SES console."
|
||||
except ses_client.exceptions.ConfigurationSetDoesNotExistException:
|
||||
return "Error: SES configuration set does not exist."
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "Email address is not verified" in error_msg:
|
||||
return f"Error: Email address not verified in AWS SES.\n\nTo use this tool:\n1. Go to AWS SES console\n2. Verify {from_email}\n3. For production, move out of SES sandbox"
|
||||
else:
|
||||
return f"Error sending email: {error_msg}"
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "web-crawler",
|
||||
"displayName": "Web Crawler",
|
||||
"description": "Crawl and extract content from websites using Crawl4AI",
|
||||
"category": "information",
|
||||
"version": "1.0.0",
|
||||
"dependencies": ["crawl4ai"],
|
||||
"parameters": {
|
||||
"extract_links": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Whether to extract links from the page"
|
||||
},
|
||||
"extract_images": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether to extract image URLs from the page"
|
||||
},
|
||||
"word_count_threshold": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"description": "Minimum word count for content blocks to be included"
|
||||
}
|
||||
},
|
||||
"permissions": ["internet_access"],
|
||||
"icon": "🕷️",
|
||||
"author": "Agent Tools Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
from strands import tool
|
||||
import asyncio
|
||||
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
||||
|
||||
|
||||
@tool
|
||||
def web_crawler(
|
||||
url: str,
|
||||
extract_links: bool = True,
|
||||
extract_images: bool = False,
|
||||
word_count_threshold: int = 10
|
||||
) -> str:
|
||||
"""
|
||||
Crawl a website and extract its content using Crawl4AI.
|
||||
|
||||
This tool allows the agent to crawl specific web pages and extract
|
||||
their content in a clean, readable format. It handles JavaScript-rendered
|
||||
pages and provides structured content extraction.
|
||||
|
||||
Args:
|
||||
url: The URL of the website to crawl
|
||||
extract_links: Whether to include extracted links in the output (default: True)
|
||||
extract_images: Whether to include image URLs in the output (default: False)
|
||||
word_count_threshold: Minimum words per content block to include (default: 10)
|
||||
|
||||
Returns:
|
||||
Extracted content from the website including text, and optionally links and images
|
||||
|
||||
Example:
|
||||
result = web_crawler("https://example.com")
|
||||
result = web_crawler("https://docs.python.org", extract_links=True)
|
||||
"""
|
||||
try:
|
||||
# Run the async crawler
|
||||
result = asyncio.run(_crawl_website(
|
||||
url, extract_links, extract_images, word_count_threshold
|
||||
))
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"Error crawling website: {str(e)}"
|
||||
|
||||
|
||||
async def _crawl_website(
|
||||
url: str,
|
||||
extract_links: bool,
|
||||
extract_images: bool,
|
||||
word_count_threshold: int
|
||||
) -> str:
|
||||
"""Async helper function to perform the actual crawling."""
|
||||
|
||||
# Configure browser settings
|
||||
browser_config = BrowserConfig(
|
||||
headless=True,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Configure crawler run settings
|
||||
crawler_config = CrawlerRunConfig(
|
||||
word_count_threshold=word_count_threshold,
|
||||
exclude_external_links=False,
|
||||
remove_overlay_elements=True,
|
||||
process_iframes=False
|
||||
)
|
||||
|
||||
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||
result = await crawler.arun(
|
||||
url=url,
|
||||
config=crawler_config
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return f"Failed to crawl {url}: {result.error_message or 'Unknown error'}"
|
||||
|
||||
# Build the output
|
||||
output_parts = []
|
||||
|
||||
# Add page title
|
||||
if result.metadata and result.metadata.get('title'):
|
||||
output_parts.append(f"# {result.metadata['title']}\n")
|
||||
|
||||
output_parts.append(f"**URL:** {url}\n")
|
||||
|
||||
# Add main content (markdown format)
|
||||
if result.markdown:
|
||||
output_parts.append("## Content\n")
|
||||
# Truncate if too long
|
||||
content = result.markdown
|
||||
if len(content) > 10000:
|
||||
content = content[:10000] + "\n\n... [Content truncated]"
|
||||
output_parts.append(content)
|
||||
|
||||
# Add extracted links if requested
|
||||
if extract_links and result.links:
|
||||
internal_links = result.links.get('internal', [])
|
||||
external_links = result.links.get('external', [])
|
||||
|
||||
if internal_links or external_links:
|
||||
output_parts.append("\n## Extracted Links\n")
|
||||
|
||||
if internal_links:
|
||||
output_parts.append("### Internal Links")
|
||||
for link in internal_links[:20]: # Limit to 20 links
|
||||
href = link.get('href', '')
|
||||
text = link.get('text', 'No text')
|
||||
output_parts.append(f"- [{text[:50]}]({href})")
|
||||
|
||||
if external_links:
|
||||
output_parts.append("\n### External Links")
|
||||
for link in external_links[:20]: # Limit to 20 links
|
||||
href = link.get('href', '')
|
||||
text = link.get('text', 'No text')
|
||||
output_parts.append(f"- [{text[:50]}]({href})")
|
||||
|
||||
# Add extracted images if requested
|
||||
if extract_images and result.media:
|
||||
images = result.media.get('images', [])
|
||||
if images:
|
||||
output_parts.append("\n## Images\n")
|
||||
for img in images[:10]: # Limit to 10 images
|
||||
src = img.get('src', '')
|
||||
alt = img.get('alt', 'No description')
|
||||
output_parts.append(f"- {alt}: {src}")
|
||||
|
||||
return "\n".join(output_parts)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "web-search",
|
||||
"displayName": "Web Search",
|
||||
"description": "Search the web using DuckDuckGo (requests + BeautifulSoup)",
|
||||
"category": "information",
|
||||
"version": "2.1.0",
|
||||
"dependencies": ["requests", "beautifulsoup4"],
|
||||
"parameters": {
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"default": 5,
|
||||
"min": 1,
|
||||
"max": 10,
|
||||
"description": "Maximum number of search results to return"
|
||||
}
|
||||
},
|
||||
"permissions": ["internet_access"],
|
||||
"icon": "🔍",
|
||||
"author": "Agent Tools Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
from strands import tool
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import urllib.parse
|
||||
|
||||
|
||||
@tool
|
||||
def web_search(query: str, max_results: int = 5) -> str:
|
||||
"""
|
||||
Search the web for information using DuckDuckGo.
|
||||
|
||||
This tool allows the agent to search the internet and retrieve
|
||||
relevant information to answer user questions. It uses DuckDuckGo's
|
||||
HTML interface for reliable results without external dependencies.
|
||||
|
||||
Args:
|
||||
query: The search query string
|
||||
max_results: Maximum number of results to return (default: 5, max: 10)
|
||||
|
||||
Returns:
|
||||
Formatted search results as a string with titles, snippets, and URLs
|
||||
|
||||
Example:
|
||||
result = web_search("Python programming tutorials", max_results=3)
|
||||
result = web_search("latest AI news")
|
||||
"""
|
||||
try:
|
||||
# Limit max_results to reasonable range
|
||||
max_results = min(max(1, max_results), 10)
|
||||
|
||||
# Use DuckDuckGo HTML interface
|
||||
url = "https://html.duckduckgo.com/html/"
|
||||
|
||||
# Prepare search parameters
|
||||
params = {
|
||||
'q': query,
|
||||
'kl': 'us-en' # Region/language
|
||||
}
|
||||
|
||||
# Set headers to mimic a browser
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
}
|
||||
|
||||
# Make the request
|
||||
response = requests.post(url, data=params, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Find search results
|
||||
results = []
|
||||
result_divs = soup.find_all('div', class_='result')
|
||||
|
||||
if not result_divs:
|
||||
# Try alternative selectors
|
||||
result_divs = soup.find_all('div', class_='results_links')
|
||||
|
||||
for result_div in result_divs[:max_results]:
|
||||
try:
|
||||
# Extract title and link
|
||||
title_elem = result_div.find('a', class_='result__a')
|
||||
if not title_elem:
|
||||
title_elem = result_div.find('a', class_='large')
|
||||
|
||||
if title_elem:
|
||||
title = title_elem.get_text(strip=True)
|
||||
link = title_elem.get('href', '')
|
||||
|
||||
# Extract snippet/description
|
||||
snippet_elem = result_div.find('a', class_='result__snippet')
|
||||
if not snippet_elem:
|
||||
snippet_elem = result_div.find('div', class_='result__snippet')
|
||||
if not snippet_elem:
|
||||
snippet_elem = result_div.find('td', class_='result-snippet')
|
||||
|
||||
snippet = snippet_elem.get_text(strip=True) if snippet_elem else 'No description available'
|
||||
|
||||
# Clean up the link (DuckDuckGo sometimes uses redirect URLs)
|
||||
if link.startswith('/'):
|
||||
# Try to extract the actual URL from redirect
|
||||
if 'uddg=' in link:
|
||||
link = urllib.parse.unquote(link.split('uddg=')[1].split('&')[0])
|
||||
|
||||
results.append({
|
||||
'title': title,
|
||||
'snippet': snippet,
|
||||
'url': link
|
||||
})
|
||||
except Exception:
|
||||
# Skip this result if there's an error parsing it
|
||||
continue
|
||||
|
||||
if not results:
|
||||
return f"No results found for query: '{query}'"
|
||||
|
||||
# Format results
|
||||
formatted_results = []
|
||||
for idx, result in enumerate(results, 1):
|
||||
result_text = f"{idx}. **{result['title']}**\n"
|
||||
result_text += f" {result['snippet']}\n"
|
||||
if result['url']:
|
||||
result_text += f" URL: {result['url']}\n"
|
||||
|
||||
formatted_results.append(result_text)
|
||||
|
||||
header = f"Found {len(results)} results for '{query}':\n\n"
|
||||
return header + "\n".join(formatted_results)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return f"Search timed out for query: '{query}'. Please try again."
|
||||
except requests.exceptions.RequestException as e:
|
||||
return f"Network error during search: {str(e)}"
|
||||
except Exception as e:
|
||||
return f"Error performing web search: {str(e)}"
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "🧹 Starting cleanup process..."
|
||||
echo ""
|
||||
|
||||
# Get stack outputs
|
||||
echo "📋 Retrieving stack information..."
|
||||
STACK_OUTPUTS=$(aws cloudformation describe-stacks --stack-name BedrockAgentStack --query 'Stacks[0].Outputs' --output json 2>/dev/null || echo "[]")
|
||||
|
||||
if [ "$STACK_OUTPUTS" = "[]" ]; then
|
||||
echo "⚠️ Stack not found or already deleted. Skipping agent cleanup."
|
||||
else
|
||||
# Extract API endpoint and key
|
||||
API_ENDPOINT=$(echo $STACK_OUTPUTS | jq -r '.[] | select(.OutputKey=="ApiEndpoint") | .OutputValue')
|
||||
API_KEY_ID=$(echo $STACK_OUTPUTS | jq -r '.[] | select(.OutputKey=="ApiKeyId") | .OutputValue')
|
||||
|
||||
if [ -n "$API_ENDPOINT" ] && [ -n "$API_KEY_ID" ]; then
|
||||
# Get the actual API key value
|
||||
echo "🔑 Retrieving API key..."
|
||||
API_KEY=$(aws apigateway get-api-key --api-key $API_KEY_ID --include-value --query 'value' --output text 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$API_KEY" ]; then
|
||||
echo "🤖 Fetching deployed agents..."
|
||||
|
||||
# Get list of all agents (only agents deployed by this app from DynamoDB)
|
||||
AGENTS=$(curl -s -H "x-api-key: $API_KEY" "$API_ENDPOINT/agents" || echo "[]")
|
||||
|
||||
# Check if we got valid JSON
|
||||
if echo "$AGENTS" | jq empty 2>/dev/null; then
|
||||
AGENT_COUNT=$(echo "$AGENTS" | jq 'length')
|
||||
|
||||
if [ "$AGENT_COUNT" -gt 0 ]; then
|
||||
echo "Found $AGENT_COUNT agent(s) deployed by this application to delete..."
|
||||
echo ""
|
||||
|
||||
# Ask for confirmation
|
||||
read -p "⚠️ Are you sure you want to delete all agents and destroy the stack? (yes/no): " CONFIRM
|
||||
|
||||
if [ "$CONFIRM" != "yes" ]; then
|
||||
echo "❌ Cleanup cancelled by user"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Delete each agent
|
||||
echo "$AGENTS" | jq -c '.[]' | while read -r agent; do
|
||||
TENANT_ID=$(echo "$agent" | jq -r '.tenantId')
|
||||
AGENT_ID=$(echo "$agent" | jq -r '.agentRuntimeId')
|
||||
AGENT_NAME=$(echo "$agent" | jq -r '.agentName // "Unnamed"')
|
||||
|
||||
echo " 🗑️ Deleting agent: $AGENT_NAME (Tenant: $TENANT_ID)"
|
||||
|
||||
DELETE_RESPONSE=$(curl -s -X DELETE -H "x-api-key: $API_KEY" \
|
||||
"$API_ENDPOINT/agent?tenantId=$TENANT_ID&agentRuntimeId=$AGENT_ID")
|
||||
|
||||
if echo "$DELETE_RESPONSE" | grep -q "deleted successfully\|Agent deleted"; then
|
||||
echo " ✅ Deleted successfully"
|
||||
else
|
||||
echo " ⚠️ Delete response: $DELETE_RESPONSE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ All agents deleted"
|
||||
else
|
||||
echo "✅ No agents found to delete"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Could not retrieve agents list. Proceeding with stack deletion..."
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Could not retrieve API key. Skipping agent cleanup."
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Could not find API endpoint or key. Skipping agent cleanup."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "☁️ Destroying CDK stack..."
|
||||
echo ""
|
||||
|
||||
# Ask for final confirmation before destroying stack
|
||||
read -p "⚠️ Proceed with CDK stack destruction? (yes/no): " STACK_CONFIRM
|
||||
|
||||
if [ "$STACK_CONFIRM" != "yes" ]; then
|
||||
echo "❌ Stack destruction cancelled by user"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
cdk destroy --app "python3 src/cdk_app.py" --force
|
||||
|
||||
echo ""
|
||||
echo "✅ Cleanup complete!"
|
||||
echo ""
|
||||
echo "All resources have been removed:"
|
||||
echo " - Bedrock Agent Core agents"
|
||||
echo " - API Gateway and Lambda functions"
|
||||
echo " - DynamoDB tables"
|
||||
echo " - S3 buckets"
|
||||
echo " - CloudFront distribution"
|
||||
echo " - SQS queue"
|
||||
echo " - IAM roles"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "🚀 Building and deploying Bedrock Agent Stack..."
|
||||
echo ""
|
||||
|
||||
# Install Python CDK dependencies
|
||||
echo "📦 Installing Python CDK dependencies..."
|
||||
pip install -r src/cdk_requirements.txt
|
||||
|
||||
echo ""
|
||||
|
||||
# Build frontend
|
||||
echo "📦 Installing frontend dependencies..."
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
echo "🔨 Building React app..."
|
||||
npm run build
|
||||
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo "☁️ Deploying CDK stack..."
|
||||
echo " - Infrastructure (API Gateway, Lambda, DynamoDB, SQS)"
|
||||
echo " - Frontend (S3 + CloudFront)"
|
||||
echo " - Auto-generating config.js with API credentials"
|
||||
echo ""
|
||||
|
||||
# Set region to us-east-1 (or use AWS_DEFAULT_REGION if set)
|
||||
export CDK_DEFAULT_REGION=${AWS_DEFAULT_REGION:-us-east-1}
|
||||
echo " Deploying to region: $CDK_DEFAULT_REGION"
|
||||
echo ""
|
||||
|
||||
cd src
|
||||
cdk bootstrap
|
||||
cd ..
|
||||
cdk deploy --require-approval never --app "python3 src/cdk_app.py"
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "Stack Outputs:"
|
||||
echo " Check the outputs above for:"
|
||||
echo " - Frontend URL (CloudFront)"
|
||||
echo " - API Endpoint"
|
||||
echo " - API Key ID"
|
||||
echo ""
|
||||
echo "💡 The config.js file has been automatically generated and deployed."
|
||||
echo " Your frontend is ready to use at the CloudFront URL above."
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 232 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 510 KiB |
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Bedrock Agent Dashboard" />
|
||||
<title>Bedrock Agent Dashboard</title>
|
||||
<script
|
||||
src="https://sdk.amazonaws.com/js/aws-sdk-2.1000.0.min.js"
|
||||
integrity="sha384-BOEcjazI3616mVk0MbtJ3KNhp85nYlJGuOpgJRw4KCxuB7ml/yU2GpTRkf4WOi6v"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "bedrock-agent-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^3.0.0-beta.3",
|
||||
"@heroui/styles": "^3.0.0-beta.3",
|
||||
"axios": "^1.6.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-is": "^19.2.3",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwind-variants": "^0.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"fast-check": "^3.15.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# config.js is generated at deployment time, not included in build
|
||||
config.js
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Minimal custom styles - HeroUI v3 handles most styling */
|
||||
|
||||
/* Custom utility classes for spacing and layout */
|
||||
.space-y-1 > * + * { margin-top: 0.25rem; }
|
||||
.space-y-2 > * + * { margin-top: 0.5rem; }
|
||||
.space-y-4 > * + * { margin-top: 1rem; }
|
||||
.space-y-6 > * + * { margin-top: 1.5rem; }
|
||||
|
||||
/* Table styles */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Code styling - use monospace font */
|
||||
code {
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace);
|
||||
}
|
||||
|
||||
/* Ensure charts use the same font */
|
||||
.recharts-wrapper,
|
||||
.recharts-text,
|
||||
.recharts-cartesian-axis-tick-value {
|
||||
font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif) !important;
|
||||
}
|
||||
|
||||
/* Smooth transitions - exclude font properties */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, opacity, transform;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
/* Modal Advanced Configuration - Form field visibility improvements */
|
||||
.bg-surface-secondary input,
|
||||
.bg-surface-secondary textarea {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3) !important;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.bg-surface-secondary input:focus,
|
||||
.bg-surface-secondary textarea:focus {
|
||||
border-color: #0066cc !important;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
/* Select/Dropdown styling with custom class */
|
||||
.modal-select-trigger {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.3) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
|
||||
.modal-select-trigger:hover {
|
||||
border-color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.modal-select-trigger:focus {
|
||||
border-color: #0066cc !important;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
|
||||
/* Checkbox styling with custom class */
|
||||
.modal-checkbox-control {
|
||||
background-color: #fff !important;
|
||||
border: 2px solid rgba(0, 0, 0, 0.4) !important;
|
||||
border-radius: 0.25rem !important;
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
min-width: 1.25rem !important;
|
||||
min-height: 1.25rem !important;
|
||||
}
|
||||
|
||||
.modal-checkbox-control:hover {
|
||||
border-color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.modal-checkbox[data-selected] .modal-checkbox-control,
|
||||
.modal-checkbox-control[data-selected] {
|
||||
background-color: #0066cc !important;
|
||||
border-color: #0066cc !important;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme="dark"] .bg-surface-secondary input,
|
||||
[data-theme="dark"] .bg-surface-secondary textarea {
|
||||
background-color: #333 !important;
|
||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-select-trigger {
|
||||
background-color: #333 !important;
|
||||
border-color: rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-checkbox-control {
|
||||
background-color: #333 !important;
|
||||
border-color: rgba(255, 255, 255, 0.4) !important;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
/* Tailwind CSS v4 */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* HeroUI v3 styles - includes default theme */
|
||||
@import "@heroui/styles";
|
||||
|
||||
/* HeroUI Default Font Family */
|
||||
:root {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
/* Apply default font to everything */
|
||||
html, body, #root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.bg-background { background-color: var(--background); }
|
||||
.bg-surface { background-color: var(--surface); }
|
||||
.bg-surface-secondary { background-color: var(--default); }
|
||||
.bg-surface-tertiary { background-color: var(--separator); }
|
||||
.bg-overlay { background-color: var(--overlay); }
|
||||
.bg-accent { background-color: var(--accent); }
|
||||
.bg-accent-soft { background-color: rgba(98, 4, 195, 0.1); }
|
||||
.bg-success { background-color: var(--success); }
|
||||
.bg-success-soft { background-color: rgba(115, 41, 115, 0.1); }
|
||||
.bg-warning { background-color: var(--warning); }
|
||||
.bg-warning-soft { background-color: rgba(120, 25, 120, 0.1); }
|
||||
.bg-danger { background-color: var(--danger); }
|
||||
.bg-danger-soft { background-color: rgba(101, 50, 35, 0.1); }
|
||||
|
||||
.text-foreground { color: var(--foreground); }
|
||||
.text-muted { color: var(--muted); }
|
||||
.text-accent { color: var(--accent); }
|
||||
.text-success { color: var(--success); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-danger { color: var(--danger); }
|
||||
|
||||
.border-border { border-color: var(--border); }
|
||||
.border-accent { border-color: var(--accent); }
|
||||
.border-separator { border-color: var(--separator); }
|
||||
|
||||
.shadow-surface { box-shadow: var(--surface-shadow); }
|
||||
.shadow-overlay { box-shadow: var(--overlay-shadow); }
|
||||
|
||||
/* Base body styles */
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Ensure all elements inherit the font */
|
||||
*, *::before, *::after {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Monospace font for code elements */
|
||||
code, kbd, pre, samp {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Smooth theme transitions */
|
||||
* {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Token limit validation utilities.
|
||||
* Extracted for testability.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate that a token limit value is a positive integer.
|
||||
* @param {string|number} value - The value to validate
|
||||
* @returns {{ valid: boolean, error: string }} Validation result
|
||||
*/
|
||||
export function validateTokenLimit(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { valid: false, error: 'Token limit is required for new tenants' };
|
||||
}
|
||||
|
||||
const strValue = String(value).trim();
|
||||
|
||||
// Check for decimal point
|
||||
if (strValue.includes('.')) {
|
||||
return { valid: false, error: 'Token limit must be a whole number' };
|
||||
}
|
||||
|
||||
const num = parseInt(strValue, 10);
|
||||
|
||||
if (isNaN(num)) {
|
||||
return { valid: false, error: 'Token limit must be a positive number' };
|
||||
}
|
||||
|
||||
if (num <= 0) {
|
||||
return { valid: false, error: 'Token limit must be a positive number' };
|
||||
}
|
||||
|
||||
return { valid: true, error: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate usage percentage from total tokens and limit.
|
||||
* @param {number} totalTokens - Current total tokens used
|
||||
* @param {number|null} tokenLimit - Token limit (null means no limit)
|
||||
* @returns {number|null} Percentage or null if no limit
|
||||
*/
|
||||
export function calculateUsagePercentage(totalTokens, tokenLimit) {
|
||||
if (tokenLimit === null || tokenLimit === undefined || tokenLimit <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (totalTokens / tokenLimit) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color indicator for usage percentage.
|
||||
* @param {number|null} percentage - Usage percentage
|
||||
* @returns {string} Color name: 'danger', 'warning', 'success', or 'default'
|
||||
*/
|
||||
export function getUsageColor(percentage) {
|
||||
if (percentage === null) return 'default';
|
||||
if (percentage >= 100) return 'danger';
|
||||
if (percentage >= 80) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total cost from inference cost and infrastructure cost.
|
||||
* @param {number} inferenceCost - Cost from token usage
|
||||
* @param {number} infrastructureCost - Cost from AWS infrastructure
|
||||
* @returns {number} Total cost (inference + infrastructure)
|
||||
*/
|
||||
export function calculateTotalCost(inferenceCost, infrastructureCost) {
|
||||
const inference = Number(inferenceCost) || 0;
|
||||
const infra = Number(infrastructureCost) || 0;
|
||||
return inference + infra;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost value as USD with 6 decimal places.
|
||||
* @param {number} cost - Cost value
|
||||
* @returns {string} Formatted cost string (e.g., "$0.045678")
|
||||
*/
|
||||
export function formatCost(cost) {
|
||||
const numCost = Number(cost) || 0;
|
||||
return `$${numCost.toFixed(6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total cost summary across all tenants.
|
||||
* @param {Array} tenantData - Array of tenant data with costs
|
||||
* @param {Array} infrastructureCosts - Array of infrastructure cost data
|
||||
* @returns {{ totalInference: number, totalInfrastructure: number, grandTotal: number }}
|
||||
*/
|
||||
export function calculateCostSummary(tenantData, infrastructureCosts) {
|
||||
let totalInference = 0;
|
||||
let totalInfrastructure = 0;
|
||||
|
||||
// Calculate inference costs
|
||||
for (const item of tenantData) {
|
||||
if (item.aggregation_key?.startsWith('tenant:')) {
|
||||
const inputTokens = Number(item.input_tokens) || 0;
|
||||
const outputTokens = Number(item.output_tokens) || 0;
|
||||
const inferenceCost = Number(item.total_cost) || ((inputTokens * 0.003 / 1000) + (outputTokens * 0.015 / 1000));
|
||||
totalInference += inferenceCost;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate infrastructure costs
|
||||
for (const item of infrastructureCosts) {
|
||||
totalInfrastructure += Number(item.infrastructure_cost) || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
totalInference,
|
||||
totalInfrastructure,
|
||||
grandTotal: totalInference + totalInfrastructure
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Property-based tests for frontend validation utilities.
|
||||
*
|
||||
* Feature: tenant-token-limits
|
||||
* Property 1: Token Limit Validation (Frontend)
|
||||
* Property 4: Usage Percentage Calculation
|
||||
* Property 5: Usage Percentage Color Coding
|
||||
* Validates: Requirements 1.4, 3.2, 3.4, 3.5
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fc from 'fast-check';
|
||||
import { validateTokenLimit, calculateUsagePercentage, getUsageColor } from './validation.js';
|
||||
|
||||
describe('Token Limit Validation (Property 1)', () => {
|
||||
/**
|
||||
* Property 1: Token Limit Validation (Frontend)
|
||||
* For any positive integer, validation should return valid: true.
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
it('should accept any positive integer', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 10 ** 12 }), (value) => {
|
||||
const result = validateTokenLimit(value);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBe('');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 1: Token Limit Validation (Frontend)
|
||||
* For any zero or negative integer, validation should return valid: false.
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
it('should reject zero and negative integers', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ max: 0 }), (value) => {
|
||||
const result = validateTokenLimit(value);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeTruthy();
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 1: Token Limit Validation (Frontend)
|
||||
* For any string representation of a positive integer, validation should return valid: true.
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
it('should accept string representations of positive integers', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 10 ** 12 }), (value) => {
|
||||
const result = validateTokenLimit(String(value));
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBe('');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 1: Token Limit Validation (Frontend)
|
||||
* For any non-integer decimal, validation should return valid: false.
|
||||
* Validates: Requirements 1.4
|
||||
*/
|
||||
it('should reject decimal numbers', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.double({ min: 0.01, max: 10 ** 6, noNaN: true }).filter(x => !Number.isInteger(x)),
|
||||
(value) => {
|
||||
const result = validateTokenLimit(String(value));
|
||||
expect(result.valid).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Edge case tests
|
||||
it('should reject null', () => {
|
||||
const result = validateTokenLimit(null);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject undefined', () => {
|
||||
const result = validateTokenLimit(undefined);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
const result = validateTokenLimit('');
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept minimum valid value (1)', () => {
|
||||
const result = validateTokenLimit(1);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Usage Percentage Calculation (Property 4)', () => {
|
||||
/**
|
||||
* Property 4: Usage Percentage Calculation
|
||||
* For any valid usage and limit, percentage should equal (usage / limit) * 100.
|
||||
* Validates: Requirements 3.2
|
||||
*/
|
||||
it('should calculate percentage correctly for any valid inputs', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 10 ** 9 }),
|
||||
fc.integer({ min: 1, max: 10 ** 9 }),
|
||||
(totalTokens, tokenLimit) => {
|
||||
const result = calculateUsagePercentage(totalTokens, tokenLimit);
|
||||
const expected = (totalTokens / tokenLimit) * 100;
|
||||
expect(result).toBeCloseTo(expected, 10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 4: Usage Percentage Calculation
|
||||
* When limit is null/undefined/0, should return null.
|
||||
* Validates: Requirements 3.3
|
||||
*/
|
||||
it('should return null when no limit is set', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 10 ** 9 }), (totalTokens) => {
|
||||
expect(calculateUsagePercentage(totalTokens, null)).toBe(null);
|
||||
expect(calculateUsagePercentage(totalTokens, undefined)).toBe(null);
|
||||
expect(calculateUsagePercentage(totalTokens, 0)).toBe(null);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it('should return 0% when usage is 0', () => {
|
||||
expect(calculateUsagePercentage(0, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 100% when usage equals limit', () => {
|
||||
expect(calculateUsagePercentage(1000, 1000)).toBe(100);
|
||||
});
|
||||
|
||||
it('should return >100% when usage exceeds limit', () => {
|
||||
expect(calculateUsagePercentage(1500, 1000)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Usage Percentage Color Coding (Property 5)', () => {
|
||||
/**
|
||||
* Property 5: Usage Percentage Color Coding
|
||||
* For any percentage < 80, should return 'success'.
|
||||
* Validates: Requirements 3.4, 3.5
|
||||
*/
|
||||
it('should return success for percentages below 80', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.double({ min: 0, max: 79.99, noNaN: true }), (percentage) => {
|
||||
expect(getUsageColor(percentage)).toBe('success');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 5: Usage Percentage Color Coding
|
||||
* For any percentage >= 80 and < 100, should return 'warning'.
|
||||
* Validates: Requirements 3.4
|
||||
*/
|
||||
it('should return warning for percentages between 80 and 99.99', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.double({ min: 80, max: 99.99, noNaN: true }), (percentage) => {
|
||||
expect(getUsageColor(percentage)).toBe('warning');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 5: Usage Percentage Color Coding
|
||||
* For any percentage >= 100, should return 'danger'.
|
||||
* Validates: Requirements 3.5
|
||||
*/
|
||||
it('should return danger for percentages at or above 100', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.double({ min: 100, max: 1000, noNaN: true }), (percentage) => {
|
||||
expect(getUsageColor(percentage)).toBe('danger');
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 5: Usage Percentage Color Coding
|
||||
* For null percentage, should return 'default'.
|
||||
* Validates: Requirements 3.3
|
||||
*/
|
||||
it('should return default for null percentage', () => {
|
||||
expect(getUsageColor(null)).toBe('default');
|
||||
});
|
||||
|
||||
// Boundary tests
|
||||
it('should return success at 79.9%', () => {
|
||||
expect(getUsageColor(79.9)).toBe('success');
|
||||
});
|
||||
|
||||
it('should return warning at exactly 80%', () => {
|
||||
expect(getUsageColor(80)).toBe('warning');
|
||||
});
|
||||
|
||||
it('should return warning at 99.9%', () => {
|
||||
expect(getUsageColor(99.9)).toBe('warning');
|
||||
});
|
||||
|
||||
it('should return danger at exactly 100%', () => {
|
||||
expect(getUsageColor(100)).toBe('danger');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Infrastructure Cost Tests
|
||||
// Feature: tenant-infrastructure-costs
|
||||
// ============================================================
|
||||
|
||||
import { calculateTotalCost, formatCost, calculateCostSummary } from './validation.js';
|
||||
|
||||
describe('Total Cost Calculation (Property 4)', () => {
|
||||
/**
|
||||
* Property 4: Total Cost Calculation
|
||||
* For any inference and infrastructure cost pair, total should equal their sum.
|
||||
* Validates: Requirements 4.1, 4.2
|
||||
*/
|
||||
it('should calculate total cost as sum of inference and infrastructure', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.double({ min: 0, max: 10000, noNaN: true }),
|
||||
fc.double({ min: 0, max: 10000, noNaN: true }),
|
||||
(inferenceCost, infrastructureCost) => {
|
||||
const result = calculateTotalCost(inferenceCost, infrastructureCost);
|
||||
const expected = inferenceCost + infrastructureCost;
|
||||
expect(result).toBeCloseTo(expected, 10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 4: Total Cost Calculation
|
||||
* When either cost is null/undefined, should treat as 0.
|
||||
* Validates: Requirements 4.1, 4.2
|
||||
*/
|
||||
it('should handle null/undefined costs as zero', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.double({ min: 0, max: 10000, noNaN: true }), (cost) => {
|
||||
expect(calculateTotalCost(cost, null)).toBeCloseTo(cost, 10);
|
||||
expect(calculateTotalCost(cost, undefined)).toBeCloseTo(cost, 10);
|
||||
expect(calculateTotalCost(null, cost)).toBeCloseTo(cost, 10);
|
||||
expect(calculateTotalCost(undefined, cost)).toBeCloseTo(cost, 10);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it('should return 0 when both costs are 0', () => {
|
||||
expect(calculateTotalCost(0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 when both costs are null', () => {
|
||||
expect(calculateTotalCost(null, null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cost Formatting (Property 5)', () => {
|
||||
/**
|
||||
* Property 5: Cost Formatting
|
||||
* For any cost value, format should be "$X.XXXXXX" (6 decimal places).
|
||||
* Validates: Requirements 3.2, 4.3
|
||||
*/
|
||||
it('should format costs with $ prefix and 6 decimal places', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.double({ min: 0, max: 10000, noNaN: true }), (cost) => {
|
||||
const result = formatCost(cost);
|
||||
expect(result).toMatch(/^\$\d+\.\d{6}$/);
|
||||
expect(result.startsWith('$')).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property 5: Cost Formatting
|
||||
* Formatted cost should preserve the value (within precision).
|
||||
* Validates: Requirements 3.2, 4.3
|
||||
*/
|
||||
it('should preserve cost value in formatted string', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.double({ min: 0, max: 10000, noNaN: true }), (cost) => {
|
||||
const result = formatCost(cost);
|
||||
const parsed = parseFloat(result.substring(1)); // Remove $ prefix
|
||||
expect(parsed).toBeCloseTo(cost, 6);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it('should format 0 as $0.000000', () => {
|
||||
expect(formatCost(0)).toBe('$0.000000');
|
||||
});
|
||||
|
||||
it('should format null as $0.000000', () => {
|
||||
expect(formatCost(null)).toBe('$0.000000');
|
||||
});
|
||||
|
||||
it('should format undefined as $0.000000', () => {
|
||||
expect(formatCost(undefined)).toBe('$0.000000');
|
||||
});
|
||||
|
||||
it('should format small values correctly', () => {
|
||||
expect(formatCost(0.000001)).toBe('$0.000001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cost Summary Calculation (Property 6)', () => {
|
||||
/**
|
||||
* Property 6: Cost Summary Calculation
|
||||
* Grand total should equal sum of all inference costs plus all infrastructure costs.
|
||||
* Validates: Requirements 4.5, 6.3
|
||||
*/
|
||||
it('should calculate summary totals correctly', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
aggregation_key: fc.constant('tenant:test'),
|
||||
tenant_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
input_tokens: fc.integer({ min: 0, max: 100000 }),
|
||||
output_tokens: fc.integer({ min: 0, max: 100000 }),
|
||||
total_cost: fc.double({ min: 0, max: 100, noNaN: true })
|
||||
}),
|
||||
{ minLength: 0, maxLength: 10 }
|
||||
),
|
||||
fc.array(
|
||||
fc.record({
|
||||
tenant_id: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
infrastructure_cost: fc.double({ min: 0, max: 100, noNaN: true })
|
||||
}),
|
||||
{ minLength: 0, maxLength: 10 }
|
||||
),
|
||||
(tenantData, infraCosts) => {
|
||||
const result = calculateCostSummary(tenantData, infraCosts);
|
||||
|
||||
// Verify grand total equals sum of components
|
||||
expect(result.grandTotal).toBeCloseTo(
|
||||
result.totalInference + result.totalInfrastructure,
|
||||
10
|
||||
);
|
||||
|
||||
// Verify infrastructure total
|
||||
const expectedInfra = infraCosts.reduce(
|
||||
(sum, item) => sum + (Number(item.infrastructure_cost) || 0),
|
||||
0
|
||||
);
|
||||
expect(result.totalInfrastructure).toBeCloseTo(expectedInfra, 10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
// Edge cases
|
||||
it('should return zeros for empty arrays', () => {
|
||||
const result = calculateCostSummary([], []);
|
||||
expect(result.totalInference).toBe(0);
|
||||
expect(result.totalInfrastructure).toBe(0);
|
||||
expect(result.grandTotal).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle tenants without infrastructure costs', () => {
|
||||
const tenantData = [
|
||||
{ aggregation_key: 'tenant:test', tenant_id: 'test', total_cost: 1.5 }
|
||||
];
|
||||
const result = calculateCostSummary(tenantData, []);
|
||||
expect(result.totalInference).toBe(1.5);
|
||||
expect(result.totalInfrastructure).toBe(0);
|
||||
expect(result.grandTotal).toBe(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"app": "python3 cdk_app.py"
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bedrock Agent Stack - Refactored CDK Application
|
||||
|
||||
This is the main entry point for the CDK application.
|
||||
The stack is organized into logical constructs for better maintainability.
|
||||
"""
|
||||
|
||||
import os
|
||||
import aws_cdk as cdk
|
||||
from constructs import Construct
|
||||
from aws_cdk import Stack, Tags, RemovalPolicy, aws_s3 as s3
|
||||
|
||||
from stacks.database import DatabaseConstruct
|
||||
from stacks.messaging import MessagingConstruct
|
||||
from stacks.agent_runtime import AgentRuntimeConstruct
|
||||
from stacks.lambdas import LambdasConstruct
|
||||
from stacks.api import ApiConstruct
|
||||
from stacks.frontend import FrontendConstruct
|
||||
|
||||
# Get the directory where this CDK app file is located
|
||||
CDK_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(CDK_APP_DIR)
|
||||
|
||||
|
||||
class BedrockAgentStack(Stack):
|
||||
"""
|
||||
Main stack for the Bedrock Agent application.
|
||||
|
||||
This stack creates all the infrastructure needed for:
|
||||
- Agent deployment and management
|
||||
- Token usage tracking and aggregation
|
||||
- Frontend dashboard hosting
|
||||
- API Gateway for all operations
|
||||
"""
|
||||
|
||||
def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
account_id = self.account
|
||||
region = self.region
|
||||
|
||||
# ============================================================
|
||||
# Storage Layer
|
||||
# ============================================================
|
||||
|
||||
# S3 bucket for agent code
|
||||
# Note: Server access logging disabled to avoid deletion issues
|
||||
# For production, consider using CloudWatch Logs or S3 Inventory instead
|
||||
code_bucket = s3.Bucket(
|
||||
self,
|
||||
"AgentCodeBucket",
|
||||
bucket_name=f"bedrock-agentcore-code-{account_id}-{region}",
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
auto_delete_objects=True,
|
||||
enforce_ssl=True,
|
||||
)
|
||||
|
||||
# DynamoDB tables
|
||||
database = DatabaseConstruct(
|
||||
self, "Database", account_id=account_id, region=region
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Messaging Layer
|
||||
# ============================================================
|
||||
|
||||
messaging = MessagingConstruct(
|
||||
self, "Messaging", account_id=account_id, region=region
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Agent Runtime
|
||||
# ============================================================
|
||||
|
||||
agent_runtime = AgentRuntimeConstruct(
|
||||
self,
|
||||
"AgentRuntime",
|
||||
region=region,
|
||||
usage_queue=messaging.usage_queue,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Lambda Functions
|
||||
# ============================================================
|
||||
|
||||
lambdas = LambdasConstruct(
|
||||
self,
|
||||
"Lambdas",
|
||||
cdk_app_dir=CDK_APP_DIR,
|
||||
usage_table=database.usage_table,
|
||||
aggregation_table=database.aggregation_table,
|
||||
agent_config_table=database.agent_config_table,
|
||||
agent_details_table=database.agent_details_table,
|
||||
usage_queue=messaging.usage_queue,
|
||||
code_bucket=code_bucket,
|
||||
agent_role_arn=agent_runtime.agent_role.role_arn,
|
||||
)
|
||||
|
||||
# Add dependency: build_deploy_agent depends on agent_role
|
||||
lambdas.build_deploy_agent.node.add_dependency(agent_runtime.agent_role)
|
||||
|
||||
# ============================================================
|
||||
# API Gateway
|
||||
# ============================================================
|
||||
|
||||
api = ApiConstruct(
|
||||
self,
|
||||
"Api",
|
||||
async_deploy_lambda=lambdas.async_deploy,
|
||||
token_usage_lambda=lambdas.token_usage,
|
||||
invoke_agent_lambda=lambdas.invoke_agent,
|
||||
get_agent_lambda=lambdas.get_agent,
|
||||
list_agents_lambda=lambdas.list_agents,
|
||||
delete_agent_lambda=lambdas.delete_agent,
|
||||
update_config_lambda=lambdas.update_config,
|
||||
set_tenant_limit_lambda=lambdas.set_tenant_limit,
|
||||
infrastructure_costs_lambda=lambdas.infrastructure_costs,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Frontend Hosting
|
||||
# ============================================================
|
||||
|
||||
frontend = FrontendConstruct(
|
||||
self,
|
||||
"Frontend",
|
||||
account_id=account_id,
|
||||
region=region,
|
||||
project_root=PROJECT_ROOT,
|
||||
cdk_app_dir=CDK_APP_DIR,
|
||||
api=api.api,
|
||||
api_key=api.api_key,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Stack Outputs
|
||||
# ============================================================
|
||||
|
||||
self._create_outputs(
|
||||
code_bucket=code_bucket,
|
||||
database=database,
|
||||
messaging=messaging,
|
||||
lambdas=lambdas,
|
||||
api=api,
|
||||
frontend=frontend,
|
||||
)
|
||||
|
||||
def _create_outputs(
|
||||
self,
|
||||
code_bucket: s3.Bucket,
|
||||
database: DatabaseConstruct,
|
||||
messaging: MessagingConstruct,
|
||||
lambdas: LambdasConstruct,
|
||||
api: ApiConstruct,
|
||||
frontend: FrontendConstruct,
|
||||
) -> None:
|
||||
"""Create all CloudFormation outputs."""
|
||||
|
||||
outputs = {
|
||||
"QueueUrl": (messaging.usage_queue.queue_url, "SQS Queue URL"),
|
||||
"TableName": (database.usage_table.table_name, "DynamoDB Table Name"),
|
||||
"AggregationTableName": (
|
||||
database.aggregation_table.table_name,
|
||||
"DynamoDB Aggregation Table Name",
|
||||
),
|
||||
"LambdaArn": (
|
||||
lambdas.sqs_processor.function_arn,
|
||||
"SQS to DynamoDB Lambda Function ARN",
|
||||
),
|
||||
"StreamProcessorLambdaArn": (
|
||||
lambdas.stream_processor.function_arn,
|
||||
"DynamoDB Stream Processor Lambda ARN",
|
||||
),
|
||||
"BuildDeployAgentLambdaArn": (
|
||||
lambdas.build_deploy_agent.function_arn,
|
||||
"Build & Deploy Agent Lambda ARN",
|
||||
),
|
||||
"CodeBucket": (code_bucket.bucket_name, "S3 Bucket for Agent Code"),
|
||||
"ApiEndpoint": (api.api.url, "API Gateway Endpoint URL"),
|
||||
"ApiKeyId": (api.api_key.key_id, "API Key ID"),
|
||||
"DeployEndpoint": (
|
||||
f"{api.api.url}deploy?tenantId=<TENANT_ID>",
|
||||
"Deploy Agent Endpoint",
|
||||
),
|
||||
"FrontendUrl": (
|
||||
f"https://{frontend.distribution.distribution_domain_name}",
|
||||
"Frontend Dashboard URL",
|
||||
),
|
||||
"FrontendBucketName": (
|
||||
frontend.bucket.bucket_name,
|
||||
"S3 Bucket for Frontend",
|
||||
),
|
||||
}
|
||||
|
||||
for name, (value, description) in outputs.items():
|
||||
cdk.CfnOutput(self, name, value=value, description=description)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# App Entry Point
|
||||
# ============================================================
|
||||
|
||||
app = cdk.App()
|
||||
|
||||
stack = BedrockAgentStack(
|
||||
app,
|
||||
"BedrockAgentStack",
|
||||
env=cdk.Environment(
|
||||
account=os.environ.get("CDK_DEFAULT_ACCOUNT"),
|
||||
region=os.environ.get("CDK_DEFAULT_REGION", "us-west-2"),
|
||||
),
|
||||
)
|
||||
|
||||
# Add stack-level tags for cost allocation and organization
|
||||
Tags.of(stack).add("Project", "BedrockAgentCore")
|
||||
Tags.of(stack).add("Environment", "Production")
|
||||
Tags.of(stack).add("ManagedBy", "CDK")
|
||||
Tags.of(stack).add("CostCenter", "AI-Operations")
|
||||
|
||||
app.synth()
|
||||
@@ -0,0 +1,2 @@
|
||||
aws-cdk-lib>=2.170.0
|
||||
constructs>=10.0.0
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
|
||||
lambda_client = boto3.client("lambda")
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
try:
|
||||
# Get tenantId and config from query parameters or body
|
||||
query_params = event.get("queryStringParameters") or {}
|
||||
tenant_id = query_params.get("tenantId")
|
||||
config = {}
|
||||
template = {}
|
||||
tools = {}
|
||||
|
||||
raw_body = event.get("body")
|
||||
if raw_body:
|
||||
body = json.loads(raw_body) if isinstance(raw_body, str) else raw_body
|
||||
if not tenant_id:
|
||||
tenant_id = body.get("tenantId")
|
||||
config = body.get("config", {})
|
||||
template = body.get("template", {})
|
||||
tools = body.get("tools", {})
|
||||
|
||||
if not tenant_id:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps({"error": "tenantId is required"}),
|
||||
}
|
||||
|
||||
# Prepare payload with config, template, and tools
|
||||
payload = {
|
||||
"tenantId": tenant_id,
|
||||
"config": config,
|
||||
"template": template,
|
||||
"tools": tools,
|
||||
}
|
||||
|
||||
print(f"Deploying agent for tenant {tenant_id}")
|
||||
print(f"Config: {config}")
|
||||
print(f"Template: {template}")
|
||||
print(f"Tools: {tools}")
|
||||
|
||||
# Invoke the build-deploy lambda asynchronously
|
||||
lambda_client.invoke(
|
||||
FunctionName=os.environ["BUILD_DEPLOY_FUNCTION_NAME"],
|
||||
InvocationType="Event", # Async invocation
|
||||
Payload=json.dumps(payload),
|
||||
)
|
||||
|
||||
return {
|
||||
"statusCode": 202,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps(
|
||||
{
|
||||
"message": "Agent deployment started",
|
||||
"tenantId": tenant_id,
|
||||
"config": config,
|
||||
"template": template,
|
||||
"tools": tools,
|
||||
"status": "deploying",
|
||||
"note": "Deployment will take 1-2 minutes. Check token usage table for completion.",
|
||||
}
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
+919
@@ -0,0 +1,919 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
import base64
|
||||
|
||||
# Install required packages at runtime
|
||||
print("Installing required packages...")
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"boto3",
|
||||
"uv",
|
||||
"requests",
|
||||
"-t",
|
||||
"/tmp/packages", # nosec B108 - Lambda ephemeral storage
|
||||
]
|
||||
)
|
||||
sys.path.insert(0, "/tmp/packages") # nosec B108 - Lambda ephemeral storage
|
||||
|
||||
# Add uv to PATH and set cache directory
|
||||
os.environ["PATH"] = f"/tmp/packages/bin:{os.environ.get('PATH', '')}" # nosec B108
|
||||
os.environ["UV_CACHE_DIR"] = "/tmp/.uv_cache" # nosec B108 - Lambda ephemeral storage
|
||||
os.environ["UV_PYTHON_INSTALL_DIR"] = "/tmp/.uv_python" # nosec B108 - Lambda ephemeral storage
|
||||
os.environ["HOME"] = "/tmp" # nosec B108 - Lambda ephemeral storage
|
||||
|
||||
import boto3 # noqa: E402
|
||||
import requests # noqa: E402
|
||||
import time # noqa: E402
|
||||
from datetime import datetime # noqa: E402
|
||||
|
||||
# Get configuration from environment variables (all required)
|
||||
REGION = region = os.environ["AWS_REGION"]
|
||||
AGENT_NAME = os.environ["AGENT_NAME"]
|
||||
BUCKET_NAME = os.environ["BUCKET_NAME"]
|
||||
QUEUE_URL = os.environ["QUEUE_URL"]
|
||||
ROLE_ARN = os.environ["ROLE_ARN"]
|
||||
AGENT_CONFIG_TABLE_NAME = os.environ["AGENT_CONFIG_TABLE_NAME"]
|
||||
AGENT_DETAILS_TABLE_NAME = os.environ["AGENT_DETAILS_TABLE_NAME"]
|
||||
|
||||
if not AGENT_CONFIG_TABLE_NAME or not AGENT_DETAILS_TABLE_NAME:
|
||||
raise ValueError(
|
||||
"AGENT_CONFIG_TABLE_NAME and AGENT_DETAILS_TABLE_NAME environment variables are required. "
|
||||
"This Lambda must be configured with the correct DynamoDB table names."
|
||||
)
|
||||
|
||||
s3_client = boto3.client("s3", region_name=REGION)
|
||||
bedrock_client = boto3.client("bedrock-agentcore-control", region_name=REGION)
|
||||
bedrock_runtime_client = boto3.client("bedrock-agentcore", region_name=REGION)
|
||||
ce_client = boto3.client("ce")
|
||||
|
||||
|
||||
def check_and_activate_cost_allocation_tags():
|
||||
"""
|
||||
Check if tenantId tag is activated for cost allocation and activate it if needed.
|
||||
Also activates other project tags for better cost tracking.
|
||||
"""
|
||||
tags_to_activate = ["tenantId", "Project", "Environment", "CostCenter"]
|
||||
|
||||
try:
|
||||
print("Checking cost allocation tag status...")
|
||||
|
||||
response = ce_client.list_cost_allocation_tags(Status="Active", MaxResults=100)
|
||||
|
||||
active_tags = {tag["TagKey"] for tag in response.get("CostAllocationTags", [])}
|
||||
print(f"Currently active cost allocation tags: {active_tags}")
|
||||
|
||||
# Check which tags need to be activated
|
||||
tags_to_enable = [tag for tag in tags_to_activate if tag not in active_tags]
|
||||
|
||||
if tags_to_enable:
|
||||
print(f"Activating cost allocation tags: {tags_to_enable}")
|
||||
|
||||
# Activate each tag
|
||||
for tag_key in tags_to_enable:
|
||||
try:
|
||||
ce_client.update_cost_allocation_tags_status(
|
||||
CostAllocationTagsStatus=[
|
||||
{"TagKey": tag_key, "Status": "Active"}
|
||||
]
|
||||
)
|
||||
print(f"✓ Activated cost allocation tag: {tag_key}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Warning: Could not activate tag '{tag_key}': {str(e)}")
|
||||
|
||||
print(
|
||||
"✓ Cost allocation tags activated. They will appear in Cost Explorer within 24 hours."
|
||||
)
|
||||
else:
|
||||
print("✓ All required cost allocation tags are already active")
|
||||
|
||||
except Exception as e:
|
||||
# Don't fail the deployment if cost allocation tag activation fails
|
||||
print(f"⚠ Warning: Could not check/activate cost allocation tags: {str(e)}")
|
||||
print(" This is not critical - deployment will continue")
|
||||
print(
|
||||
" You can manually activate tags in AWS Billing Console > Cost Allocation Tags"
|
||||
)
|
||||
|
||||
|
||||
def fetch_from_github(repo, file_path, branch="main", token=None):
|
||||
"""
|
||||
Fetch a file from GitHub repository using GitHub API
|
||||
|
||||
Args:
|
||||
repo: Repository in format 'owner/repo'
|
||||
file_path: Path to file in repo (e.g., 'templates/main.py')
|
||||
branch: Branch name (default: 'main')
|
||||
token: GitHub personal access token (optional, for private repos)
|
||||
|
||||
Returns:
|
||||
File content as string
|
||||
"""
|
||||
print(f"Fetching from GitHub: {repo}/{file_path} (branch: {branch})")
|
||||
|
||||
url = f"https://api.github.com/repos/{repo}/contents/{file_path}"
|
||||
params = {"ref": branch}
|
||||
headers = {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "AWS-Lambda-Agent-Builder",
|
||||
}
|
||||
|
||||
if token:
|
||||
headers["Authorization"] = f"token {token}"
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# GitHub API returns content as base64 encoded
|
||||
if "content" in data:
|
||||
content = base64.b64decode(data["content"]).decode("utf-8")
|
||||
print(f"Successfully fetched file ({len(content)} bytes)")
|
||||
return content
|
||||
else:
|
||||
raise Exception(f"No content found in response: {data}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error fetching from GitHub: {str(e)}")
|
||||
if hasattr(e, "response") and e.response is not None:
|
||||
print(f"Response status: {e.response.status_code}")
|
||||
print(f"Response body: {e.response.text}")
|
||||
raise Exception(f"Failed to fetch from GitHub: {str(e)}")
|
||||
|
||||
|
||||
def fetch_tool_catalog(repo, branch="main", token=None):
|
||||
"""
|
||||
Fetch the tool catalog from GitHub repository
|
||||
|
||||
Args:
|
||||
repo: Repository in format 'owner/repo'
|
||||
branch: Branch name (default: 'main')
|
||||
token: GitHub personal access token (optional)
|
||||
|
||||
Returns:
|
||||
Tool catalog as dictionary
|
||||
"""
|
||||
try:
|
||||
catalog_content = fetch_from_github(repo, "catalog.json", branch, token)
|
||||
catalog = json.loads(catalog_content)
|
||||
print(f"Loaded tool catalog with {len(catalog.get('tools', []))} tools")
|
||||
return catalog
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch tool catalog: {str(e)}")
|
||||
return {"tools": []}
|
||||
|
||||
|
||||
def fetch_tool_code(repo, tool_path, branch="main", token=None):
|
||||
"""
|
||||
Fetch tool code from GitHub repository
|
||||
|
||||
Args:
|
||||
repo: Repository in format 'owner/repo'
|
||||
tool_path: Path to tool file (e.g., 'tools/web-search/tool.py')
|
||||
branch: Branch name
|
||||
token: GitHub personal access token (optional)
|
||||
|
||||
Returns:
|
||||
Tool code as string
|
||||
"""
|
||||
try:
|
||||
tool_code = fetch_from_github(repo, tool_path, branch, token)
|
||||
print(f"Fetched tool code from {tool_path}")
|
||||
return tool_code
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch tool code from {tool_path}: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def compose_agent_with_tools(
|
||||
base_template, selected_tools, tools_repo, branch="main", token=None
|
||||
):
|
||||
"""
|
||||
Compose agent code by injecting selected tools into base template
|
||||
|
||||
Args:
|
||||
base_template: Base agent template code
|
||||
selected_tools: List of selected tool configurations
|
||||
tools_repo: GitHub repository containing tools
|
||||
branch: Branch name
|
||||
token: GitHub token
|
||||
|
||||
Returns:
|
||||
Composed agent code with tools injected
|
||||
"""
|
||||
print(f"Composing agent with {len(selected_tools)} tools")
|
||||
|
||||
# Fetch tool catalog to get tool paths
|
||||
catalog = fetch_tool_catalog(tools_repo, branch, token)
|
||||
catalog_tools = {tool["id"]: tool for tool in catalog.get("tools", [])}
|
||||
|
||||
# Collect tool codes and dependencies
|
||||
tool_codes = []
|
||||
all_dependencies = set()
|
||||
tool_names = []
|
||||
|
||||
for selected_tool in selected_tools:
|
||||
tool_id = selected_tool.get("id")
|
||||
tool_config = selected_tool.get("config", {})
|
||||
|
||||
if tool_id not in catalog_tools:
|
||||
print(f"Warning: Tool '{tool_id}' not found in catalog, skipping")
|
||||
continue
|
||||
|
||||
tool_info = catalog_tools[tool_id]
|
||||
tool_path = tool_info.get("path")
|
||||
|
||||
if not tool_path:
|
||||
print(f"Warning: No path specified for tool '{tool_id}', skipping")
|
||||
continue
|
||||
|
||||
# Fetch tool code
|
||||
tool_code = fetch_tool_code(tools_repo, tool_path, branch, token)
|
||||
|
||||
if tool_code:
|
||||
# Inject tool configuration if needed
|
||||
if tool_config:
|
||||
config_json = json.dumps(tool_config)
|
||||
tool_code = f"# Tool configuration: {config_json}\n{tool_code}"
|
||||
|
||||
tool_codes.append(tool_code)
|
||||
tool_names.append(tool_id)
|
||||
|
||||
# Collect dependencies
|
||||
config_path = tool_info.get("configPath")
|
||||
if config_path:
|
||||
try:
|
||||
config_content = fetch_from_github(
|
||||
tools_repo, config_path, branch, token
|
||||
)
|
||||
tool_metadata = json.loads(config_content)
|
||||
dependencies = tool_metadata.get("dependencies", [])
|
||||
all_dependencies.update(dependencies)
|
||||
except Exception as e:
|
||||
print(f"Could not fetch tool config for {tool_id}: {str(e)}")
|
||||
|
||||
# Find the injection point in base template
|
||||
# Look for a marker like "# TOOLS_INJECTION_POINT" or inject before agent initialization
|
||||
tools_section = "\n\n# ========== INJECTED TOOLS ==========\n"
|
||||
tools_section += f"# Tools: {', '.join(tool_names)}\n\n"
|
||||
tools_section += "\n\n".join(tool_codes)
|
||||
tools_section += "\n# ========== END INJECTED TOOLS ==========\n\n"
|
||||
|
||||
# Inject tools before agent initialization
|
||||
if "agent = Agent(" in base_template:
|
||||
# Find agent initialization and inject tools before it
|
||||
parts = base_template.split("agent = Agent(")
|
||||
composed_code = parts[0] + tools_section + "agent = Agent(" + parts[1]
|
||||
|
||||
# Update agent initialization to include tools
|
||||
# Find the tools parameter or add it
|
||||
if "tools=[" in composed_code:
|
||||
print("Agent already has tools parameter, appending to it")
|
||||
else:
|
||||
# Add tools parameter to Agent initialization
|
||||
# Extract function names from tool codes
|
||||
tool_function_names = []
|
||||
for tool_code in tool_codes:
|
||||
# Look for @tool decorator followed by def function_name
|
||||
import re
|
||||
|
||||
matches = re.findall(r"@tool\s+def\s+(\w+)\s*\(", tool_code)
|
||||
tool_function_names.extend(matches)
|
||||
|
||||
if tool_function_names:
|
||||
# Find the Agent initialization line - look for the closing parenthesis on the same or next line
|
||||
agent_init_start = composed_code.find("agent = Agent(")
|
||||
# Find the end of the line containing Agent(
|
||||
line_end = composed_code.find("\n", agent_init_start)
|
||||
if line_end == -1:
|
||||
line_end = len(composed_code)
|
||||
|
||||
# Check if the closing ) is on the same line
|
||||
agent_line = composed_code[agent_init_start:line_end]
|
||||
if ")" in agent_line:
|
||||
# Single line Agent initialization
|
||||
close_paren = composed_code.find(")", agent_init_start)
|
||||
tools_param = f", tools=[{', '.join(tool_function_names)}]"
|
||||
composed_code = (
|
||||
composed_code[:close_paren]
|
||||
+ tools_param
|
||||
+ composed_code[close_paren:]
|
||||
)
|
||||
print(
|
||||
f"Added tools parameter with functions: {tool_function_names}"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Warning: Multi-line Agent initialization detected, tools may not be added correctly"
|
||||
)
|
||||
else:
|
||||
# No agent initialization found, append tools at the end
|
||||
composed_code = base_template + tools_section
|
||||
print("No agent initialization found, appended tools at the end")
|
||||
|
||||
print(f"Agent composition complete. Added {len(tool_codes)} tools")
|
||||
print(f"Additional dependencies needed: {list(all_dependencies)}")
|
||||
|
||||
return composed_code, list(all_dependencies)
|
||||
|
||||
|
||||
# Default template (fallback if GitHub fetch fails)
|
||||
MAIN_PY_TEMPLATE = """from bedrock_agentcore import BedrockAgentCoreApp
|
||||
from strands import Agent
|
||||
import boto3
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import json
|
||||
import os
|
||||
|
||||
region=os.environ['AWS_REGION']
|
||||
|
||||
app = BedrockAgentCoreApp(debug=True)
|
||||
|
||||
# Deployment-time configuration (injected during build)
|
||||
QUEUE_URL = 'QUEUE_URL_VALUE'
|
||||
TENANT_ID = 'TENANT_ID_VALUE'
|
||||
AGENT_RUNTIME_ID = 'AGENT_RUNTIME_ID_VALUE'
|
||||
MODEL_ID = 'MODEL_ID_VALUE'
|
||||
SYSTEM_PROMPT = '''SYSTEM_PROMPT_VALUE'''
|
||||
AGENT_CONFIG_TABLE_NAME = 'AGENT_CONFIG_TABLE_NAME_VALUE'
|
||||
|
||||
# Initialize clients
|
||||
sqs = boto3.client('sqs', region_name=region)
|
||||
dynamodb = boto3.resource('dynamodb', region_name=region)
|
||||
config_table = dynamodb.Table(AGENT_CONFIG_TABLE_NAME)
|
||||
|
||||
# Initialize agent with deployment-time config
|
||||
agent = Agent(
|
||||
model=MODEL_ID,
|
||||
system_prompt=SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
def get_runtime_config():
|
||||
\"\"\"Fetch runtime configuration from DynamoDB\"\"\"
|
||||
try:
|
||||
response = config_table.get_item(
|
||||
Key={
|
||||
'tenantId': TENANT_ID,
|
||||
'agentRuntimeId': AGENT_RUNTIME_ID
|
||||
}
|
||||
)
|
||||
if 'Item' in response:
|
||||
config = response['Item']
|
||||
app.logger.info(f"Loaded runtime config: {config}")
|
||||
return config
|
||||
else:
|
||||
app.logger.info("No runtime config found, using defaults")
|
||||
return {}
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to load runtime config: {str(e)}")
|
||||
return {}
|
||||
|
||||
def send_usage_to_sqs(input_tokens, output_tokens, total_tokens, user_message, response_message, tenant_id):
|
||||
try:
|
||||
message_body = {
|
||||
'id': str(uuid.uuid4()),
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'tenant_id': tenant_id,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'user_message': user_message,
|
||||
'response_message': response_message
|
||||
}
|
||||
|
||||
response = sqs.send_message(
|
||||
QueueUrl=QUEUE_URL,
|
||||
MessageBody=json.dumps(message_body)
|
||||
)
|
||||
|
||||
app.logger.info(f"Sent usage to SQS for tenant {tenant_id}: {input_tokens} input, {output_tokens} output tokens")
|
||||
app.logger.info(f"SQS MessageId: {response['MessageId']}")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to send usage to SQS: {str(e)}")
|
||||
|
||||
|
||||
@app.entrypoint
|
||||
def invoke(payload):
|
||||
\"\"\"Your AI agent function with runtime configuration support\"\"\"
|
||||
# Load runtime config (cached after first call)
|
||||
runtime_config = get_runtime_config()
|
||||
|
||||
# Accept both 'message' and 'prompt' for compatibility
|
||||
user_message = payload.get("message") or payload.get("prompt", "Hello! How can I help you today?")
|
||||
app.logger.info(f"User message from tenant {TENANT_ID}: {user_message}")
|
||||
app.logger.info(f"Full payload received: {payload}")
|
||||
|
||||
# Apply runtime config overrides if present
|
||||
if runtime_config:
|
||||
# Check for feature flags
|
||||
if runtime_config.get('enabled', True) == False:
|
||||
return {"result": "Agent is currently disabled for maintenance"}
|
||||
|
||||
result = agent(user_message)
|
||||
|
||||
if hasattr(result, 'metrics'):
|
||||
input_tokens = result.metrics.accumulated_usage.get('inputTokens', 0)
|
||||
output_tokens = result.metrics.accumulated_usage.get('outputTokens', 0)
|
||||
total_tokens = result.metrics.accumulated_usage.get('totalTokens', 0)
|
||||
|
||||
send_usage_to_sqs(input_tokens, output_tokens, total_tokens, user_message, result.message, TENANT_ID)
|
||||
|
||||
return {"result": result.message}
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
"""
|
||||
|
||||
|
||||
def build_agent_project(
|
||||
tenant_id="default",
|
||||
config=None,
|
||||
agent_runtime_id="pending",
|
||||
template_config=None,
|
||||
tools_config=None,
|
||||
):
|
||||
print(f"Building agent project for tenant: {tenant_id}")
|
||||
print(f"Configuration: {config}")
|
||||
print(f"Template config: {template_config}")
|
||||
print(f"Tools config: {tools_config}")
|
||||
|
||||
# Set default configuration values
|
||||
if config is None:
|
||||
config = {}
|
||||
|
||||
model_id = config.get("modelId", "global.anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
system_prompt = config.get("systemPrompt", "You are a helpful AI assistant.")
|
||||
|
||||
project_dir = "/tmp/agentcore_runtime_direct_deploy" # nosec B108 - Lambda ephemeral storage
|
||||
package_file = "deployment.zip"
|
||||
additional_dependencies = []
|
||||
|
||||
# Clean up if exists
|
||||
if os.path.exists(project_dir):
|
||||
shutil.rmtree(project_dir)
|
||||
|
||||
os.makedirs(project_dir)
|
||||
|
||||
# Fetch template from GitHub if configured
|
||||
main_content = None
|
||||
if template_config and template_config.get("source") == "github":
|
||||
try:
|
||||
repo = template_config.get("repo")
|
||||
file_path = template_config.get("path", "main.py")
|
||||
branch = template_config.get("branch", "main")
|
||||
token = template_config.get("token")
|
||||
|
||||
if repo:
|
||||
print(f"Fetching template from GitHub repository: {repo}")
|
||||
main_content = fetch_from_github(repo, file_path, branch, token)
|
||||
print("Successfully fetched template from GitHub")
|
||||
|
||||
# Compose agent with tools if tools are selected
|
||||
if tools_config and tools_config.get("selected"):
|
||||
selected_tools = tools_config.get("selected", [])
|
||||
tools_repo = tools_config.get(
|
||||
"repo", repo
|
||||
) # Use same repo or different one
|
||||
tools_branch = tools_config.get("branch", branch)
|
||||
|
||||
print(f"Composing agent with {len(selected_tools)} tools")
|
||||
main_content, additional_dependencies = compose_agent_with_tools(
|
||||
main_content, selected_tools, tools_repo, tools_branch, token
|
||||
)
|
||||
print(
|
||||
f"Agent composition complete with tools: {[t.get('id') for t in selected_tools]}"
|
||||
)
|
||||
else:
|
||||
print("No repository specified, using default template")
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch template from GitHub: {str(e)}")
|
||||
print("Falling back to default template")
|
||||
main_content = None
|
||||
|
||||
# Use default template if GitHub fetch failed or not configured
|
||||
if main_content is None:
|
||||
print("Using default template")
|
||||
main_content = MAIN_PY_TEMPLATE
|
||||
|
||||
# Replace placeholders with configuration values
|
||||
main_content = main_content.replace("QUEUE_URL_VALUE", QUEUE_URL)
|
||||
main_content = main_content.replace("TENANT_ID_VALUE", tenant_id)
|
||||
main_content = main_content.replace("AGENT_RUNTIME_ID_VALUE", agent_runtime_id)
|
||||
main_content = main_content.replace("MODEL_ID_VALUE", model_id)
|
||||
main_content = main_content.replace(
|
||||
"SYSTEM_PROMPT_VALUE", system_prompt.replace("'", "\\'")
|
||||
)
|
||||
main_content = main_content.replace("AGENT_CONFIG_TABLE_NAME_VALUE", AGENT_CONFIG_TABLE_NAME)
|
||||
|
||||
main_path = os.path.join(project_dir, "main.py")
|
||||
with open(main_path, "w") as f:
|
||||
f.write(main_content)
|
||||
|
||||
print(f"Created main.py for tenant {tenant_id} with config:")
|
||||
print(f" Model: {model_id}")
|
||||
print(f" System Prompt: {system_prompt[:50]}...")
|
||||
if template_config and template_config.get("source") == "github":
|
||||
print(f" Template Source: GitHub ({template_config.get('repo')})")
|
||||
|
||||
# Initialize UV project
|
||||
print("Initializing UV project...")
|
||||
subprocess.run(
|
||||
["uv", "init", project_dir, "--python", "3.13"], check=True, cwd="/tmp" # nosec B108
|
||||
)
|
||||
|
||||
# Add dependencies (base + additional from tools)
|
||||
print("Adding dependencies...")
|
||||
base_dependencies = ["bedrock-agentcore", "strands-agents", "boto3"]
|
||||
all_dependencies = base_dependencies + additional_dependencies
|
||||
|
||||
print(f"Installing dependencies: {all_dependencies}")
|
||||
subprocess.run(["uv", "add"] + all_dependencies, cwd=project_dir, check=True)
|
||||
|
||||
# Install for Lambda
|
||||
print("Installing dependencies for Lambda...")
|
||||
deployment_dir = os.path.join(project_dir, "deployment_package")
|
||||
subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python-platform",
|
||||
"aarch64-manylinux2014",
|
||||
"--python-version",
|
||||
"3.13",
|
||||
"--target=deployment_package",
|
||||
"--only-binary=:all:",
|
||||
"-r",
|
||||
"pyproject.toml",
|
||||
],
|
||||
cwd=project_dir,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Create deployment package
|
||||
print("Creating deployment package...")
|
||||
package_path = os.path.join(project_dir, package_file)
|
||||
|
||||
with zipfile.ZipFile(package_path, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
# Add all files from deployment_package
|
||||
for root, dirs, files in os.walk(deployment_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, deployment_dir)
|
||||
zip_file.write(file_path, arcname)
|
||||
|
||||
# Add main.py
|
||||
zip_file.write(main_path, "main.py")
|
||||
|
||||
print(f"Deployment package created: {package_path}")
|
||||
return package_path
|
||||
|
||||
|
||||
def delete_existing_agent(agent_name):
|
||||
try:
|
||||
list_response = bedrock_client.list_agent_runtimes()
|
||||
for runtime in list_response.get("agentRuntimes", []):
|
||||
if runtime["agentRuntimeName"] == agent_name:
|
||||
agent_runtime_id = runtime["agentRuntimeId"]
|
||||
print(f"Deleting existing agent: {agent_runtime_id}")
|
||||
bedrock_client.delete_agent_runtime(agentRuntimeId=agent_runtime_id)
|
||||
wait_for_deletion(agent_runtime_id)
|
||||
print(f"Existing agent deleted: {agent_runtime_id}")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error checking/deleting existing agent: {str(e)}")
|
||||
|
||||
|
||||
def wait_for_agent_ready(agent_runtime_id, max_attempts=60):
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
response = bedrock_client.get_agent_runtime(agentRuntimeId=agent_runtime_id)
|
||||
status = response["status"]
|
||||
print(f"Attempt {attempt + 1}/{max_attempts}: Status = {status}")
|
||||
|
||||
if status == "READY":
|
||||
print("Agent is READY!")
|
||||
return
|
||||
elif status in ["FAILED", "CREATE_FAILED"]:
|
||||
raise Exception(f"Agent entered failed state: {status}")
|
||||
|
||||
time.sleep(5)
|
||||
except Exception as e:
|
||||
if "ResourceNotFoundException" in str(e):
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise
|
||||
|
||||
raise Exception("Timeout waiting for agent to be ready")
|
||||
|
||||
|
||||
def wait_for_deletion(agent_runtime_id, max_attempts=30):
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
bedrock_client.get_agent_runtime(agentRuntimeId=agent_runtime_id)
|
||||
print(f"Waiting for deletion... attempt {attempt + 1}")
|
||||
time.sleep(5)
|
||||
except Exception:
|
||||
print("Agent deleted successfully")
|
||||
return
|
||||
|
||||
|
||||
def test_agent_endpoint(agent_runtime_id, agent_runtime_arn, max_retries=3):
|
||||
"""Test the agent endpoint with a simple invocation"""
|
||||
|
||||
# Wait a bit for the agent runtime to fully initialize
|
||||
print("Waiting 10 seconds for agent runtime to fully initialize...")
|
||||
time.sleep(10)
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
print(
|
||||
f"Testing agent endpoint (attempt {attempt + 1}/{max_retries}): {agent_runtime_id}"
|
||||
)
|
||||
print(f"Agent ARN: {agent_runtime_arn}")
|
||||
|
||||
# Test payload - adjust based on your agent's expected input
|
||||
test_payload = {"message": "Hello, this is a test message", "test": True}
|
||||
|
||||
print(f"Sending test payload: {json.dumps(test_payload)}")
|
||||
|
||||
# Invoke the agent with correct parameters
|
||||
response = bedrock_runtime_client.invoke_agent_runtime(
|
||||
agentRuntimeArn=agent_runtime_arn,
|
||||
payload=json.dumps(test_payload).encode("utf-8"),
|
||||
contentType="application/json",
|
||||
)
|
||||
|
||||
# Parse the response
|
||||
if "body" in response:
|
||||
body = response["body"].read()
|
||||
if isinstance(body, bytes):
|
||||
body = body.decode("utf-8")
|
||||
|
||||
print("✓ Agent responded successfully")
|
||||
print(f"Response: {body}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"response": body,
|
||||
"message": "Agent endpoint is working correctly",
|
||||
"attempts": attempt + 1,
|
||||
}
|
||||
else:
|
||||
print("✓ Agent invoked successfully")
|
||||
print(f"Response metadata: {response.get('ResponseMetadata', {})}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"response": str(response),
|
||||
"message": "Agent endpoint invoked successfully",
|
||||
"attempts": attempt + 1,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"✗ Agent endpoint test failed (attempt {attempt + 1}/{max_retries}): {str(e)}"
|
||||
print(error_msg)
|
||||
|
||||
# Check if it's a runtime initialization timeout
|
||||
if "Runtime initialization time exceeded" in str(e):
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 30
|
||||
print(
|
||||
f"Runtime still initializing. Waiting {wait_time} seconds before retry..."
|
||||
)
|
||||
time.sleep(wait_time)
|
||||
continue
|
||||
elif attempt < max_retries - 1:
|
||||
print("Waiting 10 seconds before retry...")
|
||||
time.sleep(10)
|
||||
continue
|
||||
|
||||
# Last attempt failed
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Agent endpoint test failed after retries - agent may still be functional",
|
||||
"attempts": attempt + 1,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Max retries exceeded",
|
||||
"message": "Agent endpoint test failed after all retries",
|
||||
"attempts": max_retries,
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
package_file = "deployment.zip"
|
||||
|
||||
try:
|
||||
# Check and activate cost allocation tags for billing
|
||||
print("=" * 60)
|
||||
print("Checking Cost Allocation Tags")
|
||||
print("=" * 60)
|
||||
check_and_activate_cost_allocation_tags()
|
||||
|
||||
# Extract tenantId, config, and template from event
|
||||
tenant_id = event.get("tenantId", "default")
|
||||
config = event.get("config", {})
|
||||
template_config = event.get("template", {})
|
||||
print(f"Processing request for tenant: {tenant_id}")
|
||||
print(f"Configuration: {json.dumps(config, indent=2)}")
|
||||
print(f"Template configuration: {json.dumps(template_config, indent=2)}")
|
||||
|
||||
# Generate unique agent name based on tenant ID and timestamp
|
||||
# Format: agentcore_{tenantId}_{timestamp}
|
||||
safe_tenant_id = tenant_id.replace("-", "_").replace(".", "_")[
|
||||
:20
|
||||
] # Sanitize and limit length
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
agent_name = f"agentcore_{safe_tenant_id}_{timestamp}"
|
||||
print(f"Generated unique agent name: {agent_name}")
|
||||
|
||||
# Extract tools configuration
|
||||
tools_config = event.get("tools", {})
|
||||
|
||||
# Step 1: Build the agent project with config, template, and tools
|
||||
print("=" * 60)
|
||||
print("STEP 1: Building agent project")
|
||||
print("=" * 60)
|
||||
# Note: agent_runtime_id will be updated after creation
|
||||
package_path = build_agent_project(
|
||||
tenant_id, config, "pending", template_config, tools_config
|
||||
)
|
||||
|
||||
# Step 2: Upload to S3
|
||||
print("=" * 60)
|
||||
print("STEP 2: Uploading to S3")
|
||||
print("=" * 60)
|
||||
s3_key = f"{agent_name}/{package_file}"
|
||||
print(f"Uploading {package_path} to s3://{BUCKET_NAME}/{s3_key}")
|
||||
s3_client.upload_file(package_path, BUCKET_NAME, s3_key)
|
||||
print("Upload complete!")
|
||||
|
||||
# Step 3: Delete existing agent if it exists (optional - skip for unique names)
|
||||
# Since we're using unique names per deployment, we don't need to delete
|
||||
# But we'll keep the function for backwards compatibility
|
||||
print("=" * 60)
|
||||
print("STEP 3: Checking for existing agents (skipped - using unique names)")
|
||||
print("=" * 60)
|
||||
# delete_existing_agent(agent_name) # Commented out since each deployment gets unique name
|
||||
|
||||
# Step 4: Create new agent
|
||||
print("=" * 60)
|
||||
print("STEP 4: Creating agent runtime")
|
||||
print("=" * 60)
|
||||
print(f"Creating agent runtime: {agent_name}")
|
||||
print(f"Using S3 package: s3://{BUCKET_NAME}/{s3_key}")
|
||||
|
||||
response = bedrock_client.create_agent_runtime(
|
||||
agentRuntimeName=agent_name,
|
||||
agentRuntimeArtifact={
|
||||
"codeConfiguration": {
|
||||
"code": {"s3": {"bucket": BUCKET_NAME, "prefix": s3_key}},
|
||||
"runtime": "PYTHON_3_13",
|
||||
"entryPoint": ["main.py"],
|
||||
}
|
||||
},
|
||||
networkConfiguration={"networkMode": "PUBLIC"},
|
||||
roleArn=ROLE_ARN,
|
||||
tags={"tenantId": tenant_id, "environment": "production"},
|
||||
)
|
||||
|
||||
agent_runtime_id = response["agentRuntimeId"]
|
||||
agent_runtime_arn = response["agentRuntimeArn"]
|
||||
|
||||
print(f"Agent created: {agent_runtime_id}")
|
||||
print(f"Agent ARN: {agent_runtime_arn}")
|
||||
print(f"Tagged with tenantId: {tenant_id}")
|
||||
|
||||
print(
|
||||
f"Note: Agent built with placeholder runtime ID. Runtime config will use tenantId: {tenant_id}"
|
||||
)
|
||||
|
||||
# Step 5: Wait for agent to be ready
|
||||
print("=" * 60)
|
||||
print("STEP 5: Waiting for agent to be ready")
|
||||
print("=" * 60)
|
||||
wait_for_agent_ready(agent_runtime_id)
|
||||
|
||||
# Step 6: Test the agent endpoint
|
||||
print("=" * 60)
|
||||
print("STEP 6: Testing agent endpoint")
|
||||
print("=" * 60)
|
||||
test_result = test_agent_endpoint(agent_runtime_id, agent_runtime_arn)
|
||||
|
||||
# Construct the agent endpoint URL
|
||||
agent_endpoint_url = f"https://bedrock-agentcore-runtime.{REGION}.amazonaws.com/agents/{agent_runtime_id}/invoke"
|
||||
|
||||
# Store agent details in DynamoDB for later retrieval
|
||||
try:
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
agent_table = dynamodb.Table(AGENT_DETAILS_TABLE_NAME)
|
||||
config_table = dynamodb.Table(AGENT_CONFIG_TABLE_NAME)
|
||||
|
||||
# Convert config to DynamoDB-compatible format (Float -> Decimal)
|
||||
from decimal import Decimal
|
||||
|
||||
def convert_floats(obj):
|
||||
if isinstance(obj, float):
|
||||
return Decimal(str(obj))
|
||||
elif isinstance(obj, dict):
|
||||
return {k: convert_floats(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_floats(item) for item in obj]
|
||||
return obj
|
||||
|
||||
deployment_config = convert_floats(config)
|
||||
|
||||
# Store agent details
|
||||
agent_table.put_item(
|
||||
Item={
|
||||
"tenantId": tenant_id,
|
||||
"agentName": agent_name,
|
||||
"agentRuntimeId": agent_runtime_id,
|
||||
"agentRuntimeArn": agent_runtime_arn,
|
||||
"agentEndpointUrl": agent_endpoint_url,
|
||||
"status": "READY",
|
||||
"deployedAt": datetime.now().isoformat(),
|
||||
"s3_uri": f"s3://{BUCKET_NAME}/{s3_key}",
|
||||
"deploymentConfig": deployment_config, # Store deployment-time config
|
||||
"templateSource": template_config.get("source", "default")
|
||||
if template_config
|
||||
else "default",
|
||||
"templateRepo": template_config.get("repo", "")
|
||||
if template_config
|
||||
else "",
|
||||
"templatePath": template_config.get("path", "")
|
||||
if template_config
|
||||
else "",
|
||||
"tools": tools_config if tools_config else {},
|
||||
}
|
||||
)
|
||||
print(f"✓ Stored agent details in DynamoDB for tenant: {tenant_id}")
|
||||
|
||||
# Store initial runtime configuration (can be updated later without redeployment)
|
||||
runtime_config = {
|
||||
"tenantId": tenant_id,
|
||||
"agentRuntimeId": agent_runtime_id,
|
||||
"enabled": True, # Feature flag
|
||||
"rateLimit": int(config.get("rateLimit", 100)), # Requests per minute
|
||||
"customSettings": config.get("customSettings", {}),
|
||||
"updatedAt": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
config_table.put_item(Item=runtime_config)
|
||||
print(f"✓ Stored runtime configuration in DynamoDB for tenant: {tenant_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to store agent details in DynamoDB: {str(e)}")
|
||||
# Don't fail the deployment if DynamoDB write fails
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"message": "Agent built, deployed, and tested successfully",
|
||||
"tenantId": tenant_id,
|
||||
"bucket": BUCKET_NAME,
|
||||
"s3_key": s3_key,
|
||||
"s3_uri": f"s3://{BUCKET_NAME}/{s3_key}",
|
||||
"agentRuntimeId": agent_runtime_id,
|
||||
"agentRuntimeArn": agent_runtime_arn,
|
||||
"agentEndpointUrl": agent_endpoint_url,
|
||||
"test_result": test_result,
|
||||
"templateSource": template_config.get("source", "default")
|
||||
if template_config
|
||||
else "default",
|
||||
"templateRepo": template_config.get("repo", "")
|
||||
if template_config
|
||||
else "",
|
||||
"tools": [t.get("id") for t in tools_config.get("selected", [])]
|
||||
if tools_config
|
||||
else [],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return {"statusCode": 500, "body": json.dumps({"error": str(e)})}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import json
|
||||
import boto3
|
||||
import os
|
||||
import time
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
s3 = boto3.client("s3")
|
||||
cloudfront = boto3.client("cloudfront")
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""
|
||||
Custom Resource Lambda to inject runtime config into frontend config.js
|
||||
This runs AFTER the stack is deployed and API is created
|
||||
|
||||
SECURITY WARNING: This implementation exposes the API Gateway API key in client-side
|
||||
JavaScript, making it publicly accessible to anyone who views the page source.
|
||||
This defeats the purpose of API key authentication.
|
||||
|
||||
For production use, consider one of these alternatives:
|
||||
1. Use Amazon Cognito for user authentication with JWT tokens
|
||||
2. Use IAM authentication with AWS Signature Version 4
|
||||
3. Remove API key requirement and use other controls (VPC, IP allowlisting)
|
||||
4. Implement a backend-for-frontend (BFF) pattern with server-side API calls
|
||||
|
||||
This current implementation is suitable ONLY for:
|
||||
- Internal demos where the dashboard URL is not publicly accessible
|
||||
- Development/testing environments
|
||||
- Proof-of-concept deployments
|
||||
"""
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
request_type = event["RequestType"]
|
||||
response_status = "SUCCESS"
|
||||
response_data = {}
|
||||
|
||||
try:
|
||||
if request_type in ["Create", "Update"]:
|
||||
# Get values from environment variables (set by CDK)
|
||||
api_endpoint = os.environ["API_ENDPOINT"]
|
||||
api_key_id = os.environ["API_KEY_ID"]
|
||||
region = os.environ["AWS_REGION"]
|
||||
bucket_name = os.environ["FRONTEND_BUCKET"]
|
||||
distribution_id = os.environ.get("DISTRIBUTION_ID", "")
|
||||
|
||||
# Retrieve the actual API key value
|
||||
# WARNING: This key will be publicly visible in the browser
|
||||
apigateway = boto3.client("apigateway")
|
||||
api_key_response = apigateway.get_api_key(
|
||||
apiKey=api_key_id, includeValue=True
|
||||
)
|
||||
api_key_value = api_key_response["value"]
|
||||
|
||||
# Generate config.js content
|
||||
# Remove trailing slash from API endpoint if present
|
||||
api_endpoint_clean = api_endpoint.rstrip("/")
|
||||
|
||||
config_content = f"""// This file is auto-generated during deployment
|
||||
// WARNING: API_KEY is exposed in client-side code - suitable for demos only
|
||||
window.APP_CONFIG = {{
|
||||
API_ENDPOINT: '{api_endpoint_clean}',
|
||||
API_KEY: '{api_key_value}',
|
||||
AWS_REGION: '{region}'
|
||||
}};
|
||||
"""
|
||||
|
||||
# Upload to S3
|
||||
s3.put_object(
|
||||
Bucket=bucket_name,
|
||||
Key="config.js",
|
||||
Body=config_content.encode("utf-8"),
|
||||
ContentType="application/javascript",
|
||||
CacheControl="no-cache, no-store, must-revalidate", # Prevent caching
|
||||
)
|
||||
|
||||
print(f"Successfully updated config.js in bucket {bucket_name}")
|
||||
|
||||
# Invalidate CloudFront cache for config.js
|
||||
if distribution_id:
|
||||
try:
|
||||
invalidation = cloudfront.create_invalidation(
|
||||
DistributionId=distribution_id,
|
||||
InvalidationBatch={
|
||||
"Paths": {"Quantity": 1, "Items": ["/config.js"]},
|
||||
"CallerReference": str(time.time()),
|
||||
},
|
||||
)
|
||||
print(
|
||||
f"CloudFront invalidation created: {invalidation['Invalidation']['Id']}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to invalidate CloudFront cache: {str(e)}")
|
||||
|
||||
response_data["Message"] = "Config injection successful"
|
||||
|
||||
elif request_type == "Delete":
|
||||
# Nothing to clean up
|
||||
print("Delete request - no action needed")
|
||||
response_data["Message"] = "Delete successful"
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
response_status = "FAILED"
|
||||
response_data["Message"] = str(e)
|
||||
|
||||
# Send response to CloudFormation
|
||||
send_response(event, context, response_status, response_data)
|
||||
|
||||
return {"statusCode": 200, "body": json.dumps(response_data)}
|
||||
|
||||
|
||||
def send_response(event, context, response_status, response_data):
|
||||
"""Send response to CloudFormation"""
|
||||
response_body = json.dumps(
|
||||
{
|
||||
"Status": response_status,
|
||||
"Reason": f"See CloudWatch Log Stream: {context.log_stream_name}",
|
||||
"PhysicalResourceId": context.log_stream_name,
|
||||
"StackId": event["StackId"],
|
||||
"RequestId": event["RequestId"],
|
||||
"LogicalResourceId": event["LogicalResourceId"],
|
||||
"Data": response_data,
|
||||
}
|
||||
)
|
||||
|
||||
print(f"Response body: {response_body}")
|
||||
|
||||
headers = {"Content-Type": "", "Content-Length": str(len(response_body))}
|
||||
|
||||
req = Request(
|
||||
event["ResponseURL"],
|
||||
data=response_body.encode("utf-8"),
|
||||
headers=headers,
|
||||
method="PUT",
|
||||
)
|
||||
|
||||
try:
|
||||
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||
response = urlopen(req) # nosec B310 - URL from CloudFormation, not user input
|
||||
print(f"CloudFormation response status: {response.status}")
|
||||
except Exception as e:
|
||||
print(f"Failed to send response to CloudFormation: {str(e)}")
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
import traceback
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
table = dynamodb.Table(os.environ["AGENT_DETAILS_TABLE_NAME"])
|
||||
# Use bedrock-agentcore-control for control plane operations (create/delete)
|
||||
bedrock_control_client = boto3.client(
|
||||
"bedrock-agentcore-control", region_name=os.environ["AWS_REGION"]
|
||||
)
|
||||
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "DELETE,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
try:
|
||||
# Get tenantId and agentRuntimeId from query parameters
|
||||
query_params = event.get("queryStringParameters") or {}
|
||||
tenant_id = query_params.get("tenantId")
|
||||
agent_runtime_id = query_params.get("agentRuntimeId")
|
||||
|
||||
if not tenant_id or not agent_runtime_id:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"error": "Both tenantId and agentRuntimeId query parameters are required"
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# Get agent details from DynamoDB using composite key
|
||||
print(
|
||||
f"Looking for agent with tenantId: {tenant_id}, agentRuntimeId: {agent_runtime_id}"
|
||||
)
|
||||
response = table.get_item(
|
||||
Key={"tenantId": tenant_id, "agentRuntimeId": agent_runtime_id}
|
||||
)
|
||||
|
||||
if "Item" not in response:
|
||||
return {
|
||||
"statusCode": 404,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": "Agent not found"}),
|
||||
}
|
||||
|
||||
# Step 1: Delete the agent runtime from Bedrock first
|
||||
print(f"Step 1: Deleting agent runtime from Bedrock: {agent_runtime_id}")
|
||||
try:
|
||||
# Use the control plane client to delete the agent runtime
|
||||
response = bedrock_control_client.delete_agent_runtime(
|
||||
agentRuntimeId=agent_runtime_id
|
||||
)
|
||||
print(
|
||||
f"Successfully deleted agent runtime from Bedrock: {agent_runtime_id}"
|
||||
)
|
||||
print(f"Delete response: {response}")
|
||||
except Exception as bedrock_error:
|
||||
error_msg = str(bedrock_error)
|
||||
error_type = type(bedrock_error).__name__
|
||||
print(f"Error deleting from Bedrock ({error_type}): {error_msg}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Check if it's just a "not found" error (agent already deleted)
|
||||
if (
|
||||
"ResourceNotFoundException" in error_type
|
||||
or "NotFound" in error_msg
|
||||
or "404" in error_msg
|
||||
):
|
||||
print(
|
||||
"Agent runtime not found in Bedrock (may have been already deleted)"
|
||||
)
|
||||
# Continue to delete from DB
|
||||
else:
|
||||
# Real error - don't delete from DB
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"error": "Failed to delete agent from Bedrock",
|
||||
"details": error_msg,
|
||||
"errorType": error_type,
|
||||
"tenantId": tenant_id,
|
||||
"agentRuntimeId": agent_runtime_id,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# Step 2: Only delete from DynamoDB if Bedrock deletion succeeded
|
||||
print(
|
||||
f"Step 2: Deleting agent details from DynamoDB for tenantId: {tenant_id}, agentRuntimeId: {agent_runtime_id}"
|
||||
)
|
||||
try:
|
||||
table.delete_item(
|
||||
Key={"tenantId": tenant_id, "agentRuntimeId": agent_runtime_id}
|
||||
)
|
||||
print("Successfully deleted agent details from DynamoDB")
|
||||
except Exception as db_error:
|
||||
error_msg = str(db_error)
|
||||
print( # nosec B608
|
||||
f"Warning: Agent deleted from Bedrock but failed to delete from DynamoDB: {error_msg}"
|
||||
)
|
||||
# Agent is deleted from Bedrock but not from DB - return partial success
|
||||
return {
|
||||
"statusCode": 207, # Multi-Status
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"message": "Agent deleted from Bedrock but failed to remove from database",
|
||||
"warning": "Database entry still exists",
|
||||
"details": error_msg,
|
||||
"tenantId": tenant_id,
|
||||
"agentRuntimeId": agent_runtime_id,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# Step 3: Return success response
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"message": "Agent deleted successfully from both Bedrock and database",
|
||||
"tenantId": tenant_id,
|
||||
"agentRuntimeId": agent_runtime_id,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
from decimal import Decimal
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
aggregation_table = dynamodb.Table(os.environ["AGGREGATION_TABLE_NAME"])
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received {len(event['Records'])} DynamoDB stream records")
|
||||
|
||||
# Dictionary to accumulate tokens per tenant
|
||||
tenant_aggregations = {}
|
||||
|
||||
for record in event["Records"]:
|
||||
if record["eventName"] == "INSERT":
|
||||
# New item was added
|
||||
new_item = record["dynamodb"]["NewImage"]
|
||||
|
||||
# Extract data from DynamoDB format
|
||||
item_id = new_item.get("id", {}).get("S", "unknown")
|
||||
timestamp = new_item.get("timestamp", {}).get("S", "unknown")
|
||||
tenant_id = new_item.get("tenant_id", {}).get("S", "default")
|
||||
input_tokens = int(new_item.get("input_tokens", {}).get("N", "0"))
|
||||
output_tokens = int(new_item.get("output_tokens", {}).get("N", "0"))
|
||||
total_tokens = int(new_item.get("total_tokens", {}).get("N", "0"))
|
||||
user_message = new_item.get("user_message", {}).get("S", "")
|
||||
|
||||
print("New token usage record:")
|
||||
print(f" ID: {item_id}")
|
||||
print(f" Tenant ID: {tenant_id}")
|
||||
print(f" Timestamp: {timestamp}")
|
||||
print(f" Input Tokens: {input_tokens}")
|
||||
print(f" Output Tokens: {output_tokens}")
|
||||
print(f" Total Tokens: {total_tokens}")
|
||||
print(f" User Message: {user_message[:50]}...")
|
||||
|
||||
# Accumulate tokens per tenant
|
||||
if tenant_id not in tenant_aggregations:
|
||||
tenant_aggregations[tenant_id] = {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
"request_count": 0,
|
||||
}
|
||||
|
||||
tenant_aggregations[tenant_id]["input_tokens"] += input_tokens
|
||||
tenant_aggregations[tenant_id]["output_tokens"] += output_tokens
|
||||
tenant_aggregations[tenant_id]["total_tokens"] += total_tokens
|
||||
tenant_aggregations[tenant_id]["request_count"] += 1
|
||||
|
||||
elif record["eventName"] == "MODIFY":
|
||||
print(f"Item modified: {record['dynamodb']['Keys']}")
|
||||
elif record["eventName"] == "REMOVE":
|
||||
print(f"Item removed: {record['dynamodb']['Keys']}")
|
||||
|
||||
# Update aggregation table with running totals per tenant
|
||||
for tenant_id, aggregation in tenant_aggregations.items():
|
||||
try:
|
||||
aggregation_key = f"tenant:{tenant_id}"
|
||||
|
||||
# Calculate cost increments
|
||||
# Pricing: $0.003 per 1000 input tokens, $0.015 per 1000 output tokens
|
||||
input_cost_increment = (
|
||||
Decimal(aggregation["input_tokens"])
|
||||
* Decimal("0.003")
|
||||
/ Decimal("1000")
|
||||
)
|
||||
output_cost_increment = (
|
||||
Decimal(aggregation["output_tokens"])
|
||||
* Decimal("0.015")
|
||||
/ Decimal("1000")
|
||||
)
|
||||
total_cost_increment = input_cost_increment + output_cost_increment
|
||||
|
||||
# Use atomic counter to increment the totals for this tenant
|
||||
response = aggregation_table.update_item(
|
||||
Key={"aggregation_key": aggregation_key},
|
||||
UpdateExpression="ADD input_tokens :input, output_tokens :output, total_tokens :total, request_count :count, input_cost :input_cost, output_cost :output_cost, total_cost :total_cost SET tenant_id = :tenant_id",
|
||||
ExpressionAttributeValues={
|
||||
":input": Decimal(aggregation["input_tokens"]),
|
||||
":output": Decimal(aggregation["output_tokens"]),
|
||||
":total": Decimal(aggregation["total_tokens"]),
|
||||
":count": Decimal(aggregation["request_count"]),
|
||||
":input_cost": input_cost_increment,
|
||||
":output_cost": output_cost_increment,
|
||||
":total_cost": total_cost_increment,
|
||||
":tenant_id": tenant_id,
|
||||
},
|
||||
ReturnValues="ALL_NEW",
|
||||
)
|
||||
|
||||
updated_item = response["Attributes"]
|
||||
print(f"\nUpdated aggregation for tenant {tenant_id}:")
|
||||
print(f" Total Input Tokens: {updated_item['input_tokens']}")
|
||||
print(f" Total Output Tokens: {updated_item['output_tokens']}")
|
||||
print(f" Total Tokens: {updated_item['total_tokens']}")
|
||||
print(f" Total Requests: {updated_item['request_count']}")
|
||||
print(
|
||||
f" Total Input Cost: ${float(updated_item.get('input_cost', 0)):.6f}"
|
||||
)
|
||||
print(
|
||||
f" Total Output Cost: ${float(updated_item.get('output_cost', 0)):.6f}"
|
||||
)
|
||||
print(f" Total Cost: ${float(updated_item.get('total_cost', 0)):.6f}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating aggregation table for tenant {tenant_id}: {str(e)}")
|
||||
raise
|
||||
|
||||
total_records = len(event["Records"])
|
||||
total_tenants = len(tenant_aggregations)
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"body": json.dumps(
|
||||
f"Processed {total_records} records for {total_tenants} tenant(s)"
|
||||
),
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Key
|
||||
import traceback
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
table = dynamodb.Table(os.environ["AGENT_DETAILS_TABLE_NAME"])
|
||||
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
try:
|
||||
# Get tenantId from query parameters
|
||||
query_params = event.get("queryStringParameters") or {}
|
||||
tenant_id = query_params.get("tenantId")
|
||||
|
||||
if not tenant_id:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": "tenantId query parameter is required"}),
|
||||
}
|
||||
|
||||
# Query DynamoDB for all agents with this tenantId
|
||||
print(f"Looking for agents with tenantId: {tenant_id}")
|
||||
|
||||
response = table.query(KeyConditionExpression=Key("tenantId").eq(tenant_id))
|
||||
|
||||
agents = response.get("Items", [])
|
||||
|
||||
if agents:
|
||||
print(f"Found {len(agents)} agent(s) for tenantId: {tenant_id}")
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(agents, default=str),
|
||||
}
|
||||
else:
|
||||
print(f"No agents found for tenantId: {tenant_id}")
|
||||
return {
|
||||
"statusCode": 404,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"error": "No agents found",
|
||||
"tenantId": tenant_id,
|
||||
"message": "No agents found with this tenant ID. They may still be deploying.",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Infrastructure Costs Lambda Handler
|
||||
|
||||
Retrieves infrastructure costs from AWS Cost Explorer filtered by tenant ID tags.
|
||||
Only queries for tenants that currently exist in the aggregation table.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
import boto3
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
ce_client = boto3.client("ce")
|
||||
|
||||
# Require AGGREGATION_TABLE_NAME to be set - fail fast if misconfigured
|
||||
if "AGGREGATION_TABLE_NAME" not in os.environ:
|
||||
raise ValueError(
|
||||
"AGGREGATION_TABLE_NAME environment variable is required but not set. "
|
||||
"This Lambda function cannot operate without proper configuration."
|
||||
)
|
||||
|
||||
aggregation_table = dynamodb.Table(os.environ["AGGREGATION_TABLE_NAME"])
|
||||
|
||||
|
||||
def get_current_tenant_ids():
|
||||
"""
|
||||
Retrieve list of tenant IDs from the aggregation table.
|
||||
Only returns tenants that have aggregation records.
|
||||
"""
|
||||
try:
|
||||
# Scan with pagination and server-side filtering
|
||||
from boto3.dynamodb.conditions import Attr
|
||||
|
||||
tenant_ids = set()
|
||||
last_evaluated_key = None
|
||||
|
||||
while True:
|
||||
scan_kwargs = {
|
||||
"FilterExpression": Attr("aggregation_key").begins_with("tenant:"),
|
||||
# Only retrieve the fields we need
|
||||
"ProjectionExpression": "tenant_id"
|
||||
}
|
||||
|
||||
if last_evaluated_key:
|
||||
scan_kwargs["ExclusiveStartKey"] = last_evaluated_key
|
||||
|
||||
response = aggregation_table.scan(**scan_kwargs)
|
||||
|
||||
# Extract tenant IDs from the filtered results
|
||||
for item in response.get("Items", []):
|
||||
tenant_id = item.get("tenant_id")
|
||||
if tenant_id:
|
||||
tenant_ids.add(tenant_id)
|
||||
|
||||
last_evaluated_key = response.get("LastEvaluatedKey")
|
||||
if not last_evaluated_key:
|
||||
break
|
||||
|
||||
return list(tenant_ids)
|
||||
except Exception as e:
|
||||
print(f"Error fetching tenant IDs: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
def build_cost_explorer_query(tenant_ids, start_date, end_date):
|
||||
"""
|
||||
Build the Cost Explorer query parameters.
|
||||
|
||||
Args:
|
||||
tenant_ids: List of tenant IDs to filter by
|
||||
start_date: Start date string (YYYY-MM-DD)
|
||||
end_date: End date string (YYYY-MM-DD)
|
||||
|
||||
Returns:
|
||||
Dictionary of query parameters for get_cost_and_usage
|
||||
"""
|
||||
if not tenant_ids:
|
||||
return None
|
||||
|
||||
return {
|
||||
"TimePeriod": {"Start": start_date, "End": end_date},
|
||||
"Granularity": "MONTHLY",
|
||||
"Filter": {
|
||||
"Tags": {
|
||||
"Key": "tenantId",
|
||||
"Values": tenant_ids,
|
||||
"MatchOptions": ["EQUALS"],
|
||||
}
|
||||
},
|
||||
"GroupBy": [{"Type": "TAG", "Key": "tenantId"}],
|
||||
"Metrics": ["UnblendedCost"],
|
||||
}
|
||||
|
||||
|
||||
def query_infrastructure_costs(tenant_ids):
|
||||
"""
|
||||
Query AWS Cost Explorer for infrastructure costs by tenant ID.
|
||||
|
||||
Args:
|
||||
tenant_ids: List of tenant IDs to query
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tenant_id to infrastructure_cost
|
||||
"""
|
||||
if not tenant_ids:
|
||||
return {}
|
||||
|
||||
# Calculate time period (first day of current month to today)
|
||||
today = datetime.utcnow()
|
||||
start_date = today.replace(day=1).strftime("%Y-%m-%d")
|
||||
end_date = (today + timedelta(days=1)).strftime("%Y-%m-%d") # End date is exclusive
|
||||
|
||||
query_params = build_cost_explorer_query(tenant_ids, start_date, end_date)
|
||||
if not query_params:
|
||||
return {}
|
||||
|
||||
try:
|
||||
response = ce_client.get_cost_and_usage(**query_params)
|
||||
|
||||
# Parse response and aggregate costs by tenant
|
||||
costs_by_tenant = {}
|
||||
|
||||
for result in response.get("ResultsByTime", []):
|
||||
for group in result.get("Groups", []):
|
||||
# Extract tenant ID from group key
|
||||
keys = group.get("Keys", [])
|
||||
if keys:
|
||||
# Key format is "tenantId$value" or just the value
|
||||
key = keys[0]
|
||||
tenant_id = (
|
||||
key.replace("tenantId$", "")
|
||||
if key.startswith("tenantId$")
|
||||
else key
|
||||
)
|
||||
|
||||
# Get cost amount
|
||||
metrics = group.get("Metrics", {})
|
||||
unblended_cost = metrics.get("UnblendedCost", {})
|
||||
amount = float(unblended_cost.get("Amount", 0))
|
||||
|
||||
# Aggregate costs (in case of multiple time periods)
|
||||
if tenant_id in costs_by_tenant:
|
||||
costs_by_tenant[tenant_id] += amount
|
||||
else:
|
||||
costs_by_tenant[tenant_id] = amount
|
||||
|
||||
return costs_by_tenant
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error querying Cost Explorer: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
def format_infrastructure_costs(tenant_ids, costs_by_tenant):
|
||||
"""
|
||||
Format infrastructure costs response, ensuring all tenants are included.
|
||||
Tenants without Cost Explorer data get zero cost.
|
||||
|
||||
Args:
|
||||
tenant_ids: List of all tenant IDs
|
||||
costs_by_tenant: Dictionary of costs from Cost Explorer
|
||||
|
||||
Returns:
|
||||
List of dictionaries with tenant_id and infrastructure_cost
|
||||
"""
|
||||
result = []
|
||||
|
||||
for tenant_id in tenant_ids:
|
||||
cost = costs_by_tenant.get(tenant_id, 0.0)
|
||||
result.append(
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"infrastructure_cost": round(cost, 6), # 6 decimal places precision
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""
|
||||
GET /infrastructure-costs
|
||||
|
||||
Returns infrastructure costs for all configured tenants.
|
||||
"""
|
||||
try:
|
||||
# Get list of current tenant IDs from aggregation table
|
||||
tenant_ids = get_current_tenant_ids()
|
||||
|
||||
if not tenant_ids:
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
},
|
||||
"body": json.dumps([]),
|
||||
}
|
||||
|
||||
# Query Cost Explorer for infrastructure costs
|
||||
costs_by_tenant = query_infrastructure_costs(tenant_ids)
|
||||
|
||||
# Format response with all tenants (zero cost for missing)
|
||||
result = format_infrastructure_costs(tenant_ids, costs_by_tenant)
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
},
|
||||
"body": json.dumps(result),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps({"error": "Failed to retrieve infrastructure costs"}),
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Attr
|
||||
import traceback
|
||||
|
||||
bedrock_runtime = boto3.client(
|
||||
"bedrock-agentcore", region_name=os.environ["AWS_REGION"]
|
||||
)
|
||||
|
||||
# Lazy initialization for DynamoDB (only needed if AGGREGATION_TABLE_NAME is set)
|
||||
_dynamodb = None
|
||||
_aggregation_table = None
|
||||
|
||||
|
||||
def get_aggregation_table():
|
||||
"""Get DynamoDB aggregation table with lazy initialization."""
|
||||
global _dynamodb, _aggregation_table
|
||||
if _aggregation_table is None:
|
||||
table_name = os.environ.get("AGGREGATION_TABLE_NAME")
|
||||
if table_name:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
_aggregation_table = _dynamodb.Table(table_name)
|
||||
return _aggregation_table
|
||||
|
||||
|
||||
def check_token_limit(tenant_id: str) -> tuple:
|
||||
"""
|
||||
Check if tenant has exceeded their token limit.
|
||||
|
||||
Returns:
|
||||
tuple: (allowed: bool, usage_info: dict)
|
||||
- allowed: True if request can proceed, False if limit exceeded
|
||||
- usage_info: Contains current usage and limit for error message
|
||||
|
||||
Note: This function fails open by default (allows requests on errors).
|
||||
Set FAIL_CLOSED=true environment variable to fail closed instead.
|
||||
"""
|
||||
table = get_aggregation_table()
|
||||
if table is None:
|
||||
# No aggregation table configured, allow request
|
||||
return True, {}
|
||||
|
||||
try:
|
||||
aggregation_key = f"tenant:{tenant_id}"
|
||||
response = table.get_item(Key={"aggregation_key": aggregation_key})
|
||||
|
||||
item = response.get("Item")
|
||||
if not item:
|
||||
# No usage record for tenant, allow request
|
||||
return True, {}
|
||||
|
||||
token_limit = item.get("token_limit")
|
||||
if token_limit is None:
|
||||
# No limit set for tenant, allow request
|
||||
return True, {"total_tokens": int(item.get("total_tokens", 0))}
|
||||
|
||||
total_tokens = int(item.get("total_tokens", 0))
|
||||
token_limit = int(token_limit)
|
||||
|
||||
usage_info = {
|
||||
"tenant_id": tenant_id,
|
||||
"total_tokens": total_tokens,
|
||||
"token_limit": token_limit,
|
||||
}
|
||||
|
||||
if total_tokens >= token_limit:
|
||||
# Limit exceeded
|
||||
return False, usage_info
|
||||
|
||||
return True, usage_info
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error checking token limit for tenant {tenant_id}: {str(e)}"
|
||||
print(error_msg)
|
||||
|
||||
# Check if we should fail closed (deny on error) or fail open (allow on error)
|
||||
fail_closed = os.environ.get("FAIL_CLOSED", "false").lower() == "true"
|
||||
|
||||
if fail_closed:
|
||||
# Fail closed: deny request on error
|
||||
print(f"FAIL_CLOSED mode: Denying request due to error checking token limit")
|
||||
return False, {
|
||||
"error": "Unable to verify token limit",
|
||||
"tenant_id": tenant_id,
|
||||
"message": "Service temporarily unavailable. Please try again later."
|
||||
}
|
||||
else:
|
||||
# Fail open: allow request on error (default behavior)
|
||||
print(f"FAIL_OPEN mode: Allowing request despite error checking token limit")
|
||||
return True, {}
|
||||
|
||||
|
||||
def get_tenant_id_from_agent(agent_runtime_arn: str) -> str:
|
||||
"""
|
||||
Look up the tenant ID associated with an agent from the agent details table.
|
||||
This prevents clients from bypassing token limits by omitting or spoofing tenantId.
|
||||
|
||||
Args:
|
||||
agent_runtime_arn: The agent runtime ARN
|
||||
|
||||
Returns:
|
||||
str: The tenant ID associated with the agent, or None if not found
|
||||
"""
|
||||
# Get agent details table name from environment
|
||||
agent_details_table_name = os.environ.get("AGENT_DETAILS_TABLE_NAME")
|
||||
if not agent_details_table_name:
|
||||
print("WARNING: AGENT_DETAILS_TABLE_NAME not configured, cannot look up tenant ID")
|
||||
return None
|
||||
|
||||
try:
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
agent_details_table = dynamodb.Table(agent_details_table_name)
|
||||
|
||||
# Scan for the agent with this ARN
|
||||
# Note: This could be optimized with a GSI on agentRuntimeArn
|
||||
response = agent_details_table.scan(
|
||||
FilterExpression=Attr("agentRuntimeArn").eq(agent_runtime_arn),
|
||||
ProjectionExpression="tenantId"
|
||||
)
|
||||
|
||||
items = response.get("Items", [])
|
||||
if items:
|
||||
tenant_id = items[0].get("tenantId")
|
||||
print(f"Found tenant ID {tenant_id} for agent {agent_runtime_arn}")
|
||||
return tenant_id
|
||||
else:
|
||||
print(f"WARNING: No tenant found for agent {agent_runtime_arn}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to look up tenant ID for agent {agent_runtime_arn}: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def get_tenant_id_from_agent(agent_runtime_arn: str) -> str:
|
||||
"""
|
||||
Look up the tenant ID associated with an agent from the agent details table.
|
||||
This prevents clients from bypassing token limits by omitting or spoofing tenant IDs.
|
||||
|
||||
Args:
|
||||
agent_runtime_arn: The agent runtime ARN
|
||||
|
||||
Returns:
|
||||
The tenant ID associated with the agent, or None if not found
|
||||
"""
|
||||
try:
|
||||
# Get agent details table name from environment
|
||||
agent_details_table_name = os.environ.get("AGENT_DETAILS_TABLE_NAME")
|
||||
if not agent_details_table_name:
|
||||
print("WARNING: AGENT_DETAILS_TABLE_NAME not configured, cannot look up tenant ID")
|
||||
return None
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
agent_details_table = dynamodb.Table(agent_details_table_name)
|
||||
|
||||
# Extract agent runtime ID from ARN
|
||||
# ARN format: arn:aws:bedrock-agentcore:region:account:agent/agent-runtime-id
|
||||
agent_runtime_id = agent_runtime_arn.split("/")[-1] if "/" in agent_runtime_arn else agent_runtime_arn
|
||||
|
||||
# Scan for the agent (we need to find by agentRuntimeId which is the sort key)
|
||||
# In production, consider adding a GSI on agentRuntimeId for better performance
|
||||
response = agent_details_table.scan(
|
||||
FilterExpression=Attr("agentRuntimeId").eq(agent_runtime_id) | Attr("agentRuntimeArn").eq(agent_runtime_arn),
|
||||
ProjectionExpression="tenantId"
|
||||
)
|
||||
|
||||
items = response.get("Items", [])
|
||||
if items:
|
||||
tenant_id = items[0].get("tenantId")
|
||||
print(f"Found tenant ID {tenant_id} for agent {agent_runtime_id}")
|
||||
return tenant_id
|
||||
else:
|
||||
print(f"WARNING: No tenant found for agent {agent_runtime_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error looking up tenant ID for agent {agent_runtime_arn}: {str(e)}")
|
||||
return None
|
||||
|
||||
|
||||
def extract_tenant_from_agent_arn(agent_arn: str) -> str:
|
||||
"""
|
||||
Extract tenant ID from agent ARN or return a default.
|
||||
Agent ARN format varies, so we need to look up the tenant from agent details.
|
||||
For now, we'll use a query parameter or body field.
|
||||
"""
|
||||
# This is a placeholder - in production, you'd look up the tenant from agent details
|
||||
return None
|
||||
|
||||
|
||||
# CORS headers for all responses
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def extract_text_from_response(obj):
|
||||
"""
|
||||
Recursively extract text content from nested response structure.
|
||||
Handles structures like: {'result': {'role': 'assistant', 'content': [{'text': '...'}]}}
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
|
||||
if isinstance(obj, dict):
|
||||
# Check for result key
|
||||
if "result" in obj:
|
||||
return extract_text_from_response(obj["result"])
|
||||
|
||||
# Check for role and content (Anthropic format)
|
||||
if "role" in obj and "content" in obj:
|
||||
return extract_text_from_response(obj["content"])
|
||||
|
||||
# Check for content array
|
||||
if "content" in obj and isinstance(obj["content"], list):
|
||||
texts = []
|
||||
for item in obj["content"]:
|
||||
if isinstance(item, dict) and "text" in item:
|
||||
texts.append(item["text"])
|
||||
elif isinstance(item, str):
|
||||
texts.append(item)
|
||||
return "\n\n".join(texts) if texts else str(obj)
|
||||
|
||||
# Check for direct text field
|
||||
if "text" in obj:
|
||||
return obj["text"]
|
||||
|
||||
# Check for message or completion
|
||||
if "message" in obj:
|
||||
return extract_text_from_response(obj["message"])
|
||||
if "completion" in obj:
|
||||
return extract_text_from_response(obj["completion"])
|
||||
|
||||
if isinstance(obj, list):
|
||||
texts = []
|
||||
for item in obj:
|
||||
if isinstance(item, dict) and "text" in item:
|
||||
texts.append(item["text"])
|
||||
elif isinstance(item, str):
|
||||
texts.append(item)
|
||||
else:
|
||||
texts.append(extract_text_from_response(item))
|
||||
return "\n\n".join(texts) if texts else str(obj)
|
||||
|
||||
return str(obj)
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
try:
|
||||
raw_body = event.get("body") or "{}"
|
||||
body = json.loads(raw_body)
|
||||
agent_id = body.get("agentId")
|
||||
input_text = body.get("inputText")
|
||||
session_id = body.get("sessionId", "default-session")
|
||||
|
||||
print(f"Agent ID: {agent_id}")
|
||||
print(f"Input text: {input_text}")
|
||||
|
||||
if not agent_id or not input_text:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": "agentId and inputText are required"}),
|
||||
}
|
||||
|
||||
# Look up tenant ID from agent details (server-side, not client-supplied)
|
||||
# This prevents clients from bypassing token limits
|
||||
tenant_id = get_tenant_id_from_agent(agent_id)
|
||||
|
||||
if tenant_id:
|
||||
print(f"Tenant ID (server-side lookup): {tenant_id}")
|
||||
allowed, usage_info = check_token_limit(tenant_id)
|
||||
if not allowed:
|
||||
# Check if this is a fail-closed error (service unavailable) or actual limit exceeded
|
||||
if "error" in usage_info:
|
||||
# Fail-closed: service error, return 503
|
||||
print(f"Service error checking token limit for tenant {tenant_id}: {usage_info}")
|
||||
return {
|
||||
"statusCode": 503,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"error": usage_info.get("error", "Service unavailable"),
|
||||
"message": usage_info.get("message", "Unable to process request at this time."),
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
),
|
||||
}
|
||||
else:
|
||||
# Token limit exceeded, return 429
|
||||
print(f"Token limit exceeded for tenant {tenant_id}: {usage_info}")
|
||||
return {
|
||||
"statusCode": 429,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"error": "Token limit exceeded",
|
||||
"message": f"Tenant {tenant_id} has reached their token limit of {usage_info.get('token_limit', 0):,} tokens. Current usage: {usage_info.get('total_tokens', 0):,} tokens.",
|
||||
"tenant_id": tenant_id,
|
||||
"token_limit": usage_info.get("token_limit"),
|
||||
"current_usage": usage_info.get("total_tokens"),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# Invoke the agent
|
||||
print(f"Invoking agent: {agent_id}")
|
||||
response = bedrock_runtime.invoke_agent_runtime(
|
||||
agentRuntimeArn=agent_id,
|
||||
payload=json.dumps({"message": input_text}).encode("utf-8"),
|
||||
contentType="application/json",
|
||||
)
|
||||
|
||||
# Log the full response structure for debugging
|
||||
print(f"Full response keys: {list(response.keys())}")
|
||||
print(f"Response metadata: {response.get('ResponseMetadata', {})}")
|
||||
|
||||
# The actual response is in the 'response' key, not 'body'
|
||||
response_data = response.get("response", "")
|
||||
|
||||
# Handle different response types
|
||||
if hasattr(response_data, "read"):
|
||||
# It's a StreamingBody
|
||||
response_data = response_data.read()
|
||||
|
||||
# Decode if bytes
|
||||
if isinstance(response_data, bytes):
|
||||
response_data = response_data.decode("utf-8")
|
||||
|
||||
print(f"Agent response data: {response_data}")
|
||||
print(f"Agent response data type: {type(response_data)}")
|
||||
print(f"Agent response data length: {len(str(response_data))}")
|
||||
|
||||
# Try to parse as JSON if it's a string
|
||||
try:
|
||||
if isinstance(response_data, str) and response_data.strip():
|
||||
parsed_response = json.loads(response_data)
|
||||
# Extract the actual text from the nested structure
|
||||
response_body = extract_text_from_response(parsed_response)
|
||||
else:
|
||||
response_body = response_data
|
||||
except json.JSONDecodeError:
|
||||
# If not JSON, use as-is
|
||||
response_body = response_data
|
||||
|
||||
# If response is empty, return a message indicating the agent processed the request
|
||||
if not response_body or str(response_body).strip() == "":
|
||||
response_body = "Agent processed the request successfully. Check token usage for confirmation."
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"completion": response_body, "sessionId": session_id}),
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
error_trace = traceback.format_exc()
|
||||
print(f"Error invoking agent: {error_msg}")
|
||||
print(f"Traceback: {error_trace}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": error_msg, "type": type(e).__name__}),
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Property-based tests for token limit enforcement logic.
|
||||
|
||||
Feature: tenant-token-limits, Property 6: Token Limit Enforcement
|
||||
Validates: Requirements 4.2, 4.3
|
||||
"""
|
||||
|
||||
from hypothesis import given, strategies as st, settings
|
||||
|
||||
|
||||
def check_limit_enforcement(total_tokens: int, token_limit: int | None) -> tuple:
|
||||
"""
|
||||
Pure function to check if a request should be allowed based on token usage and limit.
|
||||
|
||||
Args:
|
||||
total_tokens: Current total tokens used by tenant
|
||||
token_limit: Token limit for tenant (None means no limit)
|
||||
|
||||
Returns:
|
||||
tuple: (allowed: bool, reason: str)
|
||||
"""
|
||||
if token_limit is None:
|
||||
return True, "no_limit"
|
||||
|
||||
if total_tokens >= token_limit:
|
||||
return False, "limit_exceeded"
|
||||
|
||||
return True, "within_limit"
|
||||
|
||||
|
||||
class TestTokenLimitEnforcement:
|
||||
"""Property-based tests for token limit enforcement."""
|
||||
|
||||
@given(
|
||||
total_tokens=st.integers(min_value=0, max_value=10**12),
|
||||
token_limit=st.integers(min_value=1, max_value=10**12),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_enforcement_decision_matches_comparison(self, total_tokens, token_limit):
|
||||
"""
|
||||
Property 6: Token Limit Enforcement
|
||||
For any usage/limit combination, enforcement decision should match total_tokens >= token_limit.
|
||||
Validates: Requirements 4.2, 4.3
|
||||
"""
|
||||
allowed, reason = check_limit_enforcement(total_tokens, token_limit)
|
||||
|
||||
expected_blocked = total_tokens >= token_limit
|
||||
|
||||
if expected_blocked:
|
||||
assert allowed is False, (
|
||||
f"Expected blocked: usage={total_tokens}, limit={token_limit}"
|
||||
)
|
||||
assert reason == "limit_exceeded"
|
||||
else:
|
||||
assert allowed is True, (
|
||||
f"Expected allowed: usage={total_tokens}, limit={token_limit}"
|
||||
)
|
||||
assert reason == "within_limit"
|
||||
|
||||
@given(total_tokens=st.integers(min_value=0, max_value=10**12))
|
||||
@settings(max_examples=100)
|
||||
def test_no_limit_always_allows(self, total_tokens):
|
||||
"""
|
||||
Property 6: Token Limit Enforcement
|
||||
When no limit is set (None), requests should always be allowed.
|
||||
Validates: Requirements 4.4
|
||||
"""
|
||||
allowed, reason = check_limit_enforcement(total_tokens, None)
|
||||
|
||||
assert allowed is True, f"Expected allowed when no limit: usage={total_tokens}"
|
||||
assert reason == "no_limit"
|
||||
|
||||
@given(token_limit=st.integers(min_value=1, max_value=10**12))
|
||||
@settings(max_examples=100)
|
||||
def test_at_exact_limit_is_blocked(self, token_limit):
|
||||
"""
|
||||
Property 6: Token Limit Enforcement
|
||||
When usage equals limit exactly, request should be blocked.
|
||||
Validates: Requirements 4.2
|
||||
"""
|
||||
allowed, reason = check_limit_enforcement(token_limit, token_limit)
|
||||
|
||||
assert allowed is False, f"Expected blocked at exact limit: {token_limit}"
|
||||
assert reason == "limit_exceeded"
|
||||
|
||||
@given(token_limit=st.integers(min_value=2, max_value=10**12))
|
||||
@settings(max_examples=100)
|
||||
def test_one_below_limit_is_allowed(self, token_limit):
|
||||
"""
|
||||
Property 6: Token Limit Enforcement
|
||||
When usage is one below limit, request should be allowed.
|
||||
Validates: Requirements 4.2
|
||||
"""
|
||||
total_tokens = token_limit - 1
|
||||
allowed, reason = check_limit_enforcement(total_tokens, token_limit)
|
||||
|
||||
assert allowed is True, (
|
||||
f"Expected allowed one below limit: usage={total_tokens}, limit={token_limit}"
|
||||
)
|
||||
assert reason == "within_limit"
|
||||
|
||||
@given(
|
||||
token_limit=st.integers(min_value=1, max_value=10**12),
|
||||
excess=st.integers(min_value=1, max_value=10**6),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_over_limit_is_blocked(self, token_limit, excess):
|
||||
"""
|
||||
Property 6: Token Limit Enforcement
|
||||
When usage exceeds limit, request should be blocked.
|
||||
Validates: Requirements 4.2
|
||||
"""
|
||||
total_tokens = token_limit + excess
|
||||
allowed, reason = check_limit_enforcement(total_tokens, token_limit)
|
||||
|
||||
assert allowed is False, (
|
||||
f"Expected blocked over limit: usage={total_tokens}, limit={token_limit}"
|
||||
)
|
||||
assert reason == "limit_exceeded"
|
||||
|
||||
|
||||
class TestTokenLimitEnforcementEdgeCases:
|
||||
"""Unit tests for specific edge cases."""
|
||||
|
||||
def test_zero_usage_with_limit(self):
|
||||
"""Zero usage should always be allowed when limit exists."""
|
||||
allowed, reason = check_limit_enforcement(0, 1000)
|
||||
assert allowed is True
|
||||
assert reason == "within_limit"
|
||||
|
||||
def test_zero_usage_no_limit(self):
|
||||
"""Zero usage with no limit should be allowed."""
|
||||
allowed, reason = check_limit_enforcement(0, None)
|
||||
assert allowed is True
|
||||
assert reason == "no_limit"
|
||||
|
||||
def test_large_usage_no_limit(self):
|
||||
"""Large usage with no limit should be allowed."""
|
||||
allowed, reason = check_limit_enforcement(10**12, None)
|
||||
assert allowed is True
|
||||
assert reason == "no_limit"
|
||||
|
||||
def test_small_limit_exceeded(self):
|
||||
"""Small limit of 1 should block when usage is 1."""
|
||||
allowed, reason = check_limit_enforcement(1, 1)
|
||||
assert allowed is False
|
||||
assert reason == "limit_exceeded"
|
||||
|
||||
def test_small_limit_not_exceeded(self):
|
||||
"""Small limit of 1 should allow when usage is 0."""
|
||||
allowed, reason = check_limit_enforcement(0, 1)
|
||||
assert allowed is True
|
||||
assert reason == "within_limit"
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
import traceback
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
table = dynamodb.Table(os.environ["AGENT_DETAILS_TABLE_NAME"])
|
||||
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
try:
|
||||
# Scan DynamoDB for all agents with pagination
|
||||
agents = []
|
||||
last_evaluated_key = None
|
||||
|
||||
while True:
|
||||
if last_evaluated_key:
|
||||
response = table.scan(ExclusiveStartKey=last_evaluated_key)
|
||||
else:
|
||||
response = table.scan()
|
||||
|
||||
agents.extend(response.get("Items", []))
|
||||
|
||||
last_evaluated_key = response.get("LastEvaluatedKey")
|
||||
if not last_evaluated_key:
|
||||
break
|
||||
|
||||
print(f"Found {len(agents)} agents")
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(agents, default=str),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
from decimal import Decimal
|
||||
|
||||
# Lazy initialization to support testing
|
||||
_dynamodb = None
|
||||
_aggregation_table = None
|
||||
|
||||
|
||||
def get_aggregation_table():
|
||||
"""Get DynamoDB table with lazy initialization."""
|
||||
global _dynamodb, _aggregation_table
|
||||
if _aggregation_table is None:
|
||||
_dynamodb = boto3.resource("dynamodb")
|
||||
_aggregation_table = _dynamodb.Table(os.environ["AGGREGATION_TABLE_NAME"])
|
||||
return _aggregation_table
|
||||
|
||||
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "POST,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def validate_token_limit(value):
|
||||
"""
|
||||
Validate that token limit is a positive integer greater than zero.
|
||||
Returns (is_valid, error_message)
|
||||
"""
|
||||
if value is None:
|
||||
return False, "Token limit is required"
|
||||
|
||||
if isinstance(value, bool):
|
||||
return False, "Token limit must be a positive integer"
|
||||
|
||||
if isinstance(value, float) and not value.is_integer():
|
||||
return False, "Token limit must be a positive integer, not a decimal"
|
||||
|
||||
try:
|
||||
int_value = int(value)
|
||||
if int_value <= 0:
|
||||
return False, "Token limit must be greater than zero"
|
||||
return True, None
|
||||
except (ValueError, TypeError):
|
||||
return False, "Token limit must be a positive integer"
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
try:
|
||||
# Parse request body
|
||||
raw_body = event.get("body")
|
||||
if raw_body:
|
||||
body = json.loads(raw_body) if isinstance(raw_body, str) else raw_body
|
||||
else:
|
||||
body = {}
|
||||
|
||||
tenant_id = body.get("tenantId")
|
||||
token_limit = body.get("tokenLimit")
|
||||
|
||||
# Validate tenant ID
|
||||
if not tenant_id:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": "tenantId is required"}),
|
||||
}
|
||||
|
||||
# Validate token limit
|
||||
is_valid, error_message = validate_token_limit(token_limit)
|
||||
if not is_valid:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": error_message}),
|
||||
}
|
||||
|
||||
token_limit_int = int(token_limit)
|
||||
aggregation_key = f"tenant:{tenant_id}"
|
||||
|
||||
# Update or create the tenant record with token_limit
|
||||
aggregation_table = get_aggregation_table()
|
||||
aggregation_table.update_item(
|
||||
Key={"aggregation_key": aggregation_key},
|
||||
UpdateExpression="SET token_limit = :limit, tenant_id = :tenant_id",
|
||||
ExpressionAttributeValues={
|
||||
":limit": Decimal(token_limit_int),
|
||||
":tenant_id": tenant_id,
|
||||
},
|
||||
ReturnValues="ALL_NEW",
|
||||
)
|
||||
|
||||
print(f"Updated tenant {tenant_id} with token_limit: {token_limit_int}")
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{"success": True, "tenantId": tenant_id, "tokenLimit": token_limit_int}
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error setting token limit: {str(e)}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Shared utilities for Lambda functions
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Shared utilities for Lambda functions
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
# Standard CORS headers for all API responses
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def create_response(
|
||||
status_code: int, body: Dict[str, Any], headers: Dict[str, str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a standardized API Gateway response
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
body: Response body (will be JSON serialized)
|
||||
headers: Optional additional headers (will be merged with CORS headers)
|
||||
|
||||
Returns:
|
||||
API Gateway response dict
|
||||
"""
|
||||
response_headers = CORS_HEADERS.copy()
|
||||
if headers:
|
||||
response_headers.update(headers)
|
||||
|
||||
return {
|
||||
"statusCode": status_code,
|
||||
"headers": response_headers,
|
||||
"body": json.dumps(body, default=str),
|
||||
}
|
||||
|
||||
|
||||
def create_error_response(status_code: int, error_message: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a standardized error response
|
||||
|
||||
Args:
|
||||
status_code: HTTP error status code
|
||||
error_message: Error message
|
||||
|
||||
Returns:
|
||||
API Gateway error response dict
|
||||
"""
|
||||
return create_response(status_code, {"error": error_message})
|
||||
|
||||
|
||||
def create_success_response(data: Any, message: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a standardized success response
|
||||
|
||||
Args:
|
||||
data: Response data
|
||||
message: Optional success message
|
||||
|
||||
Returns:
|
||||
API Gateway success response dict
|
||||
"""
|
||||
body = {"data": data}
|
||||
if message:
|
||||
body["message"] = message
|
||||
|
||||
return create_response(200, body)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
table = dynamodb.Table(os.environ["TABLE_NAME"])
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received {len(event['Records'])} messages")
|
||||
|
||||
for record in event["Records"]:
|
||||
try:
|
||||
message = json.loads(record["body"])
|
||||
table.put_item(Item=message)
|
||||
print(f"Recorded usage: {message['id']} - {message['total_tokens']} tokens")
|
||||
except Exception as e:
|
||||
print(f"Error processing message: {str(e)}")
|
||||
raise
|
||||
|
||||
return {"statusCode": 200, "body": "Success"}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
from boto3.dynamodb.conditions import Attr
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
aggregation_table = dynamodb.Table(os.environ["AGGREGATION_TABLE_NAME"])
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
try:
|
||||
# Scan the aggregation table with pagination and server-side filtering
|
||||
# Use FilterExpression to filter at DynamoDB level, not in memory
|
||||
tenant_items = []
|
||||
last_evaluated_key = None
|
||||
|
||||
while True:
|
||||
scan_kwargs = {
|
||||
"FilterExpression": Attr("aggregation_key").begins_with("tenant:"),
|
||||
# Only retrieve fields needed by the frontend
|
||||
"ProjectionExpression": "aggregation_key, tenant_id, total_tokens, token_limit, #ts",
|
||||
"ExpressionAttributeNames": {"#ts": "timestamp"} # 'timestamp' is a reserved word
|
||||
}
|
||||
|
||||
if last_evaluated_key:
|
||||
scan_kwargs["ExclusiveStartKey"] = last_evaluated_key
|
||||
|
||||
response = aggregation_table.scan(**scan_kwargs)
|
||||
tenant_items.extend(response.get("Items", []))
|
||||
|
||||
last_evaluated_key = response.get("LastEvaluatedKey")
|
||||
if not last_evaluated_key:
|
||||
break
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS",
|
||||
},
|
||||
"body": json.dumps(tenant_items, default=str),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import json
|
||||
import os
|
||||
import boto3
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
|
||||
dynamodb = boto3.resource("dynamodb")
|
||||
config_table = dynamodb.Table(os.environ["AGENT_CONFIG_TABLE_NAME"])
|
||||
|
||||
CORS_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
|
||||
"Access-Control-Allow-Methods": "PUT,GET,OPTIONS",
|
||||
}
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
print(f"Received event: {json.dumps(event)}")
|
||||
|
||||
try:
|
||||
# Handle GET request - retrieve config
|
||||
if event.get("httpMethod") == "GET":
|
||||
query_params = event.get("queryStringParameters") or {}
|
||||
tenant_id = query_params.get("tenantId")
|
||||
agent_runtime_id = query_params.get("agentRuntimeId")
|
||||
|
||||
if not tenant_id or not agent_runtime_id:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{"error": "Both tenantId and agentRuntimeId are required"}
|
||||
),
|
||||
}
|
||||
|
||||
response = config_table.get_item(
|
||||
Key={"tenantId": tenant_id, "agentRuntimeId": agent_runtime_id}
|
||||
)
|
||||
|
||||
if "Item" in response:
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(response["Item"], default=str),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"statusCode": 404,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": "Configuration not found"}),
|
||||
}
|
||||
|
||||
# Handle PUT request - update config
|
||||
elif event.get("httpMethod") == "PUT":
|
||||
raw_body = event.get("body") or "{}"
|
||||
body = json.loads(raw_body)
|
||||
tenant_id = body.get("tenantId")
|
||||
agent_runtime_id = body.get("agentRuntimeId")
|
||||
config_updates = body.get("config", {})
|
||||
|
||||
if not tenant_id or not agent_runtime_id:
|
||||
return {
|
||||
"statusCode": 400,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{"error": "Both tenantId and agentRuntimeId are required"}
|
||||
),
|
||||
}
|
||||
|
||||
# Build update expression
|
||||
update_expr_parts = []
|
||||
expr_attr_values = {}
|
||||
expr_attr_names = {}
|
||||
|
||||
# Always update the timestamp
|
||||
update_expr_parts.append("#attr0 = :val0")
|
||||
expr_attr_names["#attr0"] = "updatedAt"
|
||||
expr_attr_values[":val0"] = datetime.now().isoformat()
|
||||
|
||||
# Add config updates using indexed placeholders
|
||||
for idx, (key, value) in enumerate(config_updates.items(), start=1):
|
||||
attr_placeholder = f"#attr{idx}"
|
||||
value_placeholder = f":val{idx}"
|
||||
update_expr_parts.append(f"{attr_placeholder} = {value_placeholder}")
|
||||
expr_attr_names[attr_placeholder] = key
|
||||
expr_attr_values[value_placeholder] = value
|
||||
|
||||
update_expression = "SET " + ", ".join(update_expr_parts)
|
||||
|
||||
print(f"Updating config for tenant {tenant_id}, agent {agent_runtime_id}")
|
||||
print(f"Update expression: {update_expression}")
|
||||
print(f"Values: {config_updates}")
|
||||
|
||||
response = config_table.update_item(
|
||||
Key={"tenantId": tenant_id, "agentRuntimeId": agent_runtime_id},
|
||||
UpdateExpression=update_expression,
|
||||
ExpressionAttributeNames=expr_attr_names,
|
||||
ExpressionAttributeValues=expr_attr_values,
|
||||
ReturnValues="ALL_NEW",
|
||||
)
|
||||
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"message": "Configuration updated successfully",
|
||||
"config": response["Attributes"],
|
||||
},
|
||||
default=str,
|
||||
),
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"statusCode": 405,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": "Method not allowed"}),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}")
|
||||
print(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": CORS_HEADERS,
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""CDK Constructs for Bedrock Agent Stack"""
|
||||
|
||||
from .database import DatabaseConstruct
|
||||
from .messaging import MessagingConstruct
|
||||
from .api import ApiConstruct
|
||||
from .lambdas import LambdasConstruct
|
||||
from .frontend import FrontendConstruct
|
||||
from .agent_runtime import AgentRuntimeConstruct
|
||||
from .helpers import add_cors_options
|
||||
|
||||
__all__ = [
|
||||
"DatabaseConstruct",
|
||||
"MessagingConstruct",
|
||||
"ApiConstruct",
|
||||
"LambdasConstruct",
|
||||
"FrontendConstruct",
|
||||
"AgentRuntimeConstruct",
|
||||
"add_cors_options",
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Agent runtime construct for Bedrock agent IAM role"""
|
||||
|
||||
from constructs import Construct
|
||||
from aws_cdk import aws_iam as iam, aws_sqs as sqs
|
||||
|
||||
|
||||
class AgentRuntimeConstruct(Construct):
|
||||
"""Construct for Bedrock Agent Runtime IAM role and permissions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Construct,
|
||||
construct_id: str,
|
||||
region: str,
|
||||
usage_queue: sqs.Queue,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
# IAM role for Bedrock Agent Runtime with least-privilege permissions
|
||||
self.agent_role = iam.Role(
|
||||
self,
|
||||
"BedrockAgentRole",
|
||||
role_name=f"AmazonBedrockAgentCoreSDKRuntime-{region}",
|
||||
assumed_by=iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
|
||||
inline_policies={
|
||||
"BedrockAgentCorePolicy": iam.PolicyDocument(
|
||||
statements=[
|
||||
# Bedrock model invocation permissions
|
||||
iam.PolicyStatement(
|
||||
effect=iam.Effect.ALLOW,
|
||||
actions=[
|
||||
"bedrock:InvokeModel",
|
||||
"bedrock:InvokeModelWithResponseStream",
|
||||
],
|
||||
resources=[f"arn:aws:bedrock:{region}::foundation-model/*"],
|
||||
),
|
||||
# AgentCore runtime permissions
|
||||
iam.PolicyStatement(
|
||||
effect=iam.Effect.ALLOW,
|
||||
actions=[
|
||||
"bedrock-agentcore:InvokeAgent",
|
||||
"bedrock-agentcore:GetAgent",
|
||||
],
|
||||
resources=["*"], # AgentCore resources are tenant-isolated
|
||||
),
|
||||
]
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# Grant agent role permission to send to SQS
|
||||
usage_queue.grant_send_messages(self.agent_role)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""API Gateway construct"""
|
||||
|
||||
from constructs import Construct
|
||||
from aws_cdk import aws_apigateway as apigateway, aws_lambda as lambda_
|
||||
|
||||
from .helpers import add_cors_options
|
||||
|
||||
|
||||
class ApiConstruct(Construct):
|
||||
"""Construct for API Gateway and all endpoints."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Construct,
|
||||
construct_id: str,
|
||||
async_deploy_lambda: lambda_.Function,
|
||||
token_usage_lambda: lambda_.Function,
|
||||
invoke_agent_lambda: lambda_.Function,
|
||||
get_agent_lambda: lambda_.Function,
|
||||
list_agents_lambda: lambda_.Function,
|
||||
delete_agent_lambda: lambda_.Function,
|
||||
update_config_lambda: lambda_.Function,
|
||||
set_tenant_limit_lambda: lambda_.Function,
|
||||
infrastructure_costs_lambda: lambda_.Function,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
# Create API Gateway
|
||||
self.api = apigateway.RestApi(
|
||||
self,
|
||||
"AgentDeploymentAPI",
|
||||
rest_api_name="Agent Deployment API",
|
||||
description="API to deploy Bedrock agents with tenant isolation",
|
||||
deploy_options=apigateway.StageOptions(
|
||||
stage_name="prod",
|
||||
throttling_rate_limit=100,
|
||||
throttling_burst_limit=200,
|
||||
),
|
||||
)
|
||||
|
||||
# Create API Key
|
||||
self.api_key = self.api.add_api_key(
|
||||
"AgentDeploymentApiKey",
|
||||
api_key_name="agent-deployment-key", # pragma: allowlist secret
|
||||
)
|
||||
|
||||
# Create Usage Plan
|
||||
usage_plan = self.api.add_usage_plan(
|
||||
"AgentDeploymentUsagePlan",
|
||||
name="Agent Deployment Usage Plan",
|
||||
throttle=apigateway.ThrottleSettings(rate_limit=100, burst_limit=200),
|
||||
quota=apigateway.QuotaSettings(limit=10000, period=apigateway.Period.DAY),
|
||||
)
|
||||
usage_plan.add_api_key(self.api_key)
|
||||
usage_plan.add_api_stage(stage=self.api.deployment_stage)
|
||||
|
||||
# Setup all endpoints
|
||||
self._setup_deploy_endpoint(async_deploy_lambda)
|
||||
self._setup_usage_endpoint(token_usage_lambda)
|
||||
self._setup_invoke_endpoint(invoke_agent_lambda)
|
||||
self._setup_agent_endpoint(get_agent_lambda, delete_agent_lambda)
|
||||
self._setup_agents_endpoint(list_agents_lambda)
|
||||
self._setup_config_endpoint(update_config_lambda)
|
||||
self._setup_tenant_limit_endpoint(set_tenant_limit_lambda)
|
||||
self._setup_infrastructure_costs_endpoint(infrastructure_costs_lambda)
|
||||
|
||||
def _setup_deploy_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /deploy endpoint."""
|
||||
resource = self.api.root.add_resource("deploy")
|
||||
|
||||
resource.add_method(
|
||||
"POST",
|
||||
apigateway.LambdaIntegration(lambda_fn, proxy=True),
|
||||
api_key_required=True,
|
||||
request_parameters={"method.request.querystring.tenantId": True},
|
||||
method_responses=[
|
||||
apigateway.MethodResponse(
|
||||
status_code="200",
|
||||
response_parameters={
|
||||
"method.response.header.Access-Control-Allow-Origin": True
|
||||
},
|
||||
response_models={"application/json": apigateway.Model.EMPTY_MODEL},
|
||||
)
|
||||
],
|
||||
)
|
||||
add_cors_options(resource, ["POST", "OPTIONS"])
|
||||
|
||||
def _setup_usage_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /usage endpoint."""
|
||||
resource = self.api.root.add_resource("usage")
|
||||
resource.add_method(
|
||||
"GET",
|
||||
apigateway.LambdaIntegration(lambda_fn, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
add_cors_options(resource, ["GET", "OPTIONS"])
|
||||
|
||||
def _setup_invoke_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /invoke endpoint."""
|
||||
resource = self.api.root.add_resource("invoke")
|
||||
resource.add_method(
|
||||
"POST",
|
||||
apigateway.LambdaIntegration(lambda_fn, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
add_cors_options(resource, ["POST", "OPTIONS"])
|
||||
|
||||
def _setup_agent_endpoint(
|
||||
self, get_lambda: lambda_.Function, delete_lambda: lambda_.Function
|
||||
) -> None:
|
||||
"""Setup /agent endpoint (GET and DELETE)."""
|
||||
resource = self.api.root.add_resource("agent")
|
||||
resource.add_method(
|
||||
"GET",
|
||||
apigateway.LambdaIntegration(get_lambda, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
resource.add_method(
|
||||
"DELETE",
|
||||
apigateway.LambdaIntegration(delete_lambda, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
add_cors_options(resource, ["GET", "DELETE", "OPTIONS"])
|
||||
|
||||
def _setup_agents_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /agents endpoint."""
|
||||
resource = self.api.root.add_resource("agents")
|
||||
resource.add_method(
|
||||
"GET",
|
||||
apigateway.LambdaIntegration(lambda_fn, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
add_cors_options(resource, ["GET", "OPTIONS"])
|
||||
|
||||
def _setup_config_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /config endpoint."""
|
||||
resource = self.api.root.add_resource("config")
|
||||
integration = apigateway.LambdaIntegration(lambda_fn, proxy=True)
|
||||
resource.add_method("GET", integration, api_key_required=True)
|
||||
resource.add_method("PUT", integration, api_key_required=True)
|
||||
add_cors_options(resource, ["GET", "PUT", "OPTIONS"])
|
||||
|
||||
def _setup_tenant_limit_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /tenant-limit endpoint."""
|
||||
resource = self.api.root.add_resource("tenant-limit")
|
||||
resource.add_method(
|
||||
"POST",
|
||||
apigateway.LambdaIntegration(lambda_fn, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
add_cors_options(resource, ["POST", "OPTIONS"])
|
||||
|
||||
def _setup_infrastructure_costs_endpoint(self, lambda_fn: lambda_.Function) -> None:
|
||||
"""Setup /infrastructure-costs endpoint."""
|
||||
resource = self.api.root.add_resource("infrastructure-costs")
|
||||
resource.add_method(
|
||||
"GET",
|
||||
apigateway.LambdaIntegration(lambda_fn, proxy=True),
|
||||
api_key_required=True,
|
||||
)
|
||||
add_cors_options(resource, ["GET", "OPTIONS"])
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Database construct for DynamoDB tables"""
|
||||
|
||||
from constructs import Construct
|
||||
from aws_cdk import RemovalPolicy, aws_dynamodb as dynamodb
|
||||
|
||||
|
||||
class DatabaseConstruct(Construct):
|
||||
"""Construct for all DynamoDB tables used in the application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Construct,
|
||||
construct_id: str,
|
||||
account_id: str,
|
||||
region: str,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
# Token usage table with streams enabled
|
||||
self.usage_table = dynamodb.Table(
|
||||
self,
|
||||
"TokenUsageTable",
|
||||
table_name=f"token-usage-{account_id}-{region}",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="id", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
sort_key=dynamodb.Attribute(
|
||||
name="timestamp", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
stream=dynamodb.StreamViewType.NEW_IMAGE,
|
||||
point_in_time_recovery=True,
|
||||
)
|
||||
|
||||
# Token aggregation table
|
||||
self.aggregation_table = dynamodb.Table(
|
||||
self,
|
||||
"TokenAggregationTable",
|
||||
table_name=f"token-aggregation-{account_id}-{region}",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="aggregation_key", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
point_in_time_recovery=True,
|
||||
)
|
||||
|
||||
# Agent configurations table (runtime config)
|
||||
self.agent_config_table = dynamodb.Table(
|
||||
self,
|
||||
"AgentConfigTable",
|
||||
table_name=f"agent-configurations-{account_id}-{region}",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="tenantId", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
sort_key=dynamodb.Attribute(
|
||||
name="agentRuntimeId", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
point_in_time_recovery=True,
|
||||
)
|
||||
|
||||
# Agent details table
|
||||
self.agent_details_table = dynamodb.Table(
|
||||
self,
|
||||
"AgentDetailsTable",
|
||||
table_name=f"agent-details-{account_id}-{region}",
|
||||
partition_key=dynamodb.Attribute(
|
||||
name="tenantId", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
sort_key=dynamodb.Attribute(
|
||||
name="agentRuntimeId", type=dynamodb.AttributeType.STRING
|
||||
),
|
||||
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
point_in_time_recovery=True,
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Frontend hosting construct for S3 and CloudFront"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from constructs import Construct
|
||||
from aws_cdk import (
|
||||
Duration,
|
||||
RemovalPolicy,
|
||||
CustomResource,
|
||||
aws_s3 as s3,
|
||||
aws_s3_deployment as s3_deployment,
|
||||
aws_cloudfront as cloudfront,
|
||||
aws_cloudfront_origins as origins,
|
||||
aws_lambda as lambda_,
|
||||
aws_iam as iam,
|
||||
aws_logs as logs,
|
||||
aws_apigateway as apigateway,
|
||||
)
|
||||
|
||||
|
||||
class FrontendConstruct(Construct):
|
||||
"""Construct for frontend hosting with S3, CloudFront, and config injection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Construct,
|
||||
construct_id: str,
|
||||
account_id: str,
|
||||
region: str,
|
||||
project_root: str,
|
||||
cdk_app_dir: str,
|
||||
api: apigateway.RestApi,
|
||||
api_key: apigateway.ApiKey,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
# S3 bucket for frontend
|
||||
# Note: Server access logging disabled to avoid deletion issues
|
||||
# For production, consider using CloudWatch Logs or S3 Inventory instead
|
||||
self.bucket = s3.Bucket(
|
||||
self,
|
||||
"FrontendBucket",
|
||||
bucket_name=f"bedrock-agent-dashboard-{account_id}-{region}",
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
auto_delete_objects=True,
|
||||
enforce_ssl=True,
|
||||
)
|
||||
|
||||
# Origin Access Identity for CloudFront
|
||||
oai = cloudfront.OriginAccessIdentity(
|
||||
self, "FrontendOAI", comment="OAI for Bedrock Agent Dashboard"
|
||||
)
|
||||
self.bucket.grant_read(oai)
|
||||
|
||||
# CloudFront distribution
|
||||
self.distribution = cloudfront.Distribution(
|
||||
self,
|
||||
"FrontendDistribution",
|
||||
default_behavior=cloudfront.BehaviorOptions(
|
||||
origin=origins.S3Origin(self.bucket, origin_access_identity=oai),
|
||||
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
||||
),
|
||||
default_root_object="index.html",
|
||||
error_responses=[
|
||||
cloudfront.ErrorResponse(
|
||||
http_status=404,
|
||||
response_http_status=200,
|
||||
response_page_path="/index.html",
|
||||
),
|
||||
cloudfront.ErrorResponse(
|
||||
http_status=403,
|
||||
response_http_status=200,
|
||||
response_page_path="/index.html",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Deploy frontend files
|
||||
frontend_dist_path = os.path.join(project_root, "frontend/dist")
|
||||
|
||||
# Check if frontend build exists
|
||||
if not os.path.exists(frontend_dist_path):
|
||||
print("=" * 80)
|
||||
print("WARNING: Frontend build not found!")
|
||||
print(f"Expected location: {frontend_dist_path}")
|
||||
print("To build the frontend, run:")
|
||||
print(" cd frontend && npm install && npm run build")
|
||||
print("=" * 80)
|
||||
raise FileNotFoundError(
|
||||
f"Frontend build directory not found at {frontend_dist_path}. "
|
||||
"Please build the frontend before deploying."
|
||||
)
|
||||
|
||||
frontend_deployment = s3_deployment.BucketDeployment(
|
||||
self,
|
||||
"DeployFrontend",
|
||||
sources=[s3_deployment.Source.asset(frontend_dist_path)],
|
||||
destination_bucket=self.bucket,
|
||||
distribution=self.distribution,
|
||||
distribution_paths=["/*"],
|
||||
)
|
||||
|
||||
# Config injector Lambda
|
||||
config_injector_log_group = logs.LogGroup(
|
||||
self,
|
||||
"ConfigInjectorLogGroup",
|
||||
log_group_name="/aws/lambda/frontend-config-injector",
|
||||
retention=logs.RetentionDays.ONE_WEEK,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
)
|
||||
|
||||
config_injector = lambda_.Function(
|
||||
self,
|
||||
"ConfigInjectorLambda",
|
||||
function_name="frontend-config-injector",
|
||||
runtime=lambda_.Runtime.PYTHON_3_10,
|
||||
handler="handler.lambda_handler",
|
||||
code=lambda_.Code.from_asset(
|
||||
os.path.join(cdk_app_dir, "lambda_functions/config_injector")
|
||||
),
|
||||
timeout=Duration.seconds(60),
|
||||
log_group=config_injector_log_group,
|
||||
memory_size=256,
|
||||
environment={
|
||||
"API_ENDPOINT": api.url,
|
||||
"API_KEY_ID": api_key.key_id,
|
||||
"FRONTEND_BUCKET": self.bucket.bucket_name,
|
||||
"DISTRIBUTION_ID": self.distribution.distribution_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Grant permissions
|
||||
config_injector.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=["apigateway:GET"],
|
||||
resources=[f"arn:aws:apigateway:{region}::/apikeys/{api_key.key_id}"],
|
||||
)
|
||||
)
|
||||
self.bucket.grant_write(config_injector)
|
||||
config_injector.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=["cloudfront:CreateInvalidation"],
|
||||
resources=[
|
||||
f"arn:aws:cloudfront::{account_id}:distribution/{self.distribution.distribution_id}"
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Custom Resource for config injection
|
||||
config_injection = CustomResource(
|
||||
self,
|
||||
"ConfigInjection",
|
||||
service_token=config_injector.function_arn,
|
||||
properties={"Timestamp": str(time.time())},
|
||||
)
|
||||
config_injection.node.add_dependency(api)
|
||||
config_injection.node.add_dependency(api_key)
|
||||
config_injection.node.add_dependency(frontend_deployment)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Helper functions for CDK constructs"""
|
||||
|
||||
from aws_cdk import aws_apigateway as apigateway
|
||||
|
||||
|
||||
def add_cors_options(resource: apigateway.Resource, methods: list[str]) -> None:
|
||||
"""
|
||||
Add CORS OPTIONS method to an API Gateway resource.
|
||||
|
||||
Args:
|
||||
resource: The API Gateway resource to add CORS to
|
||||
methods: List of HTTP methods to allow (e.g., ['GET', 'POST', 'OPTIONS'])
|
||||
"""
|
||||
methods_str = ",".join(methods)
|
||||
|
||||
resource.add_method(
|
||||
"OPTIONS",
|
||||
apigateway.MockIntegration(
|
||||
integration_responses=[
|
||||
apigateway.IntegrationResponse(
|
||||
status_code="200",
|
||||
response_parameters={
|
||||
"method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'",
|
||||
"method.response.header.Access-Control-Allow-Origin": "'*'",
|
||||
"method.response.header.Access-Control-Allow-Methods": f"'{methods_str}'",
|
||||
},
|
||||
)
|
||||
],
|
||||
passthrough_behavior=apigateway.PassthroughBehavior.NEVER,
|
||||
request_templates={"application/json": '{"statusCode": 200}'},
|
||||
),
|
||||
method_responses=[
|
||||
apigateway.MethodResponse(
|
||||
status_code="200",
|
||||
response_parameters={
|
||||
"method.response.header.Access-Control-Allow-Headers": True,
|
||||
"method.response.header.Access-Control-Allow-Origin": True,
|
||||
"method.response.header.Access-Control-Allow-Methods": True,
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Lambda functions construct"""
|
||||
|
||||
import os
|
||||
from constructs import Construct
|
||||
from aws_cdk import (
|
||||
Duration,
|
||||
Size,
|
||||
RemovalPolicy,
|
||||
aws_lambda as lambda_,
|
||||
aws_lambda_event_sources as lambda_event_sources,
|
||||
aws_iam as iam,
|
||||
aws_logs as logs,
|
||||
aws_dynamodb as dynamodb,
|
||||
aws_sqs as sqs,
|
||||
aws_s3 as s3,
|
||||
)
|
||||
|
||||
|
||||
class LambdasConstruct(Construct):
|
||||
"""Construct for all Lambda functions used in the application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Construct,
|
||||
construct_id: str,
|
||||
cdk_app_dir: str,
|
||||
usage_table: dynamodb.Table,
|
||||
aggregation_table: dynamodb.Table,
|
||||
agent_config_table: dynamodb.Table,
|
||||
agent_details_table: dynamodb.Table,
|
||||
usage_queue: sqs.Queue,
|
||||
code_bucket: s3.Bucket,
|
||||
agent_role_arn: str,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
self.cdk_app_dir = cdk_app_dir
|
||||
|
||||
# SQS to DynamoDB processor
|
||||
self.sqs_processor = self._create_lambda(
|
||||
"SQSToDynamoDBProcessor",
|
||||
"sqs-to-dynamodb-processor",
|
||||
"lambda_functions/sqs_to_dynamodb",
|
||||
environment={"TABLE_NAME": usage_table.table_name},
|
||||
)
|
||||
usage_table.grant_write_data(self.sqs_processor)
|
||||
self.sqs_processor.add_event_source(
|
||||
lambda_event_sources.SqsEventSource(usage_queue, batch_size=10)
|
||||
)
|
||||
|
||||
# DynamoDB stream processor
|
||||
self.stream_processor = self._create_lambda(
|
||||
"DynamoDBStreamProcessor",
|
||||
"dynamodb-stream-processor",
|
||||
"lambda_functions/dynamodb_stream_processor",
|
||||
environment={"AGGREGATION_TABLE_NAME": aggregation_table.table_name},
|
||||
)
|
||||
usage_table.grant_stream_read(self.stream_processor)
|
||||
aggregation_table.grant_read_write_data(self.stream_processor)
|
||||
self.stream_processor.add_event_source(
|
||||
lambda_event_sources.DynamoEventSource(
|
||||
usage_table,
|
||||
starting_position=lambda_.StartingPosition.LATEST,
|
||||
batch_size=10,
|
||||
retry_attempts=3,
|
||||
)
|
||||
)
|
||||
|
||||
# Build and deploy agent Lambda (special config - high memory/storage)
|
||||
build_deploy_log_group = logs.LogGroup(
|
||||
self,
|
||||
"BuildDeployAgentLogGroup",
|
||||
log_group_name="/aws/lambda/build-deploy-bedrock-agent",
|
||||
retention=logs.RetentionDays.ONE_WEEK,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
)
|
||||
|
||||
self.build_deploy_agent = lambda_.Function(
|
||||
self,
|
||||
"BuildDeployAgentLambda",
|
||||
function_name="build-deploy-bedrock-agent",
|
||||
runtime=lambda_.Runtime.PYTHON_3_10,
|
||||
handler="handler.lambda_handler",
|
||||
code=lambda_.Code.from_asset(
|
||||
os.path.join(cdk_app_dir, "lambda_functions/build_deploy_agent")
|
||||
),
|
||||
timeout=Duration.minutes(15),
|
||||
memory_size=3008,
|
||||
log_group=build_deploy_log_group,
|
||||
ephemeral_storage_size=Size.mebibytes(10240),
|
||||
environment={
|
||||
"AGENT_NAME": "sqs",
|
||||
"QUEUE_URL": usage_queue.queue_url,
|
||||
"ROLE_ARN": agent_role_arn,
|
||||
"BUCKET_NAME": code_bucket.bucket_name,
|
||||
"AGENT_CONFIG_TABLE_NAME": agent_config_table.table_name,
|
||||
"AGENT_DETAILS_TABLE_NAME": agent_details_table.table_name,
|
||||
},
|
||||
)
|
||||
code_bucket.grant_read_write(self.build_deploy_agent)
|
||||
agent_details_table.grant_write_data(self.build_deploy_agent)
|
||||
agent_config_table.grant_read_write_data(self.build_deploy_agent)
|
||||
|
||||
# Add Bedrock and IAM permissions
|
||||
self.build_deploy_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=[
|
||||
"bedrock-agentcore-control:*",
|
||||
"bedrock-agentcore:*",
|
||||
],
|
||||
resources=["*"],
|
||||
)
|
||||
)
|
||||
self.build_deploy_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=["iam:PassRole"],
|
||||
resources=[agent_role_arn],
|
||||
)
|
||||
)
|
||||
self.build_deploy_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=[
|
||||
"iam:CreateServiceLinkedRole",
|
||||
],
|
||||
resources=[
|
||||
"arn:aws:iam::*:role/aws-service-role/bedrock-agentcore.amazonaws.com/*"
|
||||
],
|
||||
conditions={
|
||||
"StringLike": {
|
||||
"iam:AWSServiceName": "bedrock-agentcore.amazonaws.com"
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
self.build_deploy_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=[
|
||||
"iam:GetRole",
|
||||
],
|
||||
resources=[
|
||||
"arn:aws:iam::*:role/aws-service-role/bedrock-agentcore.amazonaws.com/*"
|
||||
],
|
||||
)
|
||||
)
|
||||
self.build_deploy_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=[
|
||||
"ce:ListCostAllocationTags",
|
||||
"ce:UpdateCostAllocationTagsStatus",
|
||||
],
|
||||
resources=["*"],
|
||||
)
|
||||
)
|
||||
|
||||
# Async deploy Lambda
|
||||
self.async_deploy = self._create_lambda(
|
||||
"AsyncDeployLambda",
|
||||
"async-deploy-agent",
|
||||
"lambda_functions/async_deploy_agent",
|
||||
timeout_seconds=10,
|
||||
environment={
|
||||
"BUILD_DEPLOY_FUNCTION_NAME": self.build_deploy_agent.function_name
|
||||
},
|
||||
)
|
||||
self.build_deploy_agent.grant_invoke(self.async_deploy)
|
||||
|
||||
# Token usage Lambda
|
||||
self.token_usage = self._create_lambda(
|
||||
"TokenUsageLambda",
|
||||
"get-token-usage",
|
||||
"lambda_functions/token_usage",
|
||||
environment={"AGGREGATION_TABLE_NAME": aggregation_table.table_name},
|
||||
)
|
||||
aggregation_table.grant_read_data(self.token_usage)
|
||||
|
||||
# Invoke agent Lambda
|
||||
self.invoke_agent = self._create_lambda(
|
||||
"InvokeAgentLambda",
|
||||
"invoke-bedrock-agent",
|
||||
"lambda_functions/invoke_agent",
|
||||
timeout_seconds=60,
|
||||
memory_size=512,
|
||||
environment={"AGGREGATION_TABLE_NAME": aggregation_table.table_name},
|
||||
)
|
||||
self.invoke_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=["bedrock-agentcore:*"],
|
||||
resources=["*"],
|
||||
)
|
||||
)
|
||||
aggregation_table.grant_read_data(self.invoke_agent)
|
||||
|
||||
# Get agent details Lambda
|
||||
self.get_agent = self._create_lambda(
|
||||
"GetAgentLambda",
|
||||
"get-agent-details",
|
||||
"lambda_functions/get_agent_details",
|
||||
environment={"AGENT_DETAILS_TABLE_NAME": agent_details_table.table_name},
|
||||
)
|
||||
agent_details_table.grant_read_data(self.get_agent)
|
||||
|
||||
# List agents Lambda
|
||||
self.list_agents = self._create_lambda(
|
||||
"ListAgentsLambda",
|
||||
"list-agents",
|
||||
"lambda_functions/list_agents",
|
||||
environment={"AGENT_DETAILS_TABLE_NAME": agent_details_table.table_name},
|
||||
)
|
||||
agent_details_table.grant_read_data(self.list_agents)
|
||||
|
||||
# Delete agent Lambda
|
||||
self.delete_agent = self._create_lambda(
|
||||
"DeleteAgentLambda",
|
||||
"delete-agent",
|
||||
"lambda_functions/delete_agent",
|
||||
timeout_seconds=60,
|
||||
environment={"AGENT_DETAILS_TABLE_NAME": agent_details_table.table_name},
|
||||
)
|
||||
agent_details_table.grant_read_write_data(self.delete_agent)
|
||||
self.delete_agent.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=[
|
||||
"bedrock-agentcore:DeleteAgentRuntime",
|
||||
"bedrock-agentcore:GetAgentRuntime",
|
||||
],
|
||||
resources=["*"],
|
||||
)
|
||||
)
|
||||
|
||||
# Update config Lambda
|
||||
self.update_config = self._create_lambda(
|
||||
"UpdateConfigLambda",
|
||||
"update-agent-config",
|
||||
"lambda_functions/update_agent_config",
|
||||
environment={"AGENT_CONFIG_TABLE_NAME": agent_config_table.table_name},
|
||||
)
|
||||
agent_config_table.grant_read_write_data(self.update_config)
|
||||
|
||||
# Set tenant limit Lambda
|
||||
self.set_tenant_limit = self._create_lambda(
|
||||
"SetTenantLimitLambda",
|
||||
"set-tenant-limit",
|
||||
"lambda_functions/set_tenant_limit",
|
||||
environment={"AGGREGATION_TABLE_NAME": aggregation_table.table_name},
|
||||
)
|
||||
aggregation_table.grant_read_write_data(self.set_tenant_limit)
|
||||
|
||||
# Infrastructure costs Lambda
|
||||
self.infrastructure_costs = self._create_lambda(
|
||||
"InfrastructureCostsLambda",
|
||||
"get-infrastructure-costs",
|
||||
"lambda_functions/infrastructure_costs",
|
||||
timeout_seconds=30,
|
||||
environment={"AGGREGATION_TABLE_NAME": aggregation_table.table_name},
|
||||
)
|
||||
aggregation_table.grant_read_data(self.infrastructure_costs)
|
||||
# Grant Cost Explorer permissions
|
||||
self.infrastructure_costs.add_to_role_policy(
|
||||
iam.PolicyStatement(
|
||||
actions=["ce:GetCostAndUsage"],
|
||||
resources=["*"],
|
||||
)
|
||||
)
|
||||
|
||||
def _create_lambda(
|
||||
self,
|
||||
id: str,
|
||||
function_name: str,
|
||||
handler_path: str,
|
||||
timeout_seconds: int = 30,
|
||||
memory_size: int = 256,
|
||||
environment: dict = None,
|
||||
) -> lambda_.Function:
|
||||
"""Factory method to create Lambda functions with common configuration."""
|
||||
# Create log group explicitly
|
||||
log_group = logs.LogGroup(
|
||||
self,
|
||||
f"{id}LogGroup",
|
||||
log_group_name=f"/aws/lambda/{function_name}",
|
||||
retention=logs.RetentionDays.ONE_WEEK,
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
)
|
||||
|
||||
return lambda_.Function(
|
||||
self,
|
||||
id,
|
||||
function_name=function_name,
|
||||
runtime=lambda_.Runtime.PYTHON_3_10,
|
||||
handler="handler.lambda_handler",
|
||||
code=lambda_.Code.from_asset(os.path.join(self.cdk_app_dir, handler_path)),
|
||||
timeout=Duration.seconds(timeout_seconds),
|
||||
memory_size=memory_size,
|
||||
log_group=log_group,
|
||||
environment=environment or {},
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Messaging construct for SQS queues"""
|
||||
|
||||
from constructs import Construct
|
||||
from aws_cdk import Duration, RemovalPolicy, aws_sqs as sqs, aws_iam as iam
|
||||
|
||||
|
||||
class MessagingConstruct(Construct):
|
||||
"""Construct for SQS queues used in the application."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Construct,
|
||||
construct_id: str,
|
||||
account_id: str,
|
||||
region: str,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(scope, construct_id, **kwargs)
|
||||
|
||||
# Dead-letter queue for failed messages
|
||||
self.usage_dlq = sqs.Queue(
|
||||
self,
|
||||
"TokenUsageDLQ",
|
||||
queue_name=f"token-usage-dlq-{account_id}-{region}",
|
||||
retention_period=Duration.days(14),
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
)
|
||||
|
||||
# Token usage queue with DLQ
|
||||
self.usage_queue = sqs.Queue(
|
||||
self,
|
||||
"TokenUsageQueue",
|
||||
queue_name=f"token-usage-queue-{account_id}-{region}",
|
||||
visibility_timeout=Duration.seconds(300),
|
||||
retention_period=Duration.days(1),
|
||||
removal_policy=RemovalPolicy.DESTROY,
|
||||
dead_letter_queue=sqs.DeadLetterQueue(
|
||||
max_receive_count=3,
|
||||
queue=self.usage_dlq,
|
||||
),
|
||||
)
|
||||
|
||||
# Enforce SSL/TLS on main queue
|
||||
self.usage_queue.add_to_resource_policy(
|
||||
iam.PolicyStatement(
|
||||
sid="EnforceSSLOnly",
|
||||
effect=iam.Effect.DENY,
|
||||
principals=[iam.AnyPrincipal()],
|
||||
actions=["sqs:*"],
|
||||
resources=[self.usage_queue.queue_arn],
|
||||
conditions={"Bool": {"aws:SecureTransport": "false"}},
|
||||
)
|
||||
)
|
||||
|
||||
# Enforce SSL/TLS on DLQ
|
||||
self.usage_dlq.add_to_resource_policy(
|
||||
iam.PolicyStatement(
|
||||
sid="EnforceSSLOnly",
|
||||
effect=iam.Effect.DENY,
|
||||
principals=[iam.AnyPrincipal()],
|
||||
actions=["sqs:*"],
|
||||
resources=[self.usage_dlq.queue_arn],
|
||||
conditions={"Bool": {"aws:SecureTransport": "false"}},
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user