Audience: Platform engineers and AI infrastructure teams managing AWS Bedrock at enterprise scale.
Problem statement: A Bedrock agent loop left running overnight can consume $50K+ in a single incident. AWS Cost Anomaly Detection fires 24–48 hours after the anomaly starts. This note covers the full detection stack — from sub-minute LiteLLM burn rate to day-old CUR signals — and the automated remediation layer that turns an alert into a stopped workload.
What it actually does
AWS Cost Anomaly Detection (CAD) uses a multi-layered ML model trained on each account’s historical spend patterns. It is not a static threshold monitor. The model learns seasonality (weekday vs. weekend, month-end spikes) and applies anomaly scoring against that learned baseline. An alert fires when actual spend deviates from the expected band by a configured absolute or percentage threshold.
Key distinction: CAD works on Cost and Usage Report (CUR) data, which has a 24–48 hour ingestion lag. For most AWS services, this is acceptable. For Bedrock, where a runaway agent can exhaust a monthly budget in 4 hours, it is not the right first line of defense — it is a backstop for anomalies that slipped past near-real-time detection.
Monitor types
Linked account monitor: Covers all services in a payer/linked account. Useful for detecting unexpected Bedrock charges when Bedrock is not yet a primary workload. Fires on total account spend deviation.
Service monitor: Scoped to a single AWS service. Create a dedicated AWS Bedrock service monitor to isolate Bedrock anomalies from unrelated EC2/RDS noise. This is the correct configuration for any team where Bedrock is an active cost center.
Cost category / tag monitor: Scoped to a custom dimension (e.g., CostCenter=AI-Platform). Requires that cost allocation tags are enforced upstream (see Section 8). Enables anomaly detection at the team or product level rather than the account level.
Recommended configuration
Monitor type: AWS Service — Amazon Bedrock
Threshold type: ABSOLUTE_VALUE + PERCENTAGE (use ABSOLUTE to avoid alert fatigue on small bases)
Alert threshold: $200 absolute OR 50% above baseline (whichever fires first)
Notification: SNS topic → Lambda → Slack + PagerDuty
Frequency: DAILY (IMMEDIATE is not available for service monitors)
The IMMEDIATE notification frequency is only available on linked-account monitors. Service monitors send at most one alert per day. This further reinforces that CAD is a lagging signal.
SNS → Slack/PagerDuty routing
{
"AnomalyMonitorName": "BedrockServiceMonitor",
"AnomalySubscriptionName": "BedrockAnomalyP2Alert",
"MonitorArnList": ["arn:aws:ce::123456789012:anomalymonitor/MONITOR_ID"],
"Subscribers": [
{
"Address": "arn:aws:sns:us-east-1:123456789012:bedrock-cost-alerts",
"Type": "SNS",
"Status": "CONFIRMED"
}
],
"Threshold": 200.0,
"Frequency": "DAILY"
}
The SNS message payload from CAD includes anomalyScore, impact.maxImpact, impact.totalImpact, rootCauses (service, region, usage type, linked account), and anomalyStartDate/anomalyEndDate. A Lambda subscriber can parse rootCauses to identify which Bedrock model or usage type drove the anomaly.
Limitations for Bedrock specifically
| Limitation | Impact |
|---|---|
| 24–48h CUR lag | Alert fires the morning after the incident, not during it |
| Daily notification frequency for service monitors | A 4-hour spike on Monday shows up Tuesday |
| No sub-service granularity | CAD cannot distinguish claude-3-5-sonnet from nova-micro in the anomaly root cause |
| No automated remediation built in | CAD stops at notification; no native action |
| Baseline requires 10+ days of history | New Bedrock accounts have no reliable anomaly model for weeks |
Verdict: Configure CAD as a P2 backstop. Never rely on it as the primary alerting layer for Bedrock.
2. Near-Real-Time Anomaly Detection via LiteLLM
LiteLLM proxies all Bedrock calls and emits a litellm_spend_metric Prometheus counter. Because it is instrumented at the request level, it reflects spend in near-real-time — latency is the Prometheus scrape interval (typically 15–30 seconds), not 24–48 hours.
Prometheus metric structure
# HELP litellm_spend_metric Total spend in USD
# TYPE litellm_spend_metric counter
litellm_spend_metric{
model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
team_id="platform-eng",
user_api_key_alias="agent-prod-01",
call_type="acompletion"
} 142.37
Additional useful metrics:
litellm_input_tokens_metric— input token counter by model/team/keylitellm_output_tokens_metric— output token counterlitellm_requests_metric— request count, useful for loop detectionlitellm_failed_requests_metric— error rate
Grafana burn rate alert
Burn rate is the rate at which spend is accelerating relative to a rolling baseline. The pattern below fires when the 5-minute spend rate is more than 5× the 1-hour rolling average — catching sudden spikes without false-positiving on gradual ramp-ups.
# Grafana alert rule (YAML provisioning format)
apiVersion: 1
groups:
- name: BedrockSpendAnomalies
interval: 1m
rules:
- uid: bedrock-burn-rate-spike
title: "Bedrock Spend Burn Rate Anomaly"
condition: C
data:
- refId: A
queryType: ""
relativeTimeRange:
from: 300
to: 0
datasourceUid: prometheus-ds
model:
expr: rate(litellm_spend_metric[5m]) * 3600
intervalMs: 60000
maxDataPoints: 43200
- refId: B
queryType: ""
relativeTimeRange:
from: 3600
to: 0
datasourceUid: prometheus-ds
model:
expr: rate(litellm_spend_metric[1h]) * 3600
intervalMs: 60000
maxDataPoints: 43200
- refId: C
type: classic_conditions
datasourceUid: "-100"
model:
conditions:
- evaluator:
type: gt
params: [5]
operator:
type: and
query:
params: [A]
reducer:
type: last
type: query
noDataState: NoData
execErrState: Alerting
for: 5m
annotations:
summary: "Bedrock spend rate is {{ $values.A.Value | printf \"%.2f\" }} USD/hr (baseline {{ $values.B.Value | printf \"%.2f\" }} USD/hr)"
labels:
severity: critical
team: platform-eng
For the burn rate ratio alert (fires when current rate > N × baseline):
# Burn rate ratio — fires when 5m rate > 5x the 1h average
(
rate(litellm_spend_metric[5m])
/
rate(litellm_spend_metric[1h])
) > 5
Python burn rate calculation against LiteLLM SpendLogs
For teams running scheduled anomaly scans against the LiteLLM database rather than Prometheus:
"""
litellm_burn_rate.py
Queries LiteLLM SpendLogs table and computes rolling burn rate.
Fires alert if current period spend > BURN_RATE_MULTIPLIER × baseline period.
Requires: sqlalchemy, psycopg2, boto3 (for SNS alerting)
"""
import os
import json
from datetime import datetime, timedelta, timezone
from typing import Optional
import boto3
from sqlalchemy import create_engine, text
# Configuration
DB_URL = os.environ["LITELLM_DB_URL"] # postgresql://user:pass@host/litellm
SNS_TOPIC_ARN = os.environ["ALERT_SNS_TOPIC_ARN"]
BURN_RATE_MULTIPLIER = float(os.environ.get("BURN_RATE_MULTIPLIER", "5.0"))
CURRENT_WINDOW_MINUTES = int(os.environ.get("CURRENT_WINDOW_MINUTES", "15"))
BASELINE_WINDOW_MINUTES = int(os.environ.get("BASELINE_WINDOW_MINUTES", "60"))
MIN_BASELINE_SPEND = float(os.environ.get("MIN_BASELINE_SPEND", "1.0")) # avoid div/0 on idle
def get_spend_in_window(engine, start: datetime, end: datetime) -> float:
"""Sum spend from LiteLLM SpendLogs in the given time window."""
query = text("""
SELECT COALESCE(SUM(spend), 0.0) as total_spend
FROM "LiteLLM_SpendLogs"
WHERE startTime >= :start
AND startTime < :end
""")
with engine.connect() as conn:
result = conn.execute(query, {"start": start, "end": end})
return float(result.scalar())
def get_spend_by_model(engine, start: datetime, end: datetime) -> list[dict]:
"""Breakdown by model for anomaly context."""
query = text("""
SELECT model, COALESCE(SUM(spend), 0.0) as spend, COUNT(*) as requests
FROM "LiteLLM_SpendLogs"
WHERE startTime >= :start
AND startTime < :end
GROUP BY model
ORDER BY spend DESC
LIMIT 10
""")
with engine.connect() as conn:
result = conn.execute(query, {"start": start, "end": end})
return [{"model": row.model, "spend": float(row.spend), "requests": int(row.requests)}
for row in result]
def check_burn_rate_anomaly() -> Optional[dict]:
"""
Returns anomaly dict if burn rate exceeds threshold, else None.
"""
engine = create_engine(DB_URL)
now = datetime.now(timezone.utc)
current_start = now - timedelta(minutes=CURRENT_WINDOW_MINUTES)
baseline_start = now - timedelta(minutes=BASELINE_WINDOW_MINUTES)
current_spend = get_spend_in_window(engine, current_start, now)
baseline_spend = get_spend_in_window(engine, baseline_start, current_start)
# Normalize to per-minute rate
current_rate = current_spend / CURRENT_WINDOW_MINUTES
baseline_rate = baseline_spend / BASELINE_WINDOW_MINUTES
if baseline_rate < (MIN_BASELINE_SPEND / BASELINE_WINDOW_MINUTES):
# Not enough baseline activity — skip to avoid spurious alerts on cold start
return None
ratio = current_rate / baseline_rate
if ratio >= BURN_RATE_MULTIPLIER:
model_breakdown = get_spend_by_model(engine, current_start, now)
return {
"anomaly_type": "BURN_RATE_SPIKE",
"current_rate_per_min": round(current_rate, 4),
"baseline_rate_per_min": round(baseline_rate, 4),
"ratio": round(ratio, 2),
"current_window_spend": round(current_spend, 4),
"current_window_minutes": CURRENT_WINDOW_MINUTES,
"baseline_window_minutes": BASELINE_WINDOW_MINUTES,
"model_breakdown": model_breakdown,
"detected_at": now.isoformat(),
}
return None
def send_sns_alert(anomaly: dict) -> None:
sns = boto3.client("sns")
subject = f"[BEDROCK COST ANOMALY] Burn rate {anomaly['ratio']}x baseline"
message = json.dumps(anomaly, indent=2)
sns.publish(TopicArn=SNS_TOPIC_ARN, Subject=subject, Message=message)
print(f"Alert sent: {subject}")
def lambda_handler(event, context):
"""Entry point for EventBridge-scheduled Lambda (run every 5 minutes)."""
anomaly = check_burn_rate_anomaly()
if anomaly:
send_sns_alert(anomaly)
return {"status": "ALERT_SENT", "anomaly": anomaly}
return {"status": "OK"}
if __name__ == "__main__":
anomaly = check_burn_rate_anomaly()
if anomaly:
print(json.dumps(anomaly, indent=2))
send_sns_alert(anomaly)
else:
print("No anomaly detected.")
Why LiteLLM is the right first line of defense:
| Dimension | LiteLLM Prometheus | AWS Cost Anomaly Detection |
|---|---|---|
| Detection lag | 15–30 seconds (scrape interval) | 24–48 hours (CUR lag) |
| Granularity | Per-model, per-key, per-team | Service-level, limited root cause |
| Remediation integration | Direct DB/API access to kill keys | Notification only |
| Cost | Included in LiteLLM proxy overhead | Free (no per-alert charge) |
| False positive tuning | Configurable multiplier | ML-controlled, limited tuning |
3. CloudWatch Metric-Based Anomaly Detection
CloudWatch provides two complementary mechanisms: static threshold alarms and ML-based anomaly detection bands. For Bedrock, both are valuable at different layers.
Bedrock CloudWatch metrics (2026)
| Metric | Namespace | Dimensions | Cost signal |
|---|---|---|---|
InputTokenCount |
AWS/Bedrock |
ModelId, Region | Direct — input cost driver |
OutputTokenCount |
AWS/Bedrock |
ModelId, Region | Direct — output cost driver (higher) |
InvocationThrottles |
AWS/Bedrock |
ModelId, Region | Indirect — retry storms inflate cost |
InvocationLatency |
AWS/Bedrock |
ModelId, Region | Indirect — high latency → long agent loops |
InvocationsPerMinute |
AWS/Bedrock |
ModelId, Region | Volume proxy |
CloudWatch Anomaly Detection alarm (JSON)
The following CloudFormation-compatible alarm JSON uses ANOMALY_DETECTION_BAND to create ML-based upper and lower bounds. It fires when OutputTokenCount exceeds the predicted band by a configurable standard deviation multiplier.
{
"Type": "AWS::CloudWatch::Alarm",
"Properties": {
"AlarmName": "Bedrock-OutputTokenCount-Anomaly-claude-sonnet",
"AlarmDescription": "Output token count exceeds ML anomaly band for claude-3-5-sonnet. Possible runaway agent or prompt injection.",
"Metrics": [
{
"Id": "m1",
"MetricStat": {
"Metric": {
"Namespace": "AWS/Bedrock",
"MetricName": "OutputTokenCount",
"Dimensions": [
{
"Name": "ModelId",
"Value": "anthropic.claude-3-5-sonnet-20241022-v2:0"
}
]
},
"Period": 300,
"Stat": "Sum"
},
"ReturnData": true
},
{
"Id": "ad1",
"Expression": "ANOMALY_DETECTION_BAND(m1, 3)",
"Label": "OutputTokenCount (expected)",
"ReturnData": true
}
],
"ComparisonOperator": "GreaterThanUpperThreshold",
"ThresholdMetricId": "ad1",
"EvaluationPeriods": 2,
"TreatMissingData": "notBreaching",
"AlarmActions": [
"arn:aws:sns:us-east-1:123456789012:bedrock-cost-alerts"
],
"OKActions": [
"arn:aws:sns:us-east-1:123456789012:bedrock-cost-alerts"
]
}
}
The ANOMALY_DETECTION_BAND(m1, 3) expression sets the band at ±3 standard deviations from the ML-predicted baseline. A value of 2 is more sensitive (more alerts); 3 is appropriate for production cost alerting (fewer false positives).
InvocationThrottles spike detection (static threshold)
Throttle spikes indicate retry storms — the agent retries at full cost after each throttle. Use a static threshold here since anomaly detection on throttles is less meaningful.
{
"Type": "AWS::CloudWatch::Alarm",
"Properties": {
"AlarmName": "Bedrock-InvocationThrottles-Spike",
"AlarmDescription": "Throttle count exceeds 50 in 5 minutes. Possible retry storm — check agent loop configuration.",
"Namespace": "AWS/Bedrock",
"MetricName": "InvocationThrottles",
"Dimensions": [
{ "Name": "ModelId", "Value": "anthropic.claude-3-5-sonnet-20241022-v2:0" }
],
"Statistic": "Sum",
"Period": 300,
"EvaluationPeriods": 1,
"Threshold": 50,
"ComparisonOperator": "GreaterThanOrEqualToThreshold",
"TreatMissingData": "notBreaching",
"AlarmActions": [
"arn:aws:sns:us-east-1:123456789012:bedrock-cost-alerts"
]
}
}
InvocationLatency degradation as indirect cost signal
High latency on long-context models correlates with expensive completions. An alarm on P99 latency above 30 seconds is a useful early warning for unexpectedly large prompts or runaway tool-call loops.
{
"Type": "AWS::CloudWatch::Alarm",
"Properties": {
"AlarmName": "Bedrock-InvocationLatency-P99-High",
"AlarmDescription": "P99 invocation latency above 30s. Possible large context window or runaway agent turn.",
"Namespace": "AWS/Bedrock",
"MetricName": "InvocationLatency",
"Dimensions": [
{ "Name": "ModelId", "Value": "anthropic.claude-3-5-sonnet-20241022-v2:0" }
],
"ExtendedStatistic": "p99",
"Period": 300,
"EvaluationPeriods": 3,
"Threshold": 30000,
"ComparisonOperator": "GreaterThanThreshold",
"TreatMissingData": "notBreaching",
"AlarmActions": [
"arn:aws:sns:us-east-1:123456789012:bedrock-cost-alerts"
]
}
}
CloudWatch Anomaly Detection ML bands vs. static thresholds
| Approach | Best for | Limitation |
|---|---|---|
ANOMALY_DETECTION_BAND |
Metrics with clear seasonality (weekday/weekend, business hours) | Requires 2 weeks of history to build model; poor on new deployments |
| Static threshold | Metrics with no seasonal pattern (throttle counts, hard error rates) | Requires manual recalibration as usage grows |
| Combination | Anomaly detection for normal-operation bounds + static for absolute floors | Higher alarm count and cost |
Cost of CloudWatch alarms for Bedrock
Each CloudWatch alarm costs approximately $0.10/alarm/month in us-east-1 (2026 pricing). An anomaly detection alarm costs $0.10 + $0.30 for the anomaly detection model = $0.40/alarm/month. A complete Bedrock alarm set covering 5 models × 4 metrics = 20 alarms ≈ $6–8/month. This is negligible relative to the cost of a single missed anomaly.
4. EventBridge + Lambda Automated Remediation
Detection without remediation is a notification system. The patterns below close the loop — an anomaly alert becomes an automated action within seconds, not minutes.
Architecture overview
LiteLLM Prometheus Alert ──┐
CloudWatch Alarm ├──► SNS Topic (bedrock-cost-alerts)
AWS Cost Anomaly Detection ──┘ │
▼
EventBridge Rule
(or SNS → Lambda direct)
│
┌──────────┼──────────┐
▼ ▼ ▼
Lambda A Lambda B Lambda C
(hard stop) (TPM reduce) (circuit break)
Pattern 1: Budget breach → Lambda → LiteLLM team budget to $0 (hard stop)
"""
lambda_hard_stop.py
Triggered by AWS Budgets SNS notification when Bedrock budget is exceeded.
Sets the offending LiteLLM team's budget to $0 to halt all spending immediately.
"""
import json
import os
import urllib.request
import urllib.error
LITELLM_BASE_URL = os.environ["LITELLM_BASE_URL"] # e.g. http://litellm.internal:4000
LITELLM_MASTER_KEY = os.environ["LITELLM_MASTER_KEY"]
DEFAULT_TEAM_ID = os.environ.get("DEFAULT_TEAM_ID", "") # fallback team
def set_team_budget_zero(team_id: str) -> dict:
"""Set LiteLLM team max_budget to 0 (hard stop all API calls for this team)."""
url = f"{LITELLM_BASE_URL}/team/update"
payload = json.dumps({
"team_id": team_id,
"max_budget": 0.0,
"budget_duration": None, # no reset — stay stopped until manual intervention
"metadata": {
"stopped_by": "lambda_hard_stop",
"reason": "budget_breach_auto_remediation",
}
}).encode()
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {LITELLM_MASTER_KEY}",
},
method="PATCH",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def extract_team_id_from_sns(event: dict) -> str:
"""
Parse team_id from SNS message.
Expects either a LiteLLM burn-rate alert (has team_id in model_breakdown)
or a Budgets notification (use DEFAULT_TEAM_ID or tag-derived value).
"""
try:
message = json.loads(event["Records"][0]["Sns"]["Message"])
# LiteLLM burn rate alert format
if "model_breakdown" in message and message.get("model_breakdown"):
return message.get("team_id", DEFAULT_TEAM_ID)
# AWS Budgets format — use configured default
return DEFAULT_TEAM_ID
except (KeyError, json.JSONDecodeError):
return DEFAULT_TEAM_ID
def lambda_handler(event, context):
print(f"Received event: {json.dumps(event)}")
team_id = extract_team_id_from_sns(event)
if not team_id:
print("ERROR: No team_id found and DEFAULT_TEAM_ID not set. Cannot remediate.")
return {"status": "ERROR", "reason": "no_team_id"}
print(f"Hard-stopping team: {team_id}")
result = set_team_budget_zero(team_id)
print(f"LiteLLM response: {result}")
return {
"status": "STOPPED",
"team_id": team_id,
"litellm_response": result,
}
Pattern 2: Anomaly alert → Lambda → reduce TPM quota on virtual key
"""
lambda_tpm_throttle.py
Triggered by CloudWatch OutputTokenCount anomaly alarm.
Reduces TPM (tokens per minute) limit on active virtual keys for the affected model.
More surgical than a hard stop — production traffic degrades gracefully rather than halting.
"""
import json
import os
import urllib.request
from typing import Optional
LITELLM_BASE_URL = os.environ["LITELLM_BASE_URL"]
LITELLM_MASTER_KEY = os.environ["LITELLM_MASTER_KEY"]
TPM_REDUCTION_FACTOR = float(os.environ.get("TPM_REDUCTION_FACTOR", "0.25")) # reduce to 25%
def list_active_keys(model: Optional[str] = None) -> list[dict]:
"""List virtual keys, optionally filtered by model."""
url = f"{LITELLM_BASE_URL}/key/list?include_team_keys=true"
req = urllib.request.Request(
url,
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
keys = data.get("keys", [])
if model:
keys = [k for k in keys if model in (k.get("models") or [])]
return keys
def reduce_key_tpm(key_hash: str, current_tpm: int, factor: float) -> dict:
"""Reduce a key's TPM limit to factor × current_tpm."""
new_tpm = max(int(current_tpm * factor), 1000) # floor at 1K TPM to avoid full stop
url = f"{LITELLM_BASE_URL}/key/update"
payload = json.dumps({
"key": key_hash,
"tpm_limit": new_tpm,
"metadata": {
"auto_throttled": True,
"original_tpm": current_tpm,
"throttle_reason": "output_token_anomaly",
}
}).encode()
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {LITELLM_MASTER_KEY}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def parse_model_from_alarm(event: dict) -> Optional[str]:
"""Extract ModelId from CloudWatch alarm SNS message."""
try:
message = json.loads(event["Records"][0]["Sns"]["Message"])
trigger = message.get("Trigger", {})
dims = trigger.get("Dimensions", [])
for dim in dims:
if dim.get("name") == "ModelId":
return dim.get("value")
except (KeyError, json.JSONDecodeError):
pass
return None
def lambda_handler(event, context):
print(f"Received event: {json.dumps(event)}")
model = parse_model_from_alarm(event)
print(f"Affected model: {model or 'all models'}")
keys = list_active_keys(model=model)
throttled = []
for key in keys:
current_tpm = key.get("tpm_limit") or 0
if current_tpm > 0:
key_hash = key.get("key_name") or key.get("token")
result = reduce_key_tpm(key_hash, current_tpm, TPM_REDUCTION_FACTOR)
throttled.append({"key": key_hash, "original_tpm": current_tpm, "result": result})
print(f"Throttled {len(throttled)} keys for model {model}")
return {"status": "THROTTLED", "model": model, "keys_affected": len(throttled)}
Pattern 3: Runaway agent loop detection → Lambda → disable API key
"""
lambda_kill_key.py
Triggered when agent-loop runaway signature is detected (see Section 5).
Disables the specific API key associated with the runaway agent.
"""
import json
import os
import urllib.request
LITELLM_BASE_URL = os.environ["LITELLM_BASE_URL"]
LITELLM_MASTER_KEY = os.environ["LITELLM_MASTER_KEY"]
def disable_key(key_hash: str, reason: str) -> dict:
"""Set key status to 'inactive' — all subsequent calls with this key return 403."""
url = f"{LITELLM_BASE_URL}/key/update"
payload = json.dumps({
"key": key_hash,
"key_alias": None,
"blocked": True, # LiteLLM 'blocked' flag prevents all usage
"metadata": {
"disabled_by": "lambda_kill_key",
"reason": reason,
}
}).encode()
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {LITELLM_MASTER_KEY}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def lambda_handler(event, context):
"""
Expected event format (from EventBridge custom event or SNS):
{
"key_hash": "sk-...",
"reason": "agent_loop_runaway",
"detected_at": "2026-06-18T10:23:00Z",
"evidence": { ... }
}
"""
print(f"Received kill-key request: {json.dumps(event)}")
# Handle both direct invocation and SNS wrapping
if "Records" in event:
payload = json.loads(event["Records"][0]["Sns"]["Message"])
else:
payload = event
key_hash = payload.get("key_hash")
reason = payload.get("reason", "automated_anomaly_remediation")
if not key_hash:
return {"status": "ERROR", "reason": "no_key_hash_in_event"}
print(f"Disabling key: {key_hash}, reason: {reason}")
result = disable_key(key_hash, reason)
return {
"status": "KEY_DISABLED",
"key_hash": key_hash,
"reason": reason,
"litellm_response": result,
}
Pattern 4: Circuit breaker — SNS → SQS → Lambda with exponential backoff before re-enabling
The circuit breaker pattern prevents the “alert → remediate → re-enable → alert” thrash cycle. After a hard stop, the system waits with exponential backoff before testing whether the condition has cleared.
"""
lambda_circuit_breaker.py
Implements a circuit breaker with states: CLOSED (normal) → OPEN (stopped) → HALF_OPEN (testing).
Uses SQS message visibility timeout as the backoff mechanism.
SSM Parameter Store holds circuit state.
Flow:
1. Anomaly detected → send message to circuit-breaker SQS queue
2. Lambda triggered → opens circuit (disables team/key), sets state in SSM
3. SQS message re-queued with visibility timeout = backoff_seconds
4. After backoff, message reappears → Lambda checks current spend rate
5. If spend rate is normal → closes circuit (re-enables), resets backoff
6. If spend rate is still anomalous → opens circuit again, doubles backoff
"""
import json
import os
import time
import math
import urllib.request
from datetime import datetime, timezone
import boto3
LITELLM_BASE_URL = os.environ["LITELLM_BASE_URL"]
LITELLM_MASTER_KEY = os.environ["LITELLM_MASTER_KEY"]
SSM_PREFIX = os.environ.get("SSM_CIRCUIT_PREFIX", "/bedrock/circuit-breaker")
SQS_QUEUE_URL = os.environ["CIRCUIT_BREAKER_QUEUE_URL"]
MAX_BACKOFF_SECONDS = int(os.environ.get("MAX_BACKOFF_SECONDS", "3600")) # 1 hour max
BASE_BACKOFF_SECONDS = int(os.environ.get("BASE_BACKOFF_SECONDS", "60"))
ssm = boto3.client("ssm")
sqs = boto3.client("sqs")
def get_circuit_state(team_id: str) -> dict:
param_name = f"{SSM_PREFIX}/{team_id}"
try:
resp = ssm.get_parameter(Name=param_name)
return json.loads(resp["Parameter"]["Value"])
except ssm.exceptions.ParameterNotFound:
return {"state": "CLOSED", "attempt": 0, "opened_at": None}
def set_circuit_state(team_id: str, state: dict) -> None:
param_name = f"{SSM_PREFIX}/{team_id}"
ssm.put_parameter(
Name=param_name,
Value=json.dumps(state),
Type="String",
Overwrite=True,
)
def get_current_spend_rate(team_id: str) -> float:
"""Query LiteLLM for team's current spend in last 5 minutes."""
url = f"{LITELLM_BASE_URL}/team/info?team_id={team_id}"
req = urllib.request.Request(
url,
headers={"Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
# spend_5m is a custom metric — requires LiteLLM 1.40+ with spend window tracking
return float(data.get("team_info", {}).get("spend_5m", 0.0))
def set_team_budget(team_id: str, budget: float) -> None:
url = f"{LITELLM_BASE_URL}/team/update"
payload = json.dumps({"team_id": team_id, "max_budget": budget}).encode()
req = urllib.request.Request(
url, data=payload,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {LITELLM_MASTER_KEY}"},
method="PATCH",
)
urllib.request.urlopen(req, timeout=10)
def compute_backoff(attempt: int) -> int:
"""Exponential backoff: base * 2^attempt, capped at max."""
return min(BASE_BACKOFF_SECONDS * (2 ** attempt), MAX_BACKOFF_SECONDS)
def lambda_handler(event, context):
for record in event.get("Records", []):
receipt_handle = record["receiptHandle"]
message = json.loads(record["body"])
team_id = message["team_id"]
normal_budget = float(message.get("normal_budget", 100.0))
circuit = get_circuit_state(team_id)
state = circuit["state"]
if state == "CLOSED":
# Anomaly just detected — open the circuit
print(f"[{team_id}] CLOSED → OPEN: stopping spend")
set_team_budget(team_id, 0.0)
circuit = {"state": "OPEN", "attempt": 0, "opened_at": datetime.now(timezone.utc).isoformat()}
set_circuit_state(team_id, circuit)
# Re-queue with initial backoff
backoff = compute_backoff(0)
sqs.change_message_visibility(
QueueUrl=SQS_QUEUE_URL,
ReceiptHandle=receipt_handle,
VisibilityTimeout=backoff,
)
print(f"[{team_id}] Re-queued with {backoff}s backoff (attempt 0)")
elif state == "OPEN":
# Backoff expired — test if condition cleared (HALF_OPEN)
attempt = circuit["attempt"] + 1
spend_rate = get_current_spend_rate(team_id)
threshold = message.get("spend_threshold", 0.5) # USD per 5 minutes
print(f"[{team_id}] HALF_OPEN test: spend_rate={spend_rate:.4f}, threshold={threshold}")
if spend_rate <= threshold:
# Condition cleared — close circuit and restore budget
print(f"[{team_id}] HALF_OPEN → CLOSED: restoring budget to {normal_budget}")
set_team_budget(team_id, normal_budget)
circuit = {"state": "CLOSED", "attempt": 0, "opened_at": None}
set_circuit_state(team_id, circuit)
# Delete message — circuit is closed
sqs.delete_message(QueueUrl=SQS_QUEUE_URL, ReceiptHandle=receipt_handle)
else:
# Still anomalous — keep OPEN, double the backoff
backoff = compute_backoff(attempt)
print(f"[{team_id}] Still anomalous. Re-opening with {backoff}s backoff (attempt {attempt})")
circuit = {**circuit, "state": "OPEN", "attempt": attempt}
set_circuit_state(team_id, circuit)
sqs.change_message_visibility(
QueueUrl=SQS_QUEUE_URL,
ReceiptHandle=receipt_handle,
VisibilityTimeout=backoff,
)
return {"status": "processed"}
5. Bedrock-Specific Anomaly Patterns
These signatures are the result of real-world Bedrock incidents. Each pattern includes the token-level fingerprint used to distinguish it from legitimate traffic changes.
Pattern A: Agent loop runaway
What happens: An agent calls tools repeatedly (e.g., a search tool that returns results that trigger more searches) without reaching a stop condition. Each iteration adds input tokens (the growing context) and output tokens (the reasoning + tool call).
Token signature:
OutputTokenCount: rising monotonically over rolling 15-minute windowInputTokenCount: also rising (context accumulates each turn)InvocationsPerMinute: stable or rising — regular cadence from the loopInvocationLatency: may decrease per call as context grows and generations get shorter (the model trying to resolve the loop)
Detection query (Prometheus):
# Rising output token rate over 15m window — flag if slope is positive and steep
deriv(litellm_output_tokens_metric[15m]) > 1000
Remediation: Kill the specific API key associated with the agent (lambda_kill_key.py).
Pattern B: Prompt injection attack
What happens: External data ingested by an agent contains injected instructions causing the model to produce unexpectedly long completions or call unintended tools.
Token signature:
OutputTokenCount: sudden spike on a specifickey_hashandmodelInputTokenCount: relatively stable (the injected payload may be small)- Spike is isolated to one key, not across the team
- Timing correlates with ingestion of external data source
Detection query:
# Per-key output token spike relative to that key's own baseline
(
rate(litellm_output_tokens_metric{user_api_key_alias="agent-prod-01"}[5m])
/
rate(litellm_output_tokens_metric{user_api_key_alias="agent-prod-01"}[1h] offset 5m)
) > 10
Remediation: Disable key + alert security team. This is a security incident, not just a cost incident.
Pattern C: Credential leak / unauthorized use
What happens: A Bedrock API key or IAM role credential is leaked (e.g., committed to a public repo). External parties begin calling Bedrock using the compromised credential.
Token signature in CUR:
- Usage from unexpected
lineItem/ResourceIdor IAM principal (visible in CURidentity/PrincipalId) user:agenttag absent (legitimate internal callers set it; external callers do not)- Geographic anomaly in
product/region(calls from regions not in approved list)
Detection: Athena CUR query (see Section 6, query C).
Remediation: Immediate IAM key rotation + SCPs (see Section 8).
Pattern D: Cache miss storm
What happens: Prompt caching is disabled, TTL is set too short, or a deploy changes cache key structure — causing all requests that were previously cache hits to become full model calls.
Token signature:
litellm_input_tokens_metricrises sharply (cache reads returned no tokens; now full input is processed)- LiteLLM
cache_read_input_tokensmetric drops to near zero - LiteLLM
cache_creation_input_tokensspikes (every request writes a new cache entry) - Cost per request returns to full rate instead of cache-read rate (~10% of full)
Detection query:
# Cache hit ratio collapses — flag when cache_read drops below 20% of prior ratio
(
rate(litellm_cache_read_input_tokens_metric[5m])
/
(rate(litellm_cache_read_input_tokens_metric[5m]) + rate(litellm_input_tokens_metric[5m]))
) < 0.2
Remediation: Roll back the deploy that changed cache configuration. Budget impact can be 5–10× until resolved.
Pattern E: Model routing misconfiguration
What happens: A configuration change routes requests intended for nova-micro ($0.035/$0.14 per million tokens) to claude-3-5-sonnet ($3/$15 per million tokens) — a 20–100× cost multiplier.
Token signature:
litellm_spend_metricforclaude-3-5-sonnetspikes whilenova-microdrops to zero- Request count stays flat (same traffic, wrong model)
- Cost-per-request jumps 10–100× depending on which models are swapped
Detection query:
# Model usage distribution shifts — flag when a model that had near-zero share suddenly has >50%
(
rate(litellm_requests_metric{model="bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0"}[5m])
/
rate(litellm_requests_metric[5m])
) > 0.5
# ...while that model had < 5% share previously
Remediation: Revert LiteLLM router configuration. This is a deploy-triggered incident — add model distribution alerting to the deploy pipeline.
6. Athena Anomaly Detection Queries
These queries run against the AWS Cost and Usage Report (CUR) Athena table. Replace your_cur_database.your_cur_table with your actual CUR table name. All queries assume CUR 2.0 column names.
Query A: Day-over-day spend spike (>3× rolling 7-day average)
-- Detect Bedrock service days where spend is >3x the preceding 7-day rolling average.
-- Run daily as a scheduled Athena query; results feed a CloudWatch custom metric or SNS alert.
WITH daily_spend AS (
SELECT
DATE(line_item_usage_start_date) AS usage_date,
SUM(line_item_unblended_cost) AS daily_cost
FROM your_cur_database.your_cur_table
WHERE product_service_code = 'AmazonBedrock'
AND line_item_line_item_type IN ('Usage', 'SavingsPlanCoveredUsage')
AND line_item_usage_start_date >= DATE_ADD('day', -30, CURRENT_DATE)
GROUP BY DATE(line_item_usage_start_date)
),
rolling_avg AS (
SELECT
usage_date,
daily_cost,
AVG(daily_cost) OVER (
ORDER BY usage_date
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
) AS rolling_7d_avg
FROM daily_spend
)
SELECT
usage_date,
ROUND(daily_cost, 2) AS daily_cost_usd,
ROUND(rolling_7d_avg, 2) AS rolling_7d_avg_usd,
ROUND(daily_cost / NULLIF(rolling_7d_avg, 0), 2) AS spend_ratio
FROM rolling_avg
WHERE daily_cost > 3.0 * NULLIF(rolling_7d_avg, 0)
AND rolling_7d_avg IS NOT NULL
ORDER BY usage_date DESC;
Query B: Unexpected model appearing in usage
-- Find model IDs that appeared in the last 7 days but were absent in the prior 30 days.
-- Catches model routing misconfigurations (Pattern E) that persist into CUR.
WITH recent_models AS (
SELECT DISTINCT
resource_tags_user_model_id AS model_id
FROM your_cur_database.your_cur_table
WHERE product_service_code = 'AmazonBedrock'
AND line_item_usage_start_date >= DATE_ADD('day', -7, CURRENT_DATE)
AND resource_tags_user_model_id IS NOT NULL
),
historical_models AS (
SELECT DISTINCT
resource_tags_user_model_id AS model_id
FROM your_cur_database.your_cur_table
WHERE product_service_code = 'AmazonBedrock'
AND line_item_usage_start_date >= DATE_ADD('day', -37, CURRENT_DATE)
AND line_item_usage_start_date < DATE_ADD('day', -7, CURRENT_DATE)
AND resource_tags_user_model_id IS NOT NULL
)
SELECT
r.model_id,
'NEW_MODEL_FIRST_SEEN_IN_LAST_7_DAYS' AS anomaly_type
FROM recent_models r
LEFT JOIN historical_models h ON r.model_id = h.model_id
WHERE h.model_id IS NULL
ORDER BY r.model_id;
Query C: IAM principal with first-time Bedrock usage
-- Identify IAM principals that used Bedrock in the last 24 hours for the first time ever.
-- High-priority signal for credential leak (Pattern C) or unauthorized access.
WITH recent_principals AS (
SELECT DISTINCT
identity_principal_id AS principal_id,
MIN(line_item_usage_start_date) AS first_seen_in_recent
FROM your_cur_database.your_cur_table
WHERE product_service_code = 'AmazonBedrock'
AND line_item_usage_start_date >= DATE_ADD('day', -1, CURRENT_DATE)
GROUP BY identity_principal_id
),
historical_principals AS (
SELECT DISTINCT
identity_principal_id AS principal_id
FROM your_cur_database.your_cur_table
WHERE product_service_code = 'AmazonBedrock'
AND line_item_usage_start_date < DATE_ADD('day', -1, CURRENT_DATE)
)
SELECT
r.principal_id,
r.first_seen_in_recent,
'FIRST_TIME_BEDROCK_PRINCIPAL' AS anomaly_type
FROM recent_principals r
LEFT JOIN historical_principals h ON r.principal_id = h.principal_id
WHERE h.principal_id IS NULL
ORDER BY r.first_seen_in_recent DESC;
Query D: Sudden cache hit rate collapse
-- Compare cache hit ratio (cache_read tokens / total input tokens) across days.
-- A drop >50% relative to the prior 7-day average signals a cache miss storm (Pattern D).
-- Requires CUR usage type fields to distinguish cache read vs. standard invocation.
WITH daily_cache AS (
SELECT
DATE(line_item_usage_start_date) AS usage_date,
SUM(CASE WHEN line_item_usage_type LIKE '%CacheRead%'
THEN line_item_usage_amount ELSE 0 END) AS cache_read_tokens,
SUM(CASE WHEN line_item_usage_type NOT LIKE '%Cache%'
THEN line_item_usage_amount ELSE 0 END) AS standard_tokens,
SUM(line_item_usage_amount) AS total_tokens
FROM your_cur_database.your_cur_table
WHERE product_service_code = 'AmazonBedrock'
AND line_item_usage_start_date >= DATE_ADD('day', -14, CURRENT_DATE)
GROUP BY DATE(line_item_usage_start_date)
),
cache_ratio AS (
SELECT
usage_date,
ROUND(cache_read_tokens * 100.0 / NULLIF(total_tokens, 0), 1) AS cache_hit_pct,
AVG(cache_read_tokens * 100.0 / NULLIF(total_tokens, 0)) OVER (
ORDER BY usage_date
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
) AS rolling_7d_cache_hit_pct
FROM daily_cache
)
SELECT
usage_date,
cache_hit_pct,
ROUND(rolling_7d_cache_hit_pct, 1) AS baseline_cache_hit_pct,
ROUND((rolling_7d_cache_hit_pct - cache_hit_pct) / NULLIF(rolling_7d_cache_hit_pct, 0) * 100, 1) AS pct_drop
FROM cache_ratio
WHERE cache_hit_pct < rolling_7d_cache_hit_pct * 0.5 -- >50% relative drop
AND rolling_7d_cache_hit_pct IS NOT NULL
ORDER BY usage_date DESC;
7. Cost Anomaly Playbook Integration
Anomaly alerts must map to incident severity classifications to ensure the right response cadence and ownership. The following table connects the detection signals above to a P1/P2/P3 framework compatible with standard on-call runbooks.
Severity classification
| Severity | Trigger condition | Response time | Owner |
|---|---|---|---|
| P1 | Burn rate >10× baseline OR projected daily spend >$10K OR credential leak signal | 30 minutes | On-call platform engineer + FinOps lead |
| P2 | Burn rate 5–10× baseline OR unexpected model detected OR agent loop runaway | 2 hours | Platform engineering team |
| P3 | AWS CAD alert (24-48h lag) OR cache hit rate collapse without active cost spike | Next business day | FinOps team |
30-minute P1 runbook
T+0:00 Alert fires (LiteLLM burn rate OR CloudWatch anomaly alarm)
T+0:01 PagerDuty pages on-call engineer
T+0:05 Engineer acknowledges. Opens Grafana dashboard: litellm_spend_metric by model/key
T+0:08 Identifies anomalous key_hash or team_id from Grafana breakdown
T+0:10 Executes automated remediation (lambda_hard_stop or lambda_kill_key) OR
manually calls: POST /key/update {"key": "<hash>", "blocked": true}
T+0:12 Confirms spend rate drops in Grafana (rate(litellm_spend_metric[1m]) → 0)
T+0:15 Opens Athena: runs Query A + Query C to assess CUR-level blast radius
T+0:20 Posts incident summary to #incidents Slack channel:
- Detected at: T+0:00
- Anomaly type: [pattern A/B/C/D/E]
- Key/team stopped: [identifier]
- Estimated impact: $X (from litellm_spend_metric delta)
- Root cause hypothesis: [routing change / agent loop / credential]
T+0:25 Begins root cause investigation (deploy log correlation, agent trace review)
T+0:30 Posts initial RCA to incident channel. Escalates to P1 if root cause unclear.
Post-incident:
- Update LiteLLM circuit breaker message (lambda_circuit_breaker.py) with team's normal_budget
- Submit CUR-based final cost tally (run Athena Query A scoped to incident window)
- Add anomaly pattern to Grafana dashboard as annotated event
- If credential leak: rotate IAM keys, review CloudTrail for unauthorized calls
Alert routing summary
| Source | SNS Topic | Lambda | Slack Channel | PagerDuty |
|---|---|---|---|---|
| LiteLLM burn rate (>10×) | bedrock-cost-alerts-p1 |
lambda_hard_stop |
#incidents |
Yes |
| LiteLLM burn rate (5–10×) | bedrock-cost-alerts-p2 |
lambda_tpm_throttle |
#platform-alerts |
No |
| CloudWatch OutputTokenCount anomaly | bedrock-cost-alerts-p2 |
lambda_tpm_throttle |
#platform-alerts |
No |
| CloudWatch InvocationThrottles spike | bedrock-cost-alerts-p2 |
None (informational) | #platform-alerts |
No |
| Agent loop runaway (custom metric) | bedrock-cost-alerts-p1 |
lambda_kill_key |
#incidents |
Yes |
| AWS Cost Anomaly Detection | bedrock-cost-alerts-p3 |
None | #finops |
No |
| Athena scheduled query (new IAM principal) | bedrock-cost-alerts-p1 |
lambda_kill_key |
#incidents |
Yes |
8. Proactive Cost Guardrails
The cheapest anomaly is the one that never starts. The following guardrails prevent the conditions that lead to anomalies — they are infrastructure, not process.
Guardrail 1: SCP to enforce cost allocation tags on all Bedrock calls
Without cost allocation tags on every Bedrock invocation, CUR-based anomaly detection cannot attribute spend to teams or workloads. This SCP denies Bedrock API calls that lack the Team and Application tags.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyBedrockWithoutCostTags",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/Team": "true"
}
}
},
{
"Sid": "DenyBedrockWithoutApplicationTag",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/Application": "true"
}
}
}
]
}
Note: SCPs apply at the AWS Organizations level. Test in a non-production OU first. LiteLLM can inject these tags automatically via extra_headers if the IAM role has iam:TagResource — or via a Lambda@Edge policy — but the most reliable approach is to require them in the SCP and enforce tagging in the LiteLLM config.
Guardrail 2: LiteLLM max_budget + budget_duration as a circuit breaker
Configure per-team and per-key budgets in LiteLLM. When max_budget is reached, LiteLLM raises BudgetExceededError (HTTP 429) — no further calls go to Bedrock until the budget resets.
# litellm_config.yaml excerpt
general_settings:
master_key: "sk-master-xxx"
database_url: "postgresql://user:pass@host/litellm"
litellm_settings:
max_budget: 500.0 # Global daily budget cap in USD
budget_duration: "1d" # Reset daily at 00:00 UTC
# Team-level budgets are set via API or UI, not config file:
# POST /team/new
# {
# "team_alias": "data-science",
# "max_budget": 100.0,
# "budget_duration": "1d",
# "tpm_limit": 500000,
# "rpm_limit": 1000
# }
# Key-level budgets (for agent workloads):
# POST /key/generate
# {
# "key_alias": "agent-prod-01",
# "team_id": "data-science",
# "max_budget": 20.0,
# "budget_duration": "1d",
# "tpm_limit": 100000,
# "max_parallel_requests": 5
# }
The max_parallel_requests limit on agent keys is underused but highly effective: it caps how many concurrent agent turns can run simultaneously, directly limiting the blast radius of a runaway loop.
Guardrail 3: AWS Budgets action — IAM policy attachment when budget exceeded
When the AWS budget for Bedrock exceeds the threshold, an AWS Budgets action automatically attaches a deny policy to the relevant IAM role, blocking further Bedrock calls at the IAM layer — even if LiteLLM is bypassed.
{
"BudgetName": "BedrockMonthlyBudget-DataScience",
"BudgetLimit": {
"Amount": "500",
"Unit": "USD"
},
"TimeUnit": "MONTHLY",
"BudgetType": "COST",
"CostFilters": {
"Service": ["Amazon Bedrock"],
"TagKeyValue": ["user:Team$data-science"]
},
"NotificationsWithSubscribers": [],
"AutoAdjustData": {
"AutoAdjustType": "HISTORICAL",
"HistoricalOptions": {
"BudgetAdjustmentPeriod": 3
}
}
}
The corresponding Budgets Action:
{
"BudgetName": "BedrockMonthlyBudget-DataScience",
"NotificationType": "ACTUAL",
"ActionType": "APPLY_IAM_POLICY",
"ActionThreshold": {
"ActionThresholdValue": 80,
"ActionThresholdType": "PERCENTAGE"
},
"Definition": {
"IamActionDefinition": {
"PolicyArn": "arn:aws:iam::123456789012:policy/DenyBedrockPolicy",
"Roles": ["arn:aws:iam::123456789012:role/DataScienceBedrockRole"]
}
},
"ExecutionRoleArn": "arn:aws:iam::123456789012:role/BudgetsExecutionRole",
"ApprovalModel": "AUTOMATIC",
"Subscribers": [
{
"SubscriptionType": "SNS",
"Address": "arn:aws:sns:us-east-1:123456789012:bedrock-cost-alerts"
}
]
}
The DenyBedrockPolicy attached by this action:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyBedrockBudgetBreached",
"Effect": "Deny",
"Action": ["bedrock:*"],
"Resource": "*"
}
]
}
Guardrail 4: Terraform/CDK patterns for guardrail-as-code
Terraform — complete Bedrock cost guardrail module:
# modules/bedrock-guardrails/main.tf
# Deploys: CloudWatch anomaly alarm, SNS topic, Lambda remediation, Budget with action
variable "team_name" { type = string }
variable "monthly_budget_usd" { type = number }
variable "alert_email" { type = string }
variable "litellm_base_url" { type = string }
variable "litellm_master_key" {
type = string
sensitive = true
}
resource "aws_sns_topic" "bedrock_alerts" {
name = "bedrock-cost-alerts-${var.team_name}"
}
resource "aws_sns_topic_subscription" "email_alert" {
topic_arn = aws_sns_topic.bedrock_alerts.arn
protocol = "email"
endpoint = var.alert_email
}
resource "aws_cloudwatch_metric_alarm" "output_token_anomaly" {
alarm_name = "Bedrock-OutputToken-Anomaly-${var.team_name}"
alarm_description = "Output token anomaly detected for team ${var.team_name}"
comparison_operator = "GreaterThanUpperThreshold"
evaluation_periods = 2
treat_missing_data = "notBreaching"
threshold_metric_id = "ad1"
alarm_actions = [aws_sns_topic.bedrock_alerts.arn]
metric_query {
id = "m1"
return_data = true
metric {
namespace = "AWS/Bedrock"
metric_name = "OutputTokenCount"
period = 300
stat = "Sum"
}
}
metric_query {
id = "ad1"
expression = "ANOMALY_DETECTION_BAND(m1, 3)"
label = "OutputTokenCount (expected)"
return_data = true
}
}
resource "aws_iam_role" "lambda_remediation" {
name = "bedrock-remediation-${var.team_name}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "lambda_basic" {
role = aws_iam_role.lambda_remediation.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:*"
},
{
Effect = "Allow"
Action = ["sns:Publish"]
Resource = aws_sns_topic.bedrock_alerts.arn
}
]
})
}
data "archive_file" "lambda_zip" {
type = "zip"
output_path = "/tmp/lambda_tpm_throttle.zip"
source {
content = file("${path.module}/lambda/lambda_tpm_throttle.py")
filename = "lambda_function.py"
}
}
resource "aws_lambda_function" "tpm_throttle" {
function_name = "bedrock-tpm-throttle-${var.team_name}"
role = aws_iam_role.lambda_remediation.arn
handler = "lambda_function.lambda_handler"
runtime = "python3.12"
filename = data.archive_file.lambda_zip.output_path
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
timeout = 30
environment {
variables = {
LITELLM_BASE_URL = var.litellm_base_url
LITELLM_MASTER_KEY = var.litellm_master_key
TPM_REDUCTION_FACTOR = "0.25"
}
}
}
resource "aws_sns_topic_subscription" "lambda_alert" {
topic_arn = aws_sns_topic.bedrock_alerts.arn
protocol = "lambda"
endpoint = aws_lambda_function.tpm_throttle.arn
}
resource "aws_lambda_permission" "sns_invoke" {
statement_id = "AllowSNSInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.tpm_throttle.function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.bedrock_alerts.arn
}
resource "aws_budgets_budget" "bedrock_monthly" {
name = "bedrock-monthly-${var.team_name}"
budget_type = "COST"
limit_amount = tostring(var.monthly_budget_usd)
limit_unit = "USD"
time_unit = "MONTHLY"
cost_filter {
name = "Service"
values = ["Amazon Bedrock"]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 80
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [aws_sns_topic.bedrock_alerts.arn]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [aws_sns_topic.bedrock_alerts.arn]
}
}
CDK TypeScript — equivalent pattern:
// lib/bedrock-guardrails-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as actions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as budgets from 'aws-cdk-lib/aws-budgets';
import { Construct } from 'constructs';
interface BedrockGuardrailsProps {
teamName: string;
modelId: string;
monthlyBudgetUsd: number;
litellmBaseUrl: string;
litellmMasterKey: cdk.SecretValue;
}
export class BedrockGuardrailsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: BedrockGuardrailsProps & cdk.StackProps) {
super(scope, id, props);
const alertTopic = new sns.Topic(this, 'BedrockAlerts', {
topicName: `bedrock-cost-alerts-${props.teamName}`,
});
const outputTokenMetric = new cloudwatch.Metric({
namespace: 'AWS/Bedrock',
metricName: 'OutputTokenCount',
dimensionsMap: { ModelId: props.modelId },
period: cdk.Duration.minutes(5),
statistic: 'Sum',
});
const anomalyAlarm = new cloudwatch.Alarm(this, 'OutputTokenAnomaly', {
alarmName: `Bedrock-OutputToken-Anomaly-${props.teamName}`,
metric: outputTokenMetric,
threshold: 0, // overridden by anomaly detection expression
evaluationPeriods: 2,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_UPPER_THRESHOLD,
});
// Note: CDK does not natively support ANOMALY_DETECTION_BAND expressions
// in high-level constructs. Use CfnAlarm for full control:
const cfnAlarm = new cloudwatch.CfnAlarm(this, 'OutputTokenAnomalyCfn', {
alarmName: `Bedrock-OutputToken-AnomalyBand-${props.teamName}`,
comparisonOperator: 'GreaterThanUpperThreshold',
evaluationPeriods: 2,
treatMissingData: 'notBreaching',
thresholdMetricId: 'ad1',
alarmActions: [alertTopic.topicArn],
metrics: [
{
id: 'm1',
returnData: true,
metricStat: {
metric: {
namespace: 'AWS/Bedrock',
metricName: 'OutputTokenCount',
dimensions: [{ name: 'ModelId', value: props.modelId }],
},
period: 300,
stat: 'Sum',
},
},
{
id: 'ad1',
expression: 'ANOMALY_DETECTION_BAND(m1, 3)',
label: 'OutputTokenCount (expected)',
returnData: true,
},
],
});
const remediationFn = new lambda.Function(this, 'TpmThrottle', {
functionName: `bedrock-tpm-throttle-${props.teamName}`,
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'lambda_function.lambda_handler',
code: lambda.Code.fromAsset('lambda/tpm_throttle'),
timeout: cdk.Duration.seconds(30),
environment: {
LITELLM_BASE_URL: props.litellmBaseUrl,
LITELLM_MASTER_KEY: props.litellmMasterKey.unsafeUnwrap(),
TPM_REDUCTION_FACTOR: '0.25',
},
});
alertTopic.addSubscription(
new cdk.aws_sns_subscriptions.LambdaSubscription(remediationFn)
);
}
}
Summary: Detection Stack by Latency
| Layer | Mechanism | Latency | Automated Remediation |
|---|---|---|---|
| L1 — Sub-minute | LiteLLM Prometheus burn rate | 15–30s | Yes (Lambda via SNS) |
| L2 — 5-minute | CloudWatch OutputTokenCount anomaly band | 5–10 min | Yes (Lambda via SNS) |
| L3 — 5-minute | CloudWatch InvocationThrottles static alarm | 5 min | Informational |
| L4 — Hourly | LiteLLM SpendLogs Athena (scheduled Lambda) | 60 min | Yes (Lambda direct) |
| L5 — Daily | Athena CUR anomaly queries (new principal, model drift) | 24h | Alert-driven manual |
| L6 — Lagging | AWS Cost Anomaly Detection | 24–48h | No |
The correct operational posture: L1 and L2 handle real-time incidents with automated remediation. L3 provides throttle storm visibility. L4 catches patterns that accumulate over hours. L5 and L6 handle audit-grade retrospective review and serve as validation that the earlier layers worked. Do not design an incident response process that depends on L6 alerting as the primary signal.