1
0
mirror of synced 2026-05-22 22:53:35 +00:00

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:
Mizer
2026-03-05 20:38:35 +01:00
committed by GitHub
parent d3bb341ae8
commit 1d8641b074
61 changed files with 14527 additions and 0 deletions
@@ -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
![Architecture Diagram](docs/architecture.drawio.png)
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 nonsecret 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 longlived 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 serverside and issues shortlived, 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()
@@ -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)}"
@@ -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"
]
}
@@ -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)}"
@@ -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)}"
@@ -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"
]
}
@@ -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}"
@@ -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"
}
@@ -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)
@@ -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
@@ -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)}),
}
@@ -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)})}
@@ -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)}")
@@ -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)}),
}
@@ -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)"
),
}
@@ -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)}),
}
@@ -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"}),
}
@@ -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__}),
}
@@ -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"
@@ -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)}),
}
@@ -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)
@@ -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"}
@@ -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)}),
}
@@ -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"}},
)
)