Platform Engineering for AI Teams: The Missing Layer That Makes AI Actually Work in Your Org

The Problem Every Growing AI Organisation Hits

Picture this. Your organisation decides to invest in AI. Three teams get the green light to build AI-powered features simultaneously.

Team A (customer service) spends two weeks building a Lambda function that calls Bedrock, adds basic error handling, and connects it to a Slack bot.

Team B (internal tools) spends two weeks building a Lambda function that calls Bedrock, adds basic error handling, and connects it to a web interface.

Team C (product recommendations) spends two weeks building a Lambda function that calls Bedrock, adds basic error handling, and connects it to their recommendation engine.

Three teams. Six weeks of combined engineering time. Three nearly identical implementations that differ in small, inconsistent ways. No shared prompt management. No shared cost tracking. No shared evaluation framework. No shared security controls. And when the company decides to switch from Claude 3 Haiku to Claude 3.5 Sonnet for better quality, all three teams need to update their code independently.

This is the AI platform problem. And it gets worse as more teams adopt AI.

The solution is Platform Engineering, building a shared internal platform that gives every team production-ready AI capabilities without each team building the plumbing themselves.

This is not a new concept. Platform engineering has been one of the fastest-growing disciplines in software for the past three years. What is new is applying it specifically to AI infrastructure, where the shared components are even more valuable because AI introduces unique challenges that every team faces: prompt management, model versioning, evaluation, cost attribution, hallucination handling, and safety controls.

What Is an AI Internal Developer Platform?

An AI Internal Developer Platform (AI IDP) is a set of shared services, tools, and abstractions that make it easy for any team in your organisation to build AI-powered features without reinventing the wheel.

Think of it like a utility company. You do not ask every building in a city to generate its own electricity. The utility company generates electricity once, manages the infrastructure, and every building connects to the grid. The buildings get reliable power. The city does not waste resources on redundant generators.

An AI platform works the same way:

WITHOUT AI PLATFORM:
Team A → builds auth + rate limiting + cost tracking + prompt mgmt → AI feature
Team B → builds auth + rate limiting + cost tracking + prompt mgmt → AI feature
Team C → builds auth + rate limiting + cost tracking + prompt mgmt → AI feature

9-12 weeks of duplicated engineering effort
3 inconsistent implementations
3 separate cost monitoring systems
No shared safety controls

WITH AI PLATFORM:
Platform team → builds auth + rate limiting + cost tracking + prompt mgmt → ONCE

Team A → connects to platform → builds AI feature (2-3 days)
Team B → connects to platform → builds AI feature (2-3 days)
Team C → connects to platform → builds AI feature (2-3 days)

3 weeks total. Consistent. Governed. Cost-attributed.

What We Are Building

A complete AI Internal Developer Platform on AWS with these components:

1. AI Gateway: the single entry point all teams use to call AI models. Handles authentication, rate limiting, model routing, and cost attribution automatically.

2. Prompt Library: a centralised store of versioned, tested prompt templates that teams can use and contribute to.

3. RAG Service: a shared retrieval-augmented generation service that any team can query without setting up their own knowledge base.

4. Cost Attribution Dashboard: real-time visibility into which team is spending what on AI, broken down by project and model.

5. Evaluation Framework: a shared system for testing AI feature quality so teams can measure whether their AI is actually working.

Architecture:

┌──────────────────────────────────────────────────────────────┐
│                    DEVELOPER TEAMS                           │
│   Team A App    │    Team B App    │    Team C App           │
└────────┬────────┴────────┬─────────┴────────┬────────────────┘
         │                 │                  │
         └────────────┬────┘                  │
                      │      All teams call   │
                      │      the same gateway │
                      ↓                       ↓
┌─────────────────────────────────────────────────────────────┐
│                    AI GATEWAY LAYER                         │
│         API Gateway + Lambda (auth, routing, logging)       │
│                                                             │
│   ┌────────────┐  ┌──────────────┐  ┌──────────────────┐  │
│   │ Auth &     │  │ Model Router │  │ Cost Attribution │  │
│   │ Rate Limit │  │ (Haiku →     │  │ (per team, per   │  │
│   │            │  │  Sonnet etc) │  │  project, daily) │  │
│   └────────────┘  └──────────────┘  └──────────────────┘  │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                    SHARED SERVICES                          │
│                                                             │
│  ┌─────────────────┐    ┌──────────────────────────────┐   │
│  │  Prompt Library  │    │     RAG Service              │   │
│  │  (DynamoDB +     │    │  (Bedrock Knowledge Base     │   │
│  │   versioning)    │    │   shared across all teams)   │   │
│  └─────────────────┘    └──────────────────────────────┘   │
│                                                             │
│  ┌─────────────────┐    ┌──────────────────────────────┐   │
│  │  Eval Framework  │    │  Cost Dashboard              │   │
│  │  (test suites +  │    │  (CloudWatch + QuickSight)   │   │
│  │   pass/fail)     │    │                              │   │
│  └─────────────────┘    └──────────────────────────────┘   │
└──────────────────────────┬──────────────────────────────────┘
                           │
┌──────────────────────────▼──────────────────────────────────┐
│                    AWS AI SERVICES                          │
│      Amazon Bedrock  │  Amazon S3  │  Amazon DynamoDB       │
└─────────────────────────────────────────────────────────────┘

What you need:

  • AWS account
  • AWS CLI configured
  • Basic Python knowledge
  • About 3 hours

Let us build it piece by piece.

Component 1: The AI Gateway

The AI Gateway is the heart of the platform. Every team calls the gateway instead of calling Bedrock directly. This gives the platform team centralised control over:

  • Which teams have access to which models
  • How many tokens each team can use per day
  • Which model to use for each request (routing)
  • Full cost attribution by team and project

Step 1: Create the DynamoDB Tables

bash

REGION="eu-west-1"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

# Table 1: Team registrations and API keys
aws dynamodb create-table 
  --table-name ai-platform-teams 
  --attribute-definitions 
    AttributeName=teamId,AttributeType=S 
  --key-schema 
    AttributeName=teamId,KeyType=HASH 
  --billing-mode PAY_PER_REQUEST 
  --region $REGION

# Table 2: Daily usage tracking (for rate limiting and cost attribution)
aws dynamodb create-table 
  --table-name ai-platform-usage 
  --attribute-definitions 
    AttributeName=teamId,AttributeType=S 
    AttributeName=dateAndProject,AttributeType=S 
  --key-schema 
    AttributeName=teamId,KeyType=HASH 
    AttributeName=dateAndProject,KeyType=RANGE 
  --billing-mode PAY_PER_REQUEST 
  --region $REGION

echo "DynamoDB tables created"

# Register your first team
aws dynamodb put-item 
  --table-name ai-platform-teams 
  --item '{
    "teamId": {"S": "team-customer-service"},
    "teamName": {"S": "Customer Service Team"},
    "allowedModels": {"SS": ["haiku", "sonnet"]},
    "dailyTokenLimit": {"N": "1000000"},
    "defaultModel": {"S": "haiku"},
    "projects": {"SS": ["cs-chatbot", "cs-summariser"]},
    "createdAt": {"S": "2026-01-01T00:00:00Z"},
    "active": {"BOOL": true}
  }' 
  --region $REGION

aws dynamodb put-item 
  --table-name ai-platform-teams 
  --item '{
    "teamId": {"S": "team-internal-tools"},
    "teamName": {"S": "Internal Tools Team"},
    "allowedModels": {"SS": ["haiku"]},
    "dailyTokenLimit": {"N": "500000"},
    "defaultModel": {"S": "haiku"},
    "projects": {"SS": ["hr-assistant", "policy-search"]},
    "createdAt": {"S": "2026-01-01T00:00:00Z"},
    "active": {"BOOL": true}
  }' 
  --region $REGION

echo "Teams registered"

Step 2: The AI Gateway Lambda

This is the core of the platform. Every team’s AI call goes through this function.

python

# ai_gateway.py
# The central AI Gateway — handles all team AI requests

import boto3
import json
import logging
import time
import hashlib
from datetime import datetime, timezone
from typing import Optional

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# AWS clients
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='eu-west-1')
cloudwatch = boto3.client('cloudwatch', region_name='eu-west-1')

teams_table = dynamodb.Table('ai-platform-teams')
usage_table = dynamodb.Table('ai-platform-usage')

# Model registry — maps friendly names to Bedrock model IDs
# Platform team controls which models are available
MODEL_REGISTRY = {
    "haiku": {
        "modelId": "anthropic.claude-3-haiku-20240307-v1:0",
        "inputCostPer1kTokens": 0.00025,
        "outputCostPer1kTokens": 0.00125,
        "maxTokens": 4096,
        "description": "Fast and cost-efficient. Good for simple tasks."
    },
    "sonnet": {
        "modelId": "anthropic.claude-3-5-sonnet-20241022-v2:0",
        "inputCostPer1kTokens": 0.003,
        "outputCostPer1kTokens": 0.015,
        "maxTokens": 8192,
        "description": "Balanced quality and cost. Good for complex reasoning."
    },
    "haiku-3-5": {
        "modelId": "anthropic.claude-3-5-haiku-20241022-v1:0",
        "inputCostPer1kTokens": 0.0008,
        "outputCostPer1kTokens": 0.004,
        "maxTokens": 8192,
        "description": "Latest fast model. Better than original Haiku."
    }
}


def log_event(level: str, event: str, **kwargs):
    """Structured logging for CloudWatch Logs Insights"""
    logger.info(json.dumps({
        "level": level,
        "event": event,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        **kwargs
    }))


def get_team(team_id: str) -> Optional[dict]:
    """Get team configuration from DynamoDB"""
    try:
        response = teams_table.get_item(Key={'teamId': team_id})
        return response.get('Item')
    except Exception as e:
        log_event("ERROR", "team_lookup_failed", team_id=team_id, error=str(e))
        return None


def select_model(team: dict, requested_model: Optional[str]) -> Optional[dict]:
    """
    Select the appropriate model based on:
    1. What the team requested (if allowed)
    2. The team's default model (if no request)
    3. Team's allowed models list (safety check)
    """
    allowed_models = list(team.get('allowedModels', {'haiku'}))

    # Use requested model if specified and allowed
    if requested_model:
        if requested_model not in allowed_models:
            return None  # Team not allowed to use this model
        model_key = requested_model
    else:
        # Use team's default model
        model_key = team.get('defaultModel', 'haiku')

    return MODEL_REGISTRY.get(model_key)


def check_and_record_usage(team_id: str, project: str, estimated_tokens: int, daily_limit: int) -> tuple[bool, dict]:
    """
    Rate limit check and usage recording.
    Uses DynamoDB atomic counter to prevent race conditions.
    Returns (is_allowed, usage_info)
    """
    today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
    sort_key = f"{today}#{project}"

    try:
        response = usage_table.update_item(
            Key={
                'teamId': team_id,
                'dateAndProject': sort_key
            },
            UpdateExpression='ADD tokensUsed :tokens SET lastRequest = :now, teamId = :tid, project = :proj, date = :date',
            ConditionExpression='attribute_not_exists(tokensUsed) OR tokensUsed < :limit',
            ExpressionAttributeValues={
                ':tokens': estimated_tokens,
                ':limit': daily_limit,
                ':now': datetime.now(timezone.utc).isoformat(),
                ':tid': team_id,
                ':proj': project,
                ':date': today
            },
            ReturnValues='UPDATED_NEW'
        )

        tokens_used = int(response['Attributes']['tokensUsed'])
        return True, {
            'tokensUsedToday': tokens_used,
            'dailyLimit': daily_limit,
            'remaining': max(0, daily_limit - tokens_used)
        }

    except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
        # Rate limit exceeded
        try:
            item = usage_table.get_item(
                Key={'teamId': team_id, 'dateAndProject': sort_key}
            ).get('Item', {})
            tokens_used = int(item.get('tokensUsed', daily_limit))
        except Exception:
            tokens_used = daily_limit

        return False, {
            'tokensUsedToday': tokens_used,
            'dailyLimit': daily_limit,
            'remaining': 0
        }


def call_bedrock(model_config: dict, messages: list, system_prompt: str, max_tokens: int) -> dict:
    """Call Bedrock with the selected model"""
    body = {
        "anthropic_version": "bedrock-2023-05-31",
        "max_tokens": min(max_tokens, model_config['maxTokens']),
        "temperature": 0.3,
        "messages": messages
    }

    if system_prompt:
        body["system"] = system_prompt

    max_retries = 3
    for attempt in range(max_retries):
        try:
            start_time = time.time()
            response = bedrock.invoke_model(
                modelId=model_config['modelId'],
                body=json.dumps(body)
            )
            latency_ms = int((time.time() - start_time) * 1000)
            result = json.loads(response['body'].read())

            return {
                'content': result['content'][0]['text'],
                'inputTokens': result['usage']['input_tokens'],
                'outputTokens': result['usage']['output_tokens'],
                'totalTokens': result['usage']['input_tokens'] + result['usage']['output_tokens'],
                'latencyMs': latency_ms,
                'stopReason': result.get('stop_reason', 'end_turn')
            }

        except bedrock.exceptions.ThrottlingException:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)

    raise RuntimeError("Bedrock call failed after all retries")


def publish_cost_metrics(team_id: str, project: str, model_key: str,
                         total_tokens: int, cost_usd: float, latency_ms: int):
    """Publish custom CloudWatch metrics for cost dashboard"""
    try:
        cloudwatch.put_metric_data(
            Namespace='AIPlatform/Usage',
            MetricData=[
                {
                    'MetricName': 'TokensConsumed',
                    'Value': total_tokens,
                    'Unit': 'Count',
                    'Dimensions': [
                        {'Name': 'TeamId', 'Value': team_id},
                        {'Name': 'Project', 'Value': project},
                        {'Name': 'Model', 'Value': model_key}
                    ]
                },
                {
                    'MetricName': 'EstimatedCostUSD',
                    'Value': cost_usd,
                    'Unit': 'None',
                    'Dimensions': [
                        {'Name': 'TeamId', 'Value': team_id},
                        {'Name': 'Project', 'Value': project},
                        {'Name': 'Model', 'Value': model_key}
                    ]
                },
                {
                    'MetricName': 'RequestLatencyMs',
                    'Value': latency_ms,
                    'Unit': 'Milliseconds',
                    'Dimensions': [
                        {'Name': 'TeamId', 'Value': team_id},
                        {'Name': 'Model', 'Value': model_key}
                    ]
                }
            ]
        )
    except Exception as e:
        log_event("WARN", "metrics_failed", error=str(e))


def build_response(status_code: int, body: dict, request_id: str) -> dict:
    return {
        'statusCode': status_code,
        'headers': {
            'Content-Type': 'application/json',
            'X-Request-ID': request_id,
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Headers': 'Content-Type,x-team-id,x-project-id',
            'Access-Control-Allow-Methods': 'POST,OPTIONS'
        },
        'body': json.dumps(body)
    }


def lambda_handler(event, context):
    """
    AI Gateway main handler.

    Expected request headers:
    - x-team-id: the calling team's ID (e.g. "team-customer-service")
    - x-project-id: the project within the team (e.g. "cs-chatbot")

    Expected request body:
    {
      "messages": [{"role": "user", "content": "Your question here"}],
      "systemPrompt": "Optional system prompt",
      "model": "haiku" | "sonnet" | "haiku-3-5" (optional, uses team default),
      "maxTokens": 1000 (optional, default 1000)
    }
    """
    request_id = context.aws_request_id
    start_time = time.time()

    # Handle CORS preflight
    if event.get('requestContext', {}).get('http', {}).get('method') == 'OPTIONS':
        return build_response(200, {}, request_id)

    # Extract team and project from headers
    headers = {k.lower(): v for k, v in event.get('headers', {}).items()}
    team_id = headers.get('x-team-id', '')
    project_id = headers.get('x-project-id', 'default')

    if not team_id:
        return build_response(401, {
            'success': False,
            'error': {'code': 'MISSING_TEAM_ID', 'message': 'x-team-id header is required'}
        }, request_id)

    # Load team configuration
    team = get_team(team_id)
    if not team:
        return build_response(401, {
            'success': False,
            'error': {'code': 'UNKNOWN_TEAM', 'message': f'Team {team_id} is not registered on the AI platform'}
        }, request_id)

    if not team.get('active', True):
        return build_response(403, {
            'success': False,
            'error': {'code': 'TEAM_SUSPENDED', 'message': 'This team account has been suspended'}
        }, request_id)

    # Validate project
    allowed_projects = list(team.get('projects', {'default'}))
    if project_id not in allowed_projects and 'default' not in allowed_projects:
        return build_response(403, {
            'success': False,
            'error': {
                'code': 'UNKNOWN_PROJECT',
                'message': f'Project {project_id} is not registered for team {team_id}',
                'allowedProjects': allowed_projects
            }
        }, request_id)

    # Parse request body
    try:
        body = json.loads(event.get('body', '{}'))
    except json.JSONDecodeError:
        return build_response(400, {
            'success': False,
            'error': {'code': 'INVALID_JSON', 'message': 'Request body must be valid JSON'}
        }, request_id)

    messages = body.get('messages', [])
    system_prompt = body.get('systemPrompt', '')
    requested_model = body.get('model')
    max_tokens = min(body.get('maxTokens', 1000), 4096)

    # Validate messages
    if not messages or not isinstance(messages, list):
        return build_response(400, {
            'success': False,
            'error': {'code': 'INVALID_MESSAGES', 'message': 'messages must be a non-empty array'}
        }, request_id)

    # Select model (with team permission check)
    model_config = select_model(team, requested_model)
    if not model_config:
        return build_response(403, {
            'success': False,
            'error': {
                'code': 'MODEL_NOT_ALLOWED',
                'message': f'Model {requested_model} is not allowed for team {team_id}',
                'allowedModels': list(team.get('allowedModels', {'haiku'}))
            }
        }, request_id)

    model_key = requested_model or team.get('defaultModel', 'haiku')

    # Rate limiting check
    daily_limit = int(team.get('dailyTokenLimit', 500000))
    estimated_tokens = sum(len(m.get('content', '')) // 4 for m in messages) + max_tokens

    is_allowed, usage_info = check_and_record_usage(team_id, project_id, estimated_tokens, daily_limit)

    if not is_allowed:
        log_event("WARN", "rate_limit_exceeded",
                  team_id=team_id, project_id=project_id,
                  tokens_used=usage_info['tokensUsedToday'])
        return build_response(429, {
            'success': False,
            'error': {
                'code': 'RATE_LIMIT_EXCEEDED',
                'message': f'Daily token limit exceeded for team {team_id}. Resets at midnight UTC.',
                'usage': usage_info
            }
        }, request_id)

    # Call Bedrock
    try:
        result = call_bedrock(model_config, messages, system_prompt, max_tokens)

        # Calculate cost
        cost_usd = (
            (result['inputTokens'] / 1000) * model_config['inputCostPer1kTokens'] +
            (result['outputTokens'] / 1000) * model_config['outputCostPer1kTokens']
        )

        total_duration_ms = int((time.time() - start_time) * 1000)

        log_event("INFO", "request_completed",
                  request_id=request_id,
                  team_id=team_id,
                  project_id=project_id,
                  model=model_key,
                  input_tokens=result['inputTokens'],
                  output_tokens=result['outputTokens'],
                  cost_usd=round(cost_usd, 8),
                  latency_ms=result['latencyMs'],
                  total_duration_ms=total_duration_ms)

        publish_cost_metrics(team_id, project_id, model_key,
                             result['totalTokens'], cost_usd, result['latencyMs'])

        return build_response(200, {
            'success': True,
            'content': result['content'],
            'usage': {
                'inputTokens': result['inputTokens'],
                'outputTokens': result['outputTokens'],
                'totalTokens': result['totalTokens'],
                'estimatedCostUsd': round(cost_usd, 8),
                'latencyMs': result['latencyMs'],
                'model': model_key
            },
            'rateLimitInfo': {
                'tokensUsedToday': usage_info['tokensUsedToday'],
                'dailyLimit': usage_info['dailyLimit'],
                'remaining': usage_info['remaining']
            },
            'requestId': request_id
        }, request_id)

    except bedrock.exceptions.ThrottlingException:
        return build_response(503, {
            'success': False,
            'error': {'code': 'SERVICE_BUSY', 'message': 'AI service temporarily busy. Please retry in 30 seconds.'}
        }, request_id)

    except Exception as e:
        log_event("ERROR", "request_failed",
                  request_id=request_id, team_id=team_id,
                  error_type=type(e).__name__, error=str(e))
        return build_response(500, {
            'success': False,
            'error': {'code': 'INTERNAL_ERROR', 'message': 'An unexpected error occurred.'}
        }, request_id)

Step 3: Deploy the AI Gateway

bash

# Package
zip -j ai-gateway.zip ai_gateway.py

# Create Lambda IAM role
cat > gateway-trust.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Service": "lambda.amazonaws.com"},
    "Action": "sts:AssumeRole"
  }]
}
EOF

aws iam create-role 
  --role-name ai-gateway-lambda-role 
  --assume-role-policy-document file://gateway-trust.json

aws iam attach-role-policy 
  --role-name ai-gateway-lambda-role 
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

cat > gateway-policy.json << EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel"],
      "Resource": "arn:aws:bedrock:us-east-1::foundation-model/*"
    },
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem"],
      "Resource": [
        "arn:aws:dynamodb:$REGION:$ACCOUNT_ID:table/ai-platform-teams",
        "arn:aws:dynamodb:$REGION:$ACCOUNT_ID:table/ai-platform-usage"
      ]
    },
    {
      "Effect": "Allow",
      "Action": ["cloudwatch:PutMetricData"],
      "Resource": "*"
    }
  ]
}
EOF

aws iam put-role-policy 
  --role-name ai-gateway-lambda-role 
  --policy-name gateway-permissions 
  --policy-document file://gateway-policy.json

GATEWAY_ROLE_ARN=$(aws iam get-role 
  --role-name ai-gateway-lambda-role 
  --query 'Role.Arn' --output text)

sleep 10

# Deploy Lambda
aws lambda create-function 
  --function-name ai-platform-gateway 
  --runtime python3.12 
  --role $GATEWAY_ROLE_ARN 
  --handler ai_gateway.lambda_handler 
  --zip-file fileb://ai-gateway.zip 
  --timeout 30 
  --memory-size 512 
  --region $REGION

# Create API Gateway
GATEWAY_API_ID=$(aws apigatewayv2 create-api 
  --name "ai-platform-gateway-api" 
  --protocol-type HTTP 
  --cors-configuration 
    AllowOrigins='*' 
    AllowHeaders='Content-Type,x-team-id,x-project-id' 
    AllowMethods='POST,OPTIONS' 
  --region $REGION 
  --query 'ApiId' --output text)

INT_ID=$(aws apigatewayv2 create-integration 
  --api-id $GATEWAY_API_ID 
  --integration-type AWS_PROXY 
  --integration-uri arn:aws:lambda:$REGION:$ACCOUNT_ID:function:ai-platform-gateway 
  --payload-format-version 2.0 
  --region $REGION 
  --query 'IntegrationId' --output text)

aws apigatewayv2 create-route 
  --api-id $GATEWAY_API_ID 
  --route-key 'POST /generate' 
  --target integrations/$INT_ID 
  --region $REGION

aws apigatewayv2 create-stage 
  --api-id $GATEWAY_API_ID 
  --stage-name production 
  --auto-deploy 
  --region $REGION

aws lambda add-permission 
  --function-name ai-platform-gateway 
  --statement-id allow-apigw 
  --action lambda:InvokeFunction 
  --principal apigateway.amazonaws.com 
  --source-arn "arn:aws:execute-api:$REGION:$ACCOUNT_ID:$GATEWAY_API_ID/*/*" 
  --region $REGION

GATEWAY_URL=$(aws apigatewayv2 get-api 
  --api-id $GATEWAY_API_ID 
  --region $REGION 
  --query 'ApiEndpoint' --output text)

echo "AI Gateway URL: $GATEWAY_URL/production/generate"

Step 4: Test the Gateway

bash

GATEWAY_ENDPOINT="$GATEWAY_URL/production/generate"

# Test as Team A — customer service
echo "=== Team A calling AI Gateway ==="
curl -s -X POST $GATEWAY_ENDPOINT 
  -H "Content-Type: application/json" 
  -H "x-team-id: team-customer-service" 
  -H "x-project-id: cs-chatbot" 
  -d '{
    "messages": [{"role": "user", "content": "Summarise this customer complaint in one sentence: The product arrived broken and customer service has not responded in 5 days."}],
    "model": "haiku"
  }' | python3 -m json.tool

echo ""

# Test as unknown team — should be rejected
echo "=== Unknown team — should fail ==="
curl -s -X POST $GATEWAY_ENDPOINT 
  -H "Content-Type: application/json" 
  -H "x-team-id: team-unknown" 
  -H "x-project-id: some-project" 
  -d '{"messages": [{"role": "user", "content": "Test"}]}' 
  | python3 -m json.tool

echo ""

# Test team trying to use disallowed model
echo "=== Team B trying Sonnet (not allowed) — should fail ==="
curl -s -X POST $GATEWAY_ENDPOINT 
  -H "Content-Type: application/json" 
  -H "x-team-id: team-internal-tools" 
  -H "x-project-id: hr-assistant" 
  -d '{
    "messages": [{"role": "user", "content": "Test"}],
    "model": "sonnet"
  }' | python3 -m json.tool

Component 2: The Prompt Library

Without a prompt library, every developer writes their own prompts from scratch. You end up with 15 different ways to ask the AI to “summarise a customer complaint”, some good, most not. A prompt library stores your best, tested prompts centrally so every team benefits.

The DynamoDB Prompt Store

bash

# Create prompt library table
aws dynamodb create-table 
  --table-name ai-platform-prompts 
  --attribute-definitions 
    AttributeName=promptId,AttributeType=S 
    AttributeName=version,AttributeType=S 
  --key-schema 
    AttributeName=promptId,KeyType=HASH 
    AttributeName=version,KeyType=RANGE 
  --billing-mode PAY_PER_REQUEST 
  --region $REGION

# Add your first prompt template
aws dynamodb put-item 
  --table-name ai-platform-prompts 
  --item '{
    "promptId": {"S": "summarise-customer-complaint"},
    "version": {"S": "v1.2"},
    "name": {"S": "Summarise Customer Complaint"},
    "description": {"S": "Summarises a customer complaint into a structured format"},
    "category": {"S": "customer-service"},
    "template": {"S": "You are a customer service analyst. Analyse this customer complaint and respond in JSON:n{n  "summary": "one sentence summary",n  "sentiment": "POSITIVE|NEUTRAL|NEGATIVE|VERY_NEGATIVE",n  "category": "billing|technical|delivery|other",n  "urgency": "LOW|MEDIUM|HIGH|CRITICAL",n  "suggestedAction": "what customer service should do"n}nnComplaint: {{COMPLAINT_TEXT}}nnRespond with valid JSON only."},
    "variables": {"SS": ["COMPLAINT_TEXT"]},
    "testedWith": {"SS": ["team-customer-service"]},
    "passRate": {"N": "94"},
    "createdBy": {"S": "platform-team"},
    "createdAt": {"S": "2026-01-15T10:00:00Z"},
    "active": {"BOOL": true}
  }' 
  --region $REGION

aws dynamodb put-item 
  --table-name ai-platform-prompts 
  --item '{
    "promptId": {"S": "generate-meeting-summary"},
    "version": {"S": "v2.0"},
    "name": {"S": "Generate Meeting Summary"},
    "description": {"S": "Converts meeting transcript into structured summary with action items"},
    "category": {"S": "productivity"},
    "template": {"S": "You are an executive assistant. Convert this meeting transcript into a structured summary.nnReturn JSON:n{n  "meetingTitle": "inferred from content",n  "keyDecisions": ["decision 1", "decision 2"],n  "actionItems": [n    {"owner": "name", "action": "what to do", "deadline": "by when"}n  ],n  "summary": "2-3 sentence overview",n  "nextMeeting": "date/time if mentioned, or null"n}nnTranscript:n{{TRANSCRIPT}}nnRespond with valid JSON only."},
    "variables": {"SS": ["TRANSCRIPT"]},
    "testedWith": {"SS": ["team-internal-tools"]},
    "passRate": {"N": "91"},
    "createdBy": {"S": "platform-team"},
    "createdAt": {"S": "2026-02-01T09:00:00Z"},
    "active": {"BOOL": true}
  }' 
  --region $REGION

echo "Prompt library seeded"

The Prompt Library Lambda

python

# prompt_library.py
# Teams use this to fetch and use versioned prompts

import boto3
import json
import logging
from datetime import datetime, timezone

logger = logging.getLogger()
logger.setLevel(logging.INFO)

dynamodb = boto3.resource('dynamodb', region_name='eu-west-1')
prompts_table = dynamodb.Table('ai-platform-prompts')


def get_prompt(prompt_id: str, version: str = None) -> dict:
    """
    Fetch a prompt template by ID.
    If version not specified, returns the latest active version.
    """
    if version:
        response = prompts_table.get_item(
            Key={'promptId': prompt_id, 'version': version}
        )
        return response.get('Item')
    else:
        # Scan for latest version of this prompt
        # In production, use a GSI on promptId + active for efficiency
        response = prompts_table.query(
            KeyConditionExpression=boto3.dynamodb.conditions.Key('promptId').eq(prompt_id),
            FilterExpression=boto3.dynamodb.conditions.Attr('active').eq(True)
        )
        items = response.get('Items', [])
        if not items:
            return None
        # Return highest version (sorts lexicographically — v1, v2, v10 etc)
        return sorted(items, key=lambda x: x.get('version', 'v0'))[-1]


def fill_template(template: str, variables: dict) -> str:
    """
    Replace {{VARIABLE_NAME}} placeholders in a template with actual values.

    Example:
    template = "Summarise: {{TEXT}}"
    variables = {"TEXT": "The meeting was productive"}
    result = "Summarise: The meeting was productive"
    """
    result = template
    for key, value in variables.items():
        placeholder = f"{{{{{key}}}}}"
        result = result.replace(placeholder, str(value))

    # Check for unfilled placeholders
    import re
    remaining = re.findall(r'{{(w+)}}', result)
    if remaining:
        raise ValueError(f"Missing required variables: {remaining}")

    return result


def lambda_handler(event, context):
    http_method = event.get('requestContext', {}).get('http', {}).get('method', '')

    if http_method == 'OPTIONS':
        return {'statusCode': 200, 'headers': {'Access-Control-Allow-Origin': '*'}, 'body': ''}

    path = event.get('rawPath', '')

    # GET /prompts — list all available prompts
    if http_method == 'GET' and path == '/prompts':
        try:
            response = prompts_table.scan(
                FilterExpression=boto3.dynamodb.conditions.Attr('active').eq(True),
                ProjectionExpression='promptId, version, #n, description, category, passRate, variables',
                ExpressionAttributeNames={'#n': 'name'}
            )
            prompts = response.get('Items', [])
            return {
                'statusCode': 200,
                'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
                'body': json.dumps({
                    'success': True,
                    'prompts': prompts,
                    'count': len(prompts)
                })
            }
        except Exception as e:
            logger.error(f"Error listing prompts: {e}")
            return {'statusCode': 500, 'body': json.dumps({'success': False, 'error': str(e)})}

    # POST /prompts/render — fetch and fill a prompt template
    if http_method == 'POST' and path == '/prompts/render':
        try:
            body = json.loads(event.get('body', '{}'))
            prompt_id = body.get('promptId')
            version = body.get('version')
            variables = body.get('variables', {})

            if not prompt_id:
                return {'statusCode': 400, 'body': json.dumps({'success': False, 'error': 'promptId is required'})}

            prompt = get_prompt(prompt_id, version)
            if not prompt:
                return {'statusCode': 404, 'body': json.dumps({'success': False, 'error': f'Prompt {prompt_id} not found'})}

            filled = fill_template(prompt['template'], variables)

            return {
                'statusCode': 200,
                'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
                'body': json.dumps({
                    'success': True,
                    'promptId': prompt_id,
                    'version': prompt['version'],
                    'renderedPrompt': filled,
                    'metadata': {
                        'name': prompt.get('name'),
                        'category': prompt.get('category'),
                        'passRate': prompt.get('passRate')
                    }
                })
            }
        except ValueError as e:
            return {'statusCode': 400, 'body': json.dumps({'success': False, 'error': str(e)})}
        except Exception as e:
            logger.error(f"Error rendering prompt: {e}")
            return {'statusCode': 500, 'body': json.dumps({'success': False, 'error': 'Internal error'})}

    return {'statusCode': 404, 'body': json.dumps({'success': False, 'error': 'Route not found'})}

Component 3: The Cost Attribution Dashboard

Every team should be able to see their own AI spending in real time. Platform teams need to see spending across all teams.

CloudWatch Dashboard via CLI

bash

# Create a CloudWatch dashboard for the AI platform
aws cloudwatch put-dashboard 
  --dashboard-name "AI-Platform-Cost-Dashboard" 
  --dashboard-body '{
    "widgets": [
      {
        "type": "metric",
        "properties": {
          "title": "Total Daily AI Cost (All Teams) USD",
          "view": "timeSeries",
          "metrics": [
            ["AIPlatform/Usage", "EstimatedCostUSD"]
          ],
          "period": 86400,
          "stat": "Sum",
          "region": "eu-west-1"
        }
      },
      {
        "type": "metric",
        "properties": {
          "title": "Cost by Team (Last 7 Days)",
          "view": "bar",
          "metrics": [
            ["AIPlatform/Usage", "EstimatedCostUSD", "TeamId", "team-customer-service"],
            ["AIPlatform/Usage", "EstimatedCostUSD", "TeamId", "team-internal-tools"]
          ],
          "period": 604800,
          "stat": "Sum",
          "region": "eu-west-1"
        }
      },
      {
        "type": "metric",
        "properties": {
          "title": "Tokens Consumed by Team (Today)",
          "view": "pie",
          "metrics": [
            ["AIPlatform/Usage", "TokensConsumed", "TeamId", "team-customer-service"],
            ["AIPlatform/Usage", "TokensConsumed", "TeamId", "team-internal-tools"]
          ],
          "period": 86400,
          "stat": "Sum",
          "region": "eu-west-1"
        }
      },
      {
        "type": "metric",
        "properties": {
          "title": "P95 Request Latency (ms)",
          "view": "timeSeries",
          "metrics": [
            ["AIPlatform/Usage", "RequestLatencyMs", {"stat": "p95"}]
          ],
          "period": 300,
          "region": "eu-west-1"
        }
      }
    ]
  }' 
  --region $REGION

echo "Dashboard created: https://$REGION.console.aws.amazon.com/cloudwatch/home?region=$REGION#dashboards:name=AI-Platform-Cost-Dashboard"

# Create budget alert — notify when monthly AI spend exceeds $500
aws budgets create-budget 
  --account-id $ACCOUNT_ID 
  --budget '{
    "BudgetName": "AI-Platform-Monthly-Budget",
    "BudgetLimit": {"Amount": "500", "Unit": "USD"},
    "TimeUnit": "MONTHLY",
    "BudgetType": "COST",
    "CostFilters": {
      "Service": ["Amazon Bedrock"]
    }
  }' 
  --notifications-with-subscribers '[
    {
      "Notification": {
        "NotificationType": "ACTUAL",
        "ComparisonOperator": "GREATER_THAN",
        "Threshold": 80,
        "ThresholdType": "PERCENTAGE"
      },
      "Subscribers": [
        {"SubscriptionType": "EMAIL", "Address": "platform-team@yourcompany.com"}
      ]
    }
  ]'

CloudWatch Logs Insights Queries for Cost Analysis

sql

-- Daily cost breakdown by team
fields @timestamp, team_id, project_id, model, cost_usd, total_tokens
| filter event = "request_completed"
| stats sum(cost_usd) as total_cost,
        sum(total_tokens) as total_tokens,
        count(*) as request_count
  by team_id, project_id
| sort total_cost desc

-- Most expensive projects this week
fields team_id, project_id, cost_usd
| filter event = "request_completed"
| filter @timestamp > ago(7d)
| stats sum(cost_usd) as weekly_cost by team_id, project_id
| sort weekly_cost desc
| limit 10

-- Rate limit violations (teams hitting their daily limit)
fields @timestamp, team_id, project_id, tokens_used
| filter event = "rate_limit_exceeded"
| stats count(*) as violations by team_id
| sort violations desc

Component 4: The Evaluation Framework

This is the component most organisations skip, and then they wonder why their AI features degrade over time. An evaluation framework lets you test that your AI is actually producing correct outputs, and alerts you when quality drops.

python

# eval_framework.py
# Run evaluation suites against the AI Gateway

import boto3
import json
import urllib.request
import urllib.error
from datetime import datetime, timezone

cloudwatch = boto3.client('cloudwatch', region_name='eu-west-1')

# Example evaluation suite for a customer service summarisation prompt
EVAL_SUITE = {
    "suiteId": "cs-complaint-summariser-v1",
    "description": "Tests complaint summarisation quality",
    "teamId": "team-customer-service",
    "projectId": "cs-chatbot",
    "cases": [
        {
            "id": "positive-resolution",
            "input": "Customer called to say their issue was resolved perfectly and they are very happy with the service. They specifically praised agent Sarah.",
            "expectedSentiment": "POSITIVE",
            "expectedCategory": "other",
            "mustContain": ["POSITIVE"],
            "mustNotContain": ["NEGATIVE", "CRITICAL"]
        },
        {
            "id": "billing-dispute",
            "input": "I have been charged twice for my subscription this month. This is absolutely unacceptable and I want an immediate refund. I have been a customer for 5 years.",
            "expectedSentiment": "VERY_NEGATIVE",
            "expectedCategory": "billing",
            "mustContain": ["billing", "refund"],
            "mustNotContain": []
        },
        {
            "id": "delivery-issue",
            "input": "My package was supposed to arrive 3 days ago. The tracking shows it has not moved in 5 days. I need this for an event tomorrow.",
            "expectedSentiment": "NEGATIVE",
            "expectedCategory": "delivery",
            "mustContain": ["delivery"],
            "mustNotContain": ["POSITIVE"]
        }
    ]
}


def call_gateway(gateway_url: str, team_id: str, project_id: str, content: str) -> dict:
    """Call the AI Gateway and return the response"""
    payload = json.dumps({
        "messages": [{"role": "user", "content": content}],
        "model": "haiku",
        "maxTokens": 500
    }).encode('utf-8')

    req = urllib.request.Request(
        f"{gateway_url}/production/generate",
        data=payload,
        headers={
            'Content-Type': 'application/json',
            'x-team-id': team_id,
            'x-project-id': project_id
        },
        method='POST'
    )

    with urllib.request.urlopen(req, timeout=30) as response:
        return json.loads(response.read())


def run_eval_suite(gateway_url: str, prompt_template: str, suite: dict) -> dict:
    """
    Run all test cases in an evaluation suite.
    Returns pass/fail results for each case.
    """
    results = []
    total = len(suite['cases'])
    passed = 0

    print(f"n{'='*60}")
    print(f"Running eval suite: {suite['suiteId']}")
    print(f"Total test cases: {total}")
    print(f"{'='*60}n")

    for case in suite['cases']:
        # Build the prompt using the template
        filled_prompt = prompt_template.replace('{{COMPLAINT_TEXT}}', case['input'])

        try:
            response = call_gateway(
                gateway_url,
                suite['teamId'],
                suite['projectId'],
                filled_prompt
            )

            if not response.get('success'):
                results.append({
                    'caseId': case['id'],
                    'passed': False,
                    'reason': f"Gateway error: {response.get('error', 'Unknown error')}"
                })
                print(f" {case['id']}: Gateway error")
                continue

            # Try to parse the AI response as JSON
            try:
                ai_output = json.loads(response['content'])
            except json.JSONDecodeError:
                results.append({
                    'caseId': case['id'],
                    'passed': False,
                    'reason': "AI did not return valid JSON"
                })
                print(f" {case['id']}: Invalid JSON response")
                continue

            # Run assertions
            failures = []
            ai_output_str = json.dumps(ai_output)

            # Check mustContain
            for term in case.get('mustContain', []):
                if term.lower() not in ai_output_str.lower():
                    failures.append(f"Missing expected term: '{term}'")

            # Check mustNotContain
            for term in case.get('mustNotContain', []):
                if term.lower() in ai_output_str.lower():
                    failures.append(f"Found prohibited term: '{term}'")

            # Check expected sentiment
            if 'expectedSentiment' in case:
                actual_sentiment = ai_output.get('sentiment', '')
                if actual_sentiment != case['expectedSentiment']:
                    failures.append(
                        f"Wrong sentiment: expected {case['expectedSentiment']}, got {actual_sentiment}"
                    )

            # Check expected category
            if 'expectedCategory' in case:
                actual_category = ai_output.get('category', '')
                if actual_category != case['expectedCategory']:
                    failures.append(
                        f"Wrong category: expected {case['expectedCategory']}, got {actual_category}"
                    )

            if failures:
                results.append({
                    'caseId': case['id'],
                    'passed': False,
                    'reason': '; '.join(failures),
                    'aiOutput': ai_output
                })
                print(f" {case['id']}: {'; '.join(failures)}")
            else:
                passed += 1
                results.append({
                    'caseId': case['id'],
                    'passed': True,
                    'aiOutput': ai_output
                })
                print(f" {case['id']}: All assertions passed")

        except Exception as e:
            results.append({
                'caseId': case['id'],
                'passed': False,
                'reason': f"Exception: {str(e)}"
            })
            print(f" {case['id']}: Exception — {str(e)}")

    pass_rate = (passed / total * 100) if total > 0 else 0

    print(f"n{'='*60}")
    print(f"Results: {passed}/{total} passed ({pass_rate:.1f}%)")
    print(f"{'='*60}n")

    # Publish eval results to CloudWatch
    try:
        cloudwatch.put_metric_data(
            Namespace='AIPlatform/Evaluation',
            MetricData=[
                {
                    'MetricName': 'EvalPassRate',
                    'Value': pass_rate,
                    'Unit': 'Percent',
                    'Dimensions': [
                        {'Name': 'SuiteId', 'Value': suite['suiteId']},
                        {'Name': 'TeamId', 'Value': suite['teamId']}
                    ]
                },
                {
                    'MetricName': 'EvalPassCount',
                    'Value': passed,
                    'Unit': 'Count',
                    'Dimensions': [{'Name': 'SuiteId', 'Value': suite['suiteId']}]
                }
            ]
        )
    except Exception as e:
        print(f"Warning: Could not publish CloudWatch metrics: {e}")

    return {
        'suiteId': suite['suiteId'],
        'passRate': pass_rate,
        'passed': passed,
        'total': total,
        'timestamp': datetime.now(timezone.utc).isoformat(),
        'results': results
    }


# Run evals
if __name__ == '__main__':
    import sys

    GATEWAY_URL = sys.argv[1] if len(sys.argv) > 1 else 'YOUR_GATEWAY_URL'

    # The prompt template from your prompt library
    COMPLAINT_PROMPT = """You are a customer service analyst. Analyse this customer complaint and respond in JSON:
{
  "summary": "one sentence summary",
  "sentiment": "POSITIVE|NEUTRAL|NEGATIVE|VERY_NEGATIVE",
  "category": "billing|technical|delivery|other",
  "urgency": "LOW|MEDIUM|HIGH|CRITICAL",
  "suggestedAction": "what customer service should do"
}

Complaint: {{COMPLAINT_TEXT}}

Respond with valid JSON only."""

    eval_results = run_eval_suite(GATEWAY_URL, COMPLAINT_PROMPT, EVAL_SUITE)

    if eval_results['passRate'] < 80:
        print(f"  EVAL FAILED: Pass rate {eval_results['passRate']:.1f}% is below 80% threshold")
        print("This prompt version should not be promoted to production")
        sys.exit(1)
    else:
        print(f" EVAL PASSED: Pass rate {eval_results['passRate']:.1f}%")

Run evals:

bash

python3 eval_framework.py $GATEWAY_URL/production

How Teams Use the Platform

Now that the platform is built, here is what it looks like for a developer joining Team B and wanting to build an AI feature:

python

# team_b_developer.py
# How a developer uses the AI platform — no infrastructure knowledge required

import requests

GATEWAY_URL = "https://your-gateway.execute-api.eu-west-1.amazonaws.com/production"
PROMPT_LIBRARY_URL = "https://your-prompts.execute-api.eu-west-1.amazonaws.com/production"

def summarise_meeting(transcript: str) -> dict:
    """
    Use the AI platform to summarise a meeting transcript.
    The developer does not need to know about Bedrock, IAM, or rate limiting.
    They just call the platform.
    """

    # Step 1: Get the meeting summary prompt from the library
    prompt_response = requests.post(
        f"{PROMPT_LIBRARY_URL}/prompts/render",
        json={
            "promptId": "generate-meeting-summary",
            "variables": {"TRANSCRIPT": transcript}
        }
    )
    prompt_data = prompt_response.json()
    rendered_prompt = prompt_data['renderedPrompt']

    # Step 2: Call the AI Gateway with the rendered prompt
    ai_response = requests.post(
        f"{GATEWAY_URL}/generate",
        headers={
            "Content-Type": "application/json",
            "x-team-id": "team-internal-tools",
            "x-project-id": "hr-assistant"
        },
        json={
            "messages": [{"role": "user", "content": rendered_prompt}],
            "model": "haiku",
            "maxTokens": 800
        }
    )
    result = ai_response.json()

    if not result.get('success'):
        raise Exception(f"AI platform error: {result.get('error')}")

    import json
    return json.loads(result['content'])


# Usage — this is all the developer needs to write
transcript = """
John: Let's kick off. We need to decide on the Q3 roadmap.
Sarah: I think we should prioritise the mobile app. Customers keep asking.
John: Agreed. Tom, can you own that?
Tom: Sure, I'll have a plan by next Friday.
Sarah: Great. Also the API latency issue — we need to fix that by end of month.
John: Yes, that's critical. Sarah, can you lead that?
Sarah: Confirmed. Next meeting same time next week.
"""

summary = summarise_meeting(transcript)
print(f"Key decisions: {summary['keyDecisions']}")
print(f"Action items: {summary['actionItems']}")

The developer wrote 20 lines of code to call the AI platform. They did not set up IAM roles. They did not worry about rate limiting. They did not implement error handling. They did not track costs. The platform handles all of that.

That is the point.

What You Have Built and Why It Matters

Let us review what this platform gives every team that connects to it:

Capability

Without Platform

With Platform

Time to first AI feature

2-3 weeks

2-3 days

Rate limiting

Each team builds separately

Centralised, consistent

Cost tracking

Unknown until invoice

Real-time per team

Model access control

Anyone calls anything

Governed by team config

Prompt quality

Ad-hoc, inconsistent

Versioned, tested, shared

Evaluation

Skipped

Automated pass/fail

Security controls

Inconsistent

Applied at gateway level

For an organisation with 5 teams each spending 2 weeks building AI plumbing independently, this platform saves 10 weeks of engineering time, before anyone has written a single line of business logic.

That is the return on investment for platform engineering. Build the infrastructure once. Let every team benefit.

Liked Liked