Audience: Platform engineers and FinOps leads operating production LLM workloads on AWS Bedrock, whether via LiteLLM proxy or direct SDK calls. This note is opinionated and specific — it assumes a multi-account AWS Organization, existing CUR 2.0 delivery to S3, and at least one team running token-volume workloads exceeding $5k/month on Bedrock.
- AWS Cost Anomaly Detection for Bedrock
- Automated Reserved Tier Right-Sizing
- Budget-Gated CI/CD
- Multi-Account Showback Automation
- LiteLLM Budget Enforcement Automation
- Terraform / CDK IaC Patterns
1. AWS Cost Anomaly Detection for Bedrock
1.1 Service Monitor Configuration
Cost Anomaly Detection (CAD) operates at the service level via the ce:CreateAnomalyMonitor API. For Bedrock workloads, create a DIMENSIONAL monitor scoped to SERVICE = "Amazon Bedrock":
{
"MonitorName": "Bedrock-Production",
"MonitorType": "DIMENSIONAL",
"MonitorDimension": "SERVICE"
}
With a service monitor, CAD’s ML model trains on historical Bedrock spend patterns per account. It does not, by default, isolate spend by model ID — that requires cost allocation tags (covered in §1.4).
IAM permissions required on the caller role:
ce:CreateAnomalyMonitor
ce:CreateAnomalySubscription
ce:GetAnomalies
sns:CreateTopic
sns:SetTopicAttributes
1.2 Threshold Configuration — Percentage vs Absolute
The aws_ce_anomaly_subscription resource accepts either an ABSOLUTE_VALUE or PERCENTAGE threshold via the threshold_expression block (provider ≥ 5.x) or the deprecated threshold float for absolute-only.
Production guidance:
- Use
ABSOLUTE_VALUEfor high-spend services where a 20% spike might be acceptable but a $500 overage is not. For Bedrock workloads spending $10k/month, a $300 absolute threshold catches real incidents without alert fatigue. - Use
PERCENTAGEduring ramp-up phases when baseline spend is still growing. A 40% spike over a 7-day rolling baseline is a meaningful signal even when absolute dollars are low. - Combine both: subscribe with absolute for paging, subscribe with percentage for Slack info-channel.
Alert frequency options: IMMEDIATE, DAILY, WEEKLY. For production Bedrock, use IMMEDIATE for the SNS→PagerDuty path and DAILY for the Slack digest.
1.3 EventBridge → SNS → Slack Routing
CAD emits events to EventBridge under the aws.ce source with detail-type "Cost Anomaly Detection Alert". The event payload includes anomalyTotalImpact, rootCauses[], and anomalyStartDate.
EventBridge rule (JSON):
{
"source": ["aws.ce"],
"detail-type": ["Cost Anomaly Detection Alert"],
"detail": {
"anomalyTotalImpact": {
"numericGreaterThan": 100
}
}
}
Route this rule to an SNS topic. A Lambda subscriber formats and posts to Slack:
import json, os, urllib.request
def handler(event, context):
detail = event["detail"]
impact = detail.get("anomalyTotalImpact", {})
root = detail.get("rootCauses", [{}])[0]
msg = {
"text": (
f":rotating_light: *Bedrock Cost Anomaly*\n"
f"Impact: *${impact.get('totalImpact', 0):.2f}* "
f"({impact.get('totalImpactPercentage', 0):.1f}% above expected)\n"
f"Service: {root.get('service', 'n/a')} | "
f"Region: {root.get('region', 'n/a')} | "
f"Account: {root.get('linkedAccount', 'n/a')}\n"
f"Started: {detail.get('anomalyStartDate', 'n/a')}"
)
}
req = urllib.request.Request(
os.environ["SLACK_WEBHOOK_URL"],
data=json.dumps(msg).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req)
SNS topic policy — required to allow the CAD service principal to publish:
{
"Sid": "AllowCostAnomalyDetection",
"Effect": "Allow",
"Principal": { "Service": "costalerts.amazonaws.com" },
"Action": "SNS:Publish",
"Resource": "arn:aws:sns:us-east-1:ACCOUNT_ID:bedrock-anomaly-alerts"
}
1.4 Granular Attribution with Cost Allocation Tags — The Per-Model Gap
Critical limitation: CAD service monitors cannot differentiate between Claude Sonnet and Claude Haiku spikes. You see AmazonBedrock total anomalies, not per-model anomalies. Two mechanisms close this gap:
Option A — Application Inference Profiles (AIPs) with cost allocation tags: Create an AIP per team/model combination. AIPs are logical wrappers that accept arbitrary resource tags:
import boto3
bedrock = boto3.client("bedrock")
bedrock.create_inference_profile(
inferenceProfileName="team-search-claude-sonnet",
description="Search team Sonnet 3.7 profile",
modelSource={"copyFrom": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-7-sonnet-20250219-v1:0"},
tags=[
{"key": "Team", "value": "search"},
{"key": "Model", "value": "claude-sonnet-37"},
{"key": "CostCenter", "value": "eng-platform"},
]
)
Activate these tags in the Billing console under Cost Allocation Tags (takes 24–48 hours to propagate to CUR 2.0). Now run aws ce create-cost-category-definition to build a model-level cost category.
Option B — IAM principal-based attribution (2026 GA feature):
AWS now automatically records the full IAM ARN of the caller in CUR 2.0’s line_item_iam_principal column. If your LiteLLM proxy or application assumes a different role per team (via sts:AssumeRole with session tags), each team’s Bedrock spend appears as a distinct line item with no instrumentation changes required.
Session tag passthrough pattern:
sts = boto3.client("sts")
assumed = sts.assume_role(
RoleArn="arn:aws:iam::ACCOUNT:role/bedrock-inference",
RoleSessionName="team-search-user-42",
Tags=[
{"Key": "team", "Value": "search"},
{"Key": "user_id", "Value": "user-42"},
],
TransitiveTagKeys=["team"],
)
The resulting session credentials, when used for Bedrock calls, generate CUR 2.0 rows attributed to assumed-role/bedrock-inference/team-search-user-42 with iamPrincipal/team=search as a usable tag dimension.
1.5 Practical Alert Thresholds from Production
From published production deployments and AWS guidance:
| Tier | Threshold type | Value | Channel | Frequency |
|---|---|---|---|---|
| P1 paging | Absolute | $500 single anomaly | PagerDuty | IMMEDIATE |
| P2 Slack alert | Absolute | $100 single anomaly | #finops-alerts | IMMEDIATE |
| Daily digest | Percentage | 30% above baseline | #finops-digest | DAILY |
| Weekly report | Any anomaly detected | — | Email to FinOps lead | WEEKLY |
Known CAD limitations in Bedrock context:
- CAD trains on ~60 days of history. New workloads have noisy baselines for the first 2–3 weeks.
- CAD is a spend anomaly detector, not a quota detector. A throttle storm that costs nothing (because throttled requests don’t incur charges) is invisible to CAD. Use CloudWatch
InvocationThrottlesalarms (§2) for that. - Budget data in CAD refreshes up to 24 hours behind. It cannot stop a runaway job in real time. Treat CAD as a billing-layer control, not an application-layer guardrail.
2. Automated Reserved Tier Right-Sizing
2.1 Key CloudWatch Metrics
All Bedrock metrics live in the AWS/Bedrock CloudWatch namespace. Dimensions: ModelId (foundation model or cross-region inference profile ARN), and optionally ProvisionedModelArn for provisioned throughput.
| Metric | Unit | Use |
|---|---|---|
EstimatedTPMQuotaUsage |
Percent | Fraction of your TPM quota consumed in the last minute. Fires on every successful request. Use this to detect when you are approaching quota before throttling starts. |
InputTokenCount |
Count | Tokens sent per invocation. Sum over 1-minute periods to get TPM. |
OutputTokenCount |
Count | Tokens generated per invocation. |
InvocationThrottles |
Count | Requests rejected because the workload exceeded quota. Non-zero value means user-visible errors. |
InvocationLatency |
Milliseconds | End-to-end latency including queuing. |
TimeToFirstToken |
Milliseconds | TTFT for streaming calls. Added March 2026. |
EstimatedTPMQuotaUsage includes cache write tokens and output burndown multipliers, so it is more accurate than computing InputTokenCount / quota_limit yourself.
2.2 Right-Sizing Decision Logic
The core automation logic runs on a 5-minute CloudWatch cron or a Lambda invoked by an EventBridge schedule. The algorithm:
current_tpm_usage_pct = avg(EstimatedTPMQuotaUsage, 1h)
throttle_rate_pct = sum(InvocationThrottles, 1h) / sum(Invocations, 1h) * 100
if throttle_rate_pct > 2.0:
action = INCREASE_RESERVATION
signal = "throttle_pressure"
elif current_tpm_usage_pct < 60.0 for 7 consecutive days:
action = DECREASE_RESERVATION
signal = "underutilization"
else:
action = HOLD
Thresholds:
- Underutilization: < 60% average TPM usage over 7 days. Source: AWS FinOps recommendations for Provisioned Throughput.
- Throttle pressure: > 2% of invocations throttled in any 1-hour window. Even brief throttle storms cause cascading retries.
Important constraint: Provisioned Throughput by Model Units (the commitment-based form) does not support changing the model unit count after creation — you must create a new purchase and delete the old one. Provisioned Throughput by tokens (the newer per-token reservation form for select models) does support UpdateProvisionedModelThroughput. Confirm which type applies to your model before building automation around it.
2.3 CloudWatch Alarm → Lambda → SNS → Slack Runbook
Create two alarms per model: one for over-utilization (throttle risk), one for underutilization.
Over-utilization alarm:
{
"AlarmName": "Bedrock-Claude-Sonnet-Throttle-High",
"Namespace": "AWS/Bedrock",
"MetricName": "InvocationThrottles",
"Dimensions": [{"Name": "ModelId", "Value": "anthropic.claude-3-7-sonnet-20250219-v1:0"}],
"Statistic": "Sum",
"Period": 300,
"EvaluationPeriods": 3,
"Threshold": 10,
"ComparisonOperator": "GreaterThanOrEqualToThreshold",
"AlarmActions": ["arn:aws:sns:us-east-1:ACCOUNT:bedrock-ops"],
"TreatMissingData": "notBreaching"
}
Underutilization alarm (uses EstimatedTPMQuotaUsage):
{
"AlarmName": "Bedrock-Claude-Sonnet-Quota-Underused",
"Namespace": "AWS/Bedrock",
"MetricName": "EstimatedTPMQuotaUsage",
"Dimensions": [{"Name": "ModelId", "Value": "anthropic.claude-3-7-sonnet-20250219-v1:0"}],
"ExtendedStatistic": "p95",
"Period": 3600,
"EvaluationPeriods": 168,
"Threshold": 60.0,
"ComparisonOperator": "LessThanThreshold",
"AlarmActions": ["arn:aws:sns:us-east-1:ACCOUNT:bedrock-ops"]
}
168 evaluation periods × 3600 seconds = 7 days of data required before alarm can fire. This prevents noise from weekend dips.
Lambda runbook triggered by the throttle alarm:
import boto3, json, os
bedrock = boto3.client("bedrock")
sns = boto3.client("sns")
def handler(event, context):
alarm_name = event["Records"][0]["Sns"]["Message"]
alarm_data = json.loads(alarm_name)
model_id = _extract_model_from_alarm(alarm_data)
current_rpm = _get_service_quota(model_id, quota_code="L-XXXXXXXX") # replace with actual quota code
new_rpm = int(current_rpm * 1.5)
# Post to Slack with runbook link — do NOT auto-increase without approval
sns.publish(
TopicArn=os.environ["OPS_TOPIC_ARN"],
Subject=f"Bedrock throttle alert: {model_id}",
Message=(
f"Model {model_id} is throttling.\n"
f"Current quota: {current_rpm} RPM\n"
f"Recommended new quota: {new_rpm} RPM\n"
f"Runbook: https://wiki.internal/bedrock-quota-increase\n"
f"Auto-request: run `make bedrock-quota-increase MODEL={model_id} RPM={new_rpm}`"
)
)
def _get_service_quota(model_id, quota_code):
sq = boto3.client("service-quotas")
resp = sq.get_service_quota(ServiceCode="bedrock", QuotaCode=quota_code)
return resp["Quota"]["Value"]
def _extract_model_from_alarm(alarm_data):
for dim in alarm_data.get("Trigger", {}).get("Dimensions", []):
if dim["name"] == "ModelId":
return dim["value"]
return "unknown"
Automated quota increase via Service Quotas API:
sq = boto3.client("service-quotas")
sq.request_service_quota_increase(
ServiceCode="bedrock",
QuotaCode="L-XXXXXXXX", # quota code for the specific model RPM/TPM
DesiredValue=new_rpm,
)
Quota increases for Bedrock RPM/TPM are reviewed by AWS and typically approved within 1–2 business days for moderate increases (2x). For > 5x increases, expect a business justification request.
2.4 Provisioned Throughput Automation via boto3
For workloads that have committed to Provisioned Throughput (and are using a model that supports unit count changes):
bedrock = boto3.client("bedrock")
def rightsize_provisioned_throughput(provisioned_model_arn: str, new_model_units: int):
"""
Update provisioned model units. Only works for models that support
UpdateProvisionedModelThroughput capacity changes.
Check beforehand: describe_provisioned_model_throughput to verify commitmentDuration.
"""
resp = bedrock.describe_provisioned_model_throughput(
provisionedModelId=provisioned_model_arn
)
current_units = resp["modelUnits"]
status = resp["status"] # InService | Creating | Updating | Failed
if status != "InService":
raise RuntimeError(f"Cannot resize: status is {status}")
if current_units == new_model_units:
return {"action": "noop", "units": current_units}
bedrock.update_provisioned_model_throughput(
provisionedModelId=provisioned_model_arn,
desiredModelUnits=new_model_units,
)
return {"action": "resized", "from": current_units, "to": new_model_units}
Commitment duration caveat: Provisioned Throughput with a 1-month or 6-month commitment cannot be cancelled before the term ends. Downsizing within a commitment period may not be supported. Use on-demand (no-term) Provisioned Throughput for workloads where you need resize flexibility.
3. Budget-Gated CI/CD
3.1 Architecture Overview
The pattern: before a model deployment job runs, query the AWS Budgets API to check current Bedrock spend against the monthly threshold. If spend exceeds the gate, block the deployment and alert. This prevents deploying new model endpoints during a cost spike without human sign-off.
Important limitation: AWS Budgets data refreshes once every 24 hours. This gate catches yesterday’s overage, not today’s real-time runaway. Combine with a CloudWatch alarm on EstimatedTPMQuotaUsage for real-time enforcement.
3.2 GitHub Actions Integration
# .github/workflows/bedrock-deploy.yml
name: Bedrock Model Deploy
on:
workflow_dispatch:
inputs:
model_id:
description: "Bedrock model to deploy"
required: true
environment:
description: "Target environment"
required: true
default: "production"
jobs:
budget-check:
runs-on: ubuntu-latest
outputs:
budget_ok: ${{ steps.check.outputs.budget_ok }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- name: Check Bedrock budget gate
id: check
env:
BUDGET_NAME: ${{ vars.BEDROCK_MONTHLY_BUDGET_NAME }}
ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
SPEND_THRESHOLD_PCT: "85"
run: |
python3 - <<'EOF'
import boto3, json, sys, os
budgets = boto3.client("budgets", region_name="us-east-1")
account_id = os.environ["ACCOUNT_ID"]
budget_name = os.environ["BUDGET_NAME"]
threshold_pct = float(os.environ["SPEND_THRESHOLD_PCT"])
resp = budgets.describe_budget(
AccountId=account_id,
BudgetName=budget_name,
)
budget = resp["Budget"]
limit = float(budget["BudgetLimit"]["Amount"])
actual = float(budget["CalculatedSpend"]["ActualSpend"]["Amount"])
forecast = float(budget["CalculatedSpend"].get("ForecastedSpend", {}).get("Amount", actual))
pct = (actual / limit) * 100
print(f"Bedrock budget: ${actual:.2f} / ${limit:.2f} ({pct:.1f}%)")
print(f"Forecasted: ${forecast:.2f}")
if pct >= threshold_pct:
print(f"::error::Budget gate BLOCKED: {pct:.1f}% of monthly Bedrock budget consumed.")
print("budget_ok=false", file=open(os.environ["GITHUB_OUTPUT"], "a"))
sys.exit(1)
else:
print(f"Budget gate PASSED: {pct:.1f}% consumed.")
print("budget_ok=true", file=open(os.environ["GITHUB_OUTPUT"], "a"))
EOF
deploy:
needs: budget-check
if: needs.budget-check.outputs.budget_ok == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy Bedrock configuration
run: |
echo "Deploying model: ${{ inputs.model_id }} to ${{ inputs.environment }}"
# CDK deploy, cdk synth, or boto3 calls here
3.3 Budget Action — Automatic IAM Policy Attach
For environments where you want automatic enforcement (not just a gate), configure a Budget Action to attach an IAM deny policy when spend crosses a threshold. This is the hard stop:
IAM policy document — deny Bedrock inference:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyBedrockInference",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream"
],
"Resource": "*"
}
]
}
Attach this as a budget action via boto3:
import boto3
budgets = boto3.client("budgets")
budgets.create_budget_action(
AccountId="123456789012",
BudgetName="Bedrock-Monthly-Production",
NotificationType="ACTUAL",
ActionType="APPLY_IAM_POLICY",
ActionThreshold={
"ActionThresholdValue": 95.0,
"ActionThresholdType": "PERCENTAGE",
},
Definition={
"IamActionDefinition": {
"PolicyArn": "arn:aws:iam::123456789012:policy/DenyBedrockInference",
"Roles": ["arn:aws:iam::123456789012:role/bedrock-inference-role"],
}
},
ExecutionRoleArn="arn:aws:iam::123456789012:role/budgets-execution-role",
ApprovalModel="AUTOMATIC",
Subscribers=[
{
"SubscriptionType": "SNS",
"Address": "arn:aws:sns:us-east-1:123456789012:bedrock-alerts"
}
]
)
IAM permissions required on the budget execution role:
iam:AttachRolePolicy
iam:DetachRolePolicy
budgets:ModifyBudget
The AUTOMATIC approval model fires without human approval. Use MANUAL if you want a two-step confirmation flow via the AWS console before the policy attaches.
3.4 CDK Pattern for Budget Gate Infrastructure
# cdk/bedrock_budget_gate.py
from aws_cdk import (
aws_budgets as budgets,
aws_iam as iam,
aws_sns as sns,
Stack,
)
from constructs import Construct
class BedrockBudgetGate(Stack):
def __init__(self, scope: Construct, id: str,
monthly_limit_usd: float = 10000.0,
gate_threshold_pct: float = 85.0,
hard_stop_pct: float = 95.0,
**kwargs):
super().__init__(scope, id, **kwargs)
alert_topic = sns.Topic(self, "BedrockBudgetAlerts",
display_name="Bedrock Budget Alerts")
deny_policy = iam.ManagedPolicy(self, "DenyBedrockInference",
statements=[
iam.PolicyStatement(
effect=iam.Effect.DENY,
actions=[
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream",
],
resources=["*"],
)
]
)
inference_role = iam.Role.from_role_name(
self, "InferenceRole", "bedrock-inference-role")
budgets.CfnBudget(self, "BedrockMonthlyBudget",
budget=budgets.CfnBudget.BudgetDataProperty(
budget_name="Bedrock-Monthly-Production",
budget_type="COST",
time_unit="MONTHLY",
budget_limit=budgets.CfnBudget.SpendProperty(
amount=str(monthly_limit_usd),
unit="USD",
),
cost_filters={"Service": ["Amazon Bedrock"]},
),
notifications_with_subscribers=[
budgets.CfnBudget.NotificationWithSubscribersProperty(
notification=budgets.CfnBudget.NotificationProperty(
notification_type="ACTUAL",
comparison_operator="GREATER_THAN",
threshold=gate_threshold_pct,
threshold_type="PERCENTAGE",
),
subscribers=[
budgets.CfnBudget.SubscriberProperty(
subscription_type="SNS",
address=alert_topic.topic_arn,
)
]
)
]
)
4. Multi-Account Showback Automation
4.1 CUR 2.0 Setup for Bedrock Attribution
CUR 2.0 (Cost and Usage Report, new format) is the foundation. Bedrock-specific columns available as of 2026:
| CUR 2.0 column | Content |
|---|---|
line_item_product_code |
AmazonBedrock |
line_item_iam_principal |
Full ARN of the IAM principal that made the call (new 2026 feature) |
product_model_id |
Bedrock model ID (e.g., anthropic.claude-3-7-sonnet-20250219-v1:0) |
resource_tags/user:Team |
Custom cost allocation tag value |
line_item_usage_type |
e.g., USE1-Claude-InputTokens |
line_item_usage_amount |
Token count |
line_item_unblended_cost |
USD cost |
Enable CUR 2.0 delivery to S3, then run a Glue Crawler to catalog it. Athena queries can reference the table cur_database.cur_report_name.
4.2 Athena Scheduled Queries for Monthly Chargeback
Monthly chargeback by team (using cost allocation tags):
-- Bedrock monthly chargeback by Team tag
-- Run on the 2nd of each month for prior month
SELECT
DATE_FORMAT(line_item_usage_start_date, '%Y-%m') AS billing_month,
resource_tags_user_team AS team,
product_model_id AS model,
line_item_product_code AS service,
SUM(line_item_usage_amount) AS total_tokens,
SUM(line_item_unblended_cost) AS total_cost_usd,
COUNT(DISTINCT line_item_iam_principal) AS distinct_callers
FROM cur_database.aws_cur
WHERE
line_item_product_code = 'AmazonBedrock'
AND line_item_line_item_type = 'Usage'
AND DATE_FORMAT(line_item_usage_start_date, '%Y-%m')
= DATE_FORMAT(DATE_ADD('month', -1, CURRENT_DATE), '%Y-%m')
GROUP BY 1, 2, 3, 4
ORDER BY total_cost_usd DESC;
Per-model daily spend trend (for anomaly investigation):
SELECT
CAST(line_item_usage_start_date AS DATE) AS usage_date,
product_model_id,
SUM(line_item_unblended_cost) AS daily_cost_usd,
SUM(line_item_usage_amount) AS daily_tokens
FROM cur_database.aws_cur
WHERE
line_item_product_code = 'AmazonBedrock'
AND line_item_usage_start_date >= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY 1, 2
ORDER BY 1 DESC, daily_cost_usd DESC;
4.3 Cost Categories API — Division Mapping
Cost Categories allow you to define cost attribution rules that show up in Cost Explorer and CUR 2.0. Create a BEDROCK_DIVISION category:
import boto3
ce = boto3.client("ce", region_name="us-east-1")
ce.create_cost_category_definition(
Name="BEDROCK_DIVISION",
RuleVersion="CostCategoryExpression.v1",
Rules=[
{
"Value": "Search",
"Rule": {
"Tags": {
"Key": "Team",
"Values": ["search", "search-platform"],
"MatchOptions": ["EQUALS"],
}
},
"Type": "REGULAR",
},
{
"Value": "Recommendations",
"Rule": {
"Tags": {
"Key": "Team",
"Values": ["recs", "recommendations"],
"MatchOptions": ["EQUALS"],
}
},
"Type": "REGULAR",
},
{
"Value": "Shared-Infrastructure",
"Rule": {
"And": [
{
"Dimensions": {
"Key": "SERVICE",
"Values": ["Amazon Bedrock"],
}
},
{
"Not": {
"Tags": {
"Key": "Team",
"Values": ["search", "recs"],
"MatchOptions": ["EQUALS"],
}
}
}
]
},
"Type": "REGULAR",
}
],
DefaultValue="Untagged",
SplitChargeRules=[
{
"Source": "Shared-Infrastructure",
"Targets": ["Search", "Recommendations"],
"Method": "PROPORTIONAL",
}
],
)
SplitChargeRules with PROPORTIONAL method distributes shared Bedrock costs (e.g., shared RAG infrastructure) proportionally based on each team’s tagged spend. This is the right pattern for shared embedding services.
4.4 S3 Export → BI Pipeline + Lambda Cron Delivery
Lambda cron for automated monthly report delivery:
import boto3, io, csv, os
from datetime import date, timedelta
def handler(event, context):
"""
Runs on the 2nd of each month via EventBridge.
1. Queries Athena for prior-month Bedrock chargeback
2. Writes CSV to S3
3. Posts summary to Slack
"""
athena = boto3.client("athena")
s3 = boto3.client("s3")
prior_month = (date.today().replace(day=1) - timedelta(days=1)).strftime("%Y-%m")
output_key = f"finops-reports/bedrock-chargeback-{prior_month}.csv"
output_uri = f"s3://{os.environ['REPORTS_BUCKET']}/{output_key}"
query = f"""
SELECT
resource_tags_user_team AS team,
product_model_id AS model,
SUM(line_item_usage_amount) AS tokens,
SUM(line_item_unblended_cost) AS cost_usd
FROM {os.environ['CUR_TABLE']}
WHERE line_item_product_code = 'AmazonBedrock'
AND DATE_FORMAT(line_item_usage_start_date, '%Y-%m') = '{prior_month}'
GROUP BY 1, 2
ORDER BY cost_usd DESC
"""
start = athena.start_query_execution(
QueryString=query,
QueryExecutionContext={"Database": os.environ["ATHENA_DATABASE"]},
ResultConfiguration={
"OutputLocation": f"s3://{os.environ['ATHENA_RESULTS_BUCKET']}/queries/"
},
WorkGroup=os.environ.get("ATHENA_WORKGROUP", "primary"),
)
exec_id = start["QueryExecutionId"]
_wait_for_query(athena, exec_id)
results = athena.get_query_results(QueryExecutionId=exec_id)
rows = _parse_athena_results(results)
# Write CSV to S3
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=["team", "model", "tokens", "cost_usd"])
writer.writeheader()
writer.writerows(rows)
s3.put_object(
Bucket=os.environ["REPORTS_BUCKET"],
Key=output_key,
Body=buf.getvalue().encode(),
ContentType="text/csv",
)
# Post summary to Slack
total = sum(float(r["cost_usd"]) for r in rows)
top_teams = sorted(rows, key=lambda r: float(r["cost_usd"]), reverse=True)[:5]
_post_slack(prior_month, total, top_teams, output_uri)
def _wait_for_query(athena, exec_id, max_attempts=60):
import time
for _ in range(max_attempts):
resp = athena.get_query_execution(QueryExecutionId=exec_id)
state = resp["QueryExecution"]["Status"]["State"]
if state in ("SUCCEEDED",):
return
if state in ("FAILED", "CANCELLED"):
raise RuntimeError(f"Athena query {state}: "
f"{resp['QueryExecution']['Status'].get('StateChangeReason')}")
time.sleep(5)
raise TimeoutError("Athena query timed out")
def _parse_athena_results(results):
cols = [c["VarCharValue"] for c in results["ResultSet"]["Rows"][0]["Data"]]
rows = []
for row in results["ResultSet"]["Rows"][1:]:
values = [d.get("VarCharValue", "") for d in row["Data"]]
rows.append(dict(zip(cols, values)))
return rows
def _post_slack(month, total, top_teams, report_uri):
import json, urllib.request
lines = [f"• {r['team']} | {r['model'].split('.')[-1]} | ${float(r['cost_usd']):.2f}"
for r in top_teams]
msg = {
"text": (
f":bar_chart: *Bedrock Chargeback Report — {month}*\n"
f"Total: *${total:,.2f}*\n\n"
f"Top 5 by cost:\n" + "\n".join(lines) + f"\n\n"
f"Full report: {report_uri}"
)
}
req = urllib.request.Request(
os.environ["SLACK_WEBHOOK_URL"],
data=json.dumps(msg).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req)
EventBridge rule for monthly trigger:
{
"ScheduleExpression": "cron(0 8 2 * ? *)",
"Description": "Trigger Bedrock monthly chargeback report on 2nd of each month at 08:00 UTC"
}
5. LiteLLM Budget Enforcement Automation
5.1 Architecture Overview
LiteLLM proxy operates as a budget enforcement layer between application code and Bedrock. All inference traffic passes through the proxy, which tracks spend in a PostgreSQL backend (required for multi-replica deployments). The enforcement hierarchy:
Global proxy budget (litellm_settings.max_budget)
└─ Team budget (team.max_budget)
└─ Virtual key budget (key.max_budget)
└─ Per-user budget (user.max_budget)
Each level enforces independently. A key budget does not override a team budget; both are checked on every request.
5.2 Core config.yaml Configuration
# litellm/config.yaml — production Bedrock deployment
model_list:
- model_name: "claude-sonnet-37"
litellm_params:
model: "bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0"
aws_region_name: "us-east-1"
# Use cross-region inference profile for higher quota
model: "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
- model_name: "claude-haiku-35"
litellm_params:
model: "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0"
aws_region_name: "us-east-1"
litellm_settings:
# Global monthly budget across all keys — hard stop
max_budget: 50000.0 # USD
budget_duration: "30d"
# Alert channels
alerting:
- slack
- email
# Slack alerting config
alerting_args:
daily_report_frequency: 86400 # seconds between daily digests
report_check_interval: 3600 # how often to check for budget alerts (1h)
budget_alert_ttl: 3600 # deduplicate identical alerts within 1h window
slack_daily_report_frequency: 86400
# Spend report to #finops-alerts every 24h
spend_report_frequency: "1d"
general_settings:
master_key: "sk-${LITELLM_MASTER_KEY}"
database_url: "postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:5432/litellm"
# Optional: enable spend tracking on all requests
enable_jwt_auth: false
store_model_in_db: true
Environment variables:
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx
LITELLM_MASTER_KEY=sk-xxx
DB_USER=litellm
DB_PASS=xxx
DB_HOST=postgres.internal
5.3 Virtual Key Budget Configuration
Create keys via the LiteLLM admin API. The /key/generate endpoint accepts:
curl -X POST https://litellm.internal/key/generate \
-H "Authorization: Bearer sk-${LITELLM_MASTER_KEY}" \
-H "Content-Type: application/json" \
-d '{
"key_alias": "team-search-production",
"team_id": "team-search",
"max_budget": 2000.0,
"soft_budget": 1600.0,
"budget_duration": "30d",
"models": ["claude-sonnet-37", "claude-haiku-35"],
"metadata": {
"cost_center": "eng-platform",
"owner": "search-team@company.com"
}
}'
Soft budget (soft_budget) triggers an alert at the specified dollar amount without blocking requests. Hard budget (max_budget) blocks requests with HTTP 429 once exhausted. Set soft_budget at 80% of max_budget for a meaningful warning window.
5.4 Multi-Threshold Budget Alerts
LiteLLM v1.83+ supports multi-threshold alerting on virtual keys, firing at configurable percentages rather than only at 80%:
import httpx, os
async def create_key_with_thresholds(team_id: str, budget_usd: float):
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://litellm.internal/key/generate",
headers={"Authorization": f"Bearer {os.environ['LITELLM_MASTER_KEY']}"},
json={
"team_id": team_id,
"max_budget": budget_usd,
"soft_budget": budget_usd * 0.8, # 80% alert
"budget_duration": "30d",
"budget_reset_at": None, # auto-resets per budget_duration
"metadata": {
"alert_thresholds": [50, 80, 95] # pct — for webhook callbacks
}
}
)
return resp.json()
The alert_thresholds in metadata is used by custom callback handlers. Wire a callback:
# callbacks/budget_callback.py
from litellm.integrations.custom_logger import CustomLogger
import httpx, os
class BudgetThresholdCallback(CustomLogger):
THRESHOLDS = [0.50, 0.80, 0.95]
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
key_metadata = kwargs.get("metadata", {})
key_alias = key_metadata.get("user_api_key_alias", "unknown")
spend = kwargs.get("response_cost", 0.0)
max_budget = kwargs.get("user_api_key_max_budget")
if not max_budget:
return
pct = spend / max_budget
for threshold in self.THRESHOLDS:
if abs(pct - threshold) < 0.01: # within 1% of threshold
await self._alert_slack(key_alias, spend, max_budget, pct)
break
async def _alert_slack(self, key_alias, spend, budget, pct):
async with httpx.AsyncClient() as client:
await client.post(
os.environ["SLACK_WEBHOOK_URL"],
json={
"text": (
f":money_with_wings: *LiteLLM Budget Alert* — `{key_alias}`\n"
f"Spend: *${spend:.2f}* / *${budget:.2f}* ({pct*100:.0f}%)\n"
f"{'*Hard limit approaching — requests will be blocked soon.*' if pct >= 0.95 else ''}"
)
}
)
Register the callback in config.yaml:
litellm_settings:
success_callback:
- /app/callbacks/budget_callback.BudgetThresholdCallback
5.5 Auto-Rotation on Budget Exhaustion
When a virtual key exhausts its budget, LiteLLM returns HTTP 429. For workloads that cannot tolerate downtime, implement automatic key rotation:
Pattern 1 — Client-side rotation with fallback key:
import litellm, os
PRIMARY_KEY = os.environ["LITELLM_KEY_PRIMARY"]
FALLBACK_KEY = os.environ["LITELLM_KEY_FALLBACK"] # lower quota, same team
async def invoke_with_rotation(messages: list, model: str = "claude-sonnet-37"):
for key in [PRIMARY_KEY, FALLBACK_KEY]:
try:
response = await litellm.acompletion(
model=model,
messages=messages,
api_key=key,
api_base="https://litellm.internal",
)
return response
except litellm.BudgetExceededError:
continue
raise RuntimeError("All keys exhausted — escalate to FinOps")
Pattern 2 — LiteLLM-side team rotation via admin API:
async def rotate_exhausted_key(exhausted_key_hash: str, team_id: str):
"""
Called by a Lambda triggered by LiteLLM webhook on BudgetExceeded event.
Creates a new key for the team and posts credentials to Slack.
"""
async with httpx.AsyncClient() as client:
# Disable exhausted key
await client.post(
"https://litellm.internal/key/block",
headers={"Authorization": f"Bearer {os.environ['LITELLM_MASTER_KEY']}"},
json={"key": exhausted_key_hash},
)
# Create new key (budget resets)
new_key_resp = await client.post(
"https://litellm.internal/key/generate",
headers={"Authorization": f"Bearer {os.environ['LITELLM_MASTER_KEY']}"},
json={
"team_id": team_id,
"max_budget": float(os.environ["TEAM_MONTHLY_BUDGET"]),
"soft_budget": float(os.environ["TEAM_MONTHLY_BUDGET"]) * 0.8,
"budget_duration": "30d",
"key_alias": f"{team_id}-auto-rotated",
}
)
new_key = new_key_resp.json()["key"]
await _post_new_key_to_slack(team_id, new_key)
# In practice: write to AWS Secrets Manager, not Slack
await _store_in_secrets_manager(team_id, new_key)
Posting new credentials via Secrets Manager (recommended over Slack):
async def _store_in_secrets_manager(team_id: str, new_key: str):
secrets = boto3.client("secretsmanager")
secret_name = f"litellm/team-keys/{team_id}"
try:
secrets.put_secret_value(
SecretId=secret_name,
SecretString=json.dumps({"api_key": new_key, "rotated_at": str(date.today())})
)
except secrets.exceptions.ResourceNotFoundException:
secrets.create_secret(
Name=secret_name,
SecretString=json.dumps({"api_key": new_key}),
)
Application code pulls the key from Secrets Manager at startup, so rotation is transparent.
5.6 Provider Budget Routing
LiteLLM v1.75+ supports per-provider budget routing: when a provider (Bedrock, in this case) exhausts a configured daily budget, LiteLLM automatically routes to the next provider in the list:
# config.yaml — provider budget routing
router_settings:
provider_budget_config:
bedrock:
budget_limit: 500.0 # USD per budget_duration
budget_duration: "1d" # resets daily
openai:
budget_limit: 200.0
budget_duration: "1d"
model_list:
- model_name: "claude-sonnet-37"
litellm_params:
model: "bedrock/us.anthropic.claude-3-7-sonnet-20250219-v1:0"
- model_name: "claude-sonnet-37" # same logical name, different provider
litellm_params:
model: "openai/gpt-4o" # fallback if Bedrock daily budget exhausted
When Bedrock’s budget_limit is hit, LiteLLM routes subsequent requests to openai/gpt-4o without the application seeing an error. This is a cost management pattern, not a reliability pattern — it changes the model being used.
6. Terraform / CDK IaC Patterns
6.1 Complete Terraform Module — Bedrock FinOps Stack
# terraform/bedrock_finops/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
variable "monthly_budget_usd" { default = 10000 }
variable "anomaly_alert_threshold" { default = 300 } # USD absolute
variable "slack_webhook_ssm_path" { default = "/finops/slack-webhook-url" }
variable "environment" { default = "production" }
# ─── SNS Topic ───────────────────────────────────────────────────────────────
resource "aws_sns_topic" "bedrock_finops" {
name = "bedrock-finops-${var.environment}"
}
resource "aws_sns_topic_policy" "allow_cost_anomaly" {
arn = aws_sns_topic.bedrock_finops.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCostAnomalyDetection"
Effect = "Allow"
Principal = { Service = "costalerts.amazonaws.com" }
Action = "SNS:Publish"
Resource = aws_sns_topic.bedrock_finops.arn
}]
})
}
# ─── Cost Anomaly Detection ──────────────────────────────────────────────────
resource "aws_ce_anomaly_monitor" "bedrock" {
name = "Bedrock-${var.environment}"
monitor_type = "DIMENSIONAL"
monitor_dimension = "SERVICE"
}
resource "aws_ce_anomaly_subscription" "bedrock_immediate" {
name = "Bedrock-Immediate-Alert-${var.environment}"
frequency = "IMMEDIATE"
monitor_arn_list = [aws_ce_anomaly_monitor.bedrock.arn]
threshold_expression {
and {
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
match_options = ["GREATER_THAN_OR_EQUAL"]
values = [tostring(var.anomaly_alert_threshold)]
}
}
}
subscriber {
address = aws_sns_topic.bedrock_finops.arn
type = "SNS"
}
}
resource "aws_ce_anomaly_subscription" "bedrock_daily" {
name = "Bedrock-Daily-Digest-${var.environment}"
frequency = "DAILY"
monitor_arn_list = [aws_ce_anomaly_monitor.bedrock.arn]
threshold_expression {
and {
dimension {
key = "ANOMALY_TOTAL_IMPACT_PERCENTAGE"
match_options = ["GREATER_THAN_OR_EQUAL"]
values = ["40"]
}
}
}
subscriber {
address = aws_sns_topic.bedrock_finops.arn
type = "SNS"
}
}
# ─── CloudWatch Alarms ───────────────────────────────────────────────────────
locals {
bedrock_models = {
"sonnet-37" = "anthropic.claude-3-7-sonnet-20250219-v1:0"
"haiku-35" = "anthropic.claude-3-5-haiku-20241022-v1:0"
}
}
resource "aws_cloudwatch_metric_alarm" "throttle_high" {
for_each = local.bedrock_models
alarm_name = "Bedrock-${each.key}-ThrottleHigh-${var.environment}"
comparison_operator = "GreaterThanOrEqualToThreshold"
evaluation_periods = 3
metric_name = "InvocationThrottles"
namespace = "AWS/Bedrock"
period = 300
statistic = "Sum"
threshold = 10
alarm_description = "Bedrock throttling detected — consider quota increase"
treat_missing_data = "notBreaching"
dimensions = {
ModelId = each.value
}
alarm_actions = [aws_sns_topic.bedrock_finops.arn]
ok_actions = [aws_sns_topic.bedrock_finops.arn]
}
resource "aws_cloudwatch_metric_alarm" "quota_underused" {
for_each = local.bedrock_models
alarm_name = "Bedrock-${each.key}-QuotaUnderutilized-${var.environment}"
comparison_operator = "LessThanThreshold"
evaluation_periods = 168 # 7 days at 1h resolution
metric_name = "EstimatedTPMQuotaUsage"
namespace = "AWS/Bedrock"
period = 3600
extended_statistic = "p95"
threshold = 60.0
alarm_description = "Bedrock quota p95 < 60% for 7 days — consider reducing reservation"
treat_missing_data = "notBreaching"
dimensions = {
ModelId = each.value
}
alarm_actions = [aws_sns_topic.bedrock_finops.arn]
}
# ─── AWS Budget ──────────────────────────────────────────────────────────────
resource "aws_budgets_budget" "bedrock_monthly" {
name = "Bedrock-Monthly-${var.environment}"
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_finops.arn]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 95
threshold_type = "PERCENTAGE"
notification_type = "ACTUAL"
subscriber_sns_topic_arns = [aws_sns_topic.bedrock_finops.arn]
}
notification {
comparison_operator = "GREATER_THAN"
threshold = 100
threshold_type = "PERCENTAGE"
notification_type = "FORECASTED"
subscriber_sns_topic_arns = [aws_sns_topic.bedrock_finops.arn]
}
}
# ─── IAM Deny Policy for Budget Action ───────────────────────────────────────
resource "aws_iam_policy" "deny_bedrock_inference" {
name = "DenyBedrockInference-${var.environment}"
description = "Applied automatically by Budget Action at 95% spend"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyBedrockInference"
Effect = "Deny"
Action = [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
"bedrock:Converse",
"bedrock:ConverseStream",
]
Resource = "*"
}]
})
}
resource "aws_iam_role" "budget_execution" {
name = "budgets-bedrock-execution-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "budgets.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "budget_execution_permissions" {
role = aws_iam_role.budget_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["iam:AttachRolePolicy", "iam:DetachRolePolicy"]
Resource = "arn:aws:iam::*:role/bedrock-inference-role"
}]
})
}
resource "aws_budgets_budget_action" "deny_on_95pct" {
budget_name = aws_budgets_budget.bedrock_monthly.name
action_type = "APPLY_IAM_POLICY"
approval_model = "AUTOMATIC"
notification_type = "ACTUAL"
execution_role_arn = aws_iam_role.budget_execution.arn
action_threshold {
action_threshold_type = "PERCENTAGE"
action_threshold_value = 95
}
definition {
iam_action_definition {
policy_arn = aws_iam_policy.deny_bedrock_inference.arn
roles = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/bedrock-inference-role"]
}
}
subscriber {
address = aws_sns_topic.bedrock_finops.arn
subscription_type = "SNS"
}
}
data "aws_caller_identity" "current" {}
# ─── Lambda for Slack routing ────────────────────────────────────────────────
data "aws_ssm_parameter" "slack_webhook" {
name = var.slack_webhook_ssm_path
with_decryption = true
}
resource "aws_lambda_function" "finops_router" {
function_name = "bedrock-finops-router-${var.environment}"
runtime = "python3.12"
handler = "handler.main"
role = aws_iam_role.lambda_exec.arn
filename = "${path.module}/lambda_router.zip"
timeout = 30
environment {
variables = {
SLACK_WEBHOOK_URL = data.aws_ssm_parameter.slack_webhook.value
}
}
}
resource "aws_sns_topic_subscription" "lambda_to_sns" {
topic_arn = aws_sns_topic.bedrock_finops.arn
protocol = "lambda"
endpoint = aws_lambda_function.finops_router.arn
}
resource "aws_lambda_permission" "allow_sns" {
statement_id = "AllowSNSInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.finops_router.function_name
principal = "sns.amazonaws.com"
source_arn = aws_sns_topic.bedrock_finops.arn
}
resource "aws_iam_role" "lambda_exec" {
name = "bedrock-finops-lambda-${var.environment}"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
6.2 Outputs
output "finops_sns_topic_arn" {
value = aws_sns_topic.bedrock_finops.arn
}
output "anomaly_monitor_arn" {
value = aws_ce_anomaly_monitor.bedrock.arn
}
output "deny_bedrock_policy_arn" {
value = aws_iam_policy.deny_bedrock_inference.arn
}
6.3 Minimum IAM Permissions Checklist
The role running terraform apply for this module requires:
ce:CreateAnomalyMonitor
ce:DeleteAnomalyMonitor
ce:CreateAnomalySubscription
ce:DeleteAnomalySubscription
ce:CreateCostCategoryDefinition
ce:UpdateCostCategoryDefinition
budgets:CreateBudget
budgets:ModifyBudget
budgets:CreateBudgetAction
budgets:UpdateBudgetAction
budgets:DeleteBudgetAction
sns:CreateTopic
sns:SetTopicAttributes
sns:Subscribe
sns:DeleteTopic
cloudwatch:PutMetricAlarm
cloudwatch:DeleteAlarms
iam:CreatePolicy
iam:DeletePolicy
iam:CreateRole
iam:AttachRolePolicy
iam:DetachRolePolicy
lambda:CreateFunction
lambda:UpdateFunctionCode
lambda:AddPermission
ssm:GetParameter
6.4 Athena Workgroup and Glue Catalog via Terraform
resource "aws_athena_workgroup" "finops" {
name = "bedrock-finops"
configuration {
result_configuration {
output_location = "s3://${aws_s3_bucket.athena_results.bucket}/queries/"
encryption_configuration {
encryption_option = "SSE_S3"
}
}
bytes_scanned_cutoff_per_query = 10737418240 # 10 GB safety cutoff
publish_cloudwatch_metrics_enabled = true
}
}
resource "aws_glue_crawler" "cur_crawler" {
name = "cur-bedrock-crawler"
role = aws_iam_role.glue_crawler.arn
database_name = aws_glue_catalog_database.cur.name
s3_target {
path = "s3://${var.cur_bucket_name}/${var.cur_prefix}/"
}
schedule = "cron(0 6 * * ? *)" # daily at 06:00 UTC
schema_change_policy {
delete_behavior = "LOG"
update_behavior = "UPDATE_IN_DATABASE"
}
}
resource "aws_glue_catalog_database" "cur" {
name = "aws_billing"
}
Operational Runbook — Decision Tree
Bedrock cost alert fires
│
├─ Source: Cost Anomaly Detection
│ ├─ Check root cause in AWS Cost Explorer → drill by Service → Bedrock
│ ├─ If anomaly is model-specific: query CUR 2.0 by product_model_id for the date range
│ ├─ If anomaly is team-specific: query by resource_tags_user_team or line_item_iam_principal
│ └─ Escalate if > $1000: engage FinOps lead + model team owner
│
├─ Source: InvocationThrottles alarm
│ ├─ Check EstimatedTPMQuotaUsage for the same model in the same window
│ ├─ If quota usage was < 80% before throttle: likely burst spike → add retry-with-jitter
│ ├─ If quota usage was > 90% sustained: submit Service Quotas increase request
│ └─ Consider cross-region inference profile for automatic burst absorption
│
├─ Source: LiteLLM BudgetExceeded (HTTP 429 from proxy)
│ ├─ Check which team/key exhausted budget in LiteLLM UI or /spend/logs API
│ ├─ If legitimate workload growth: request budget increase from FinOps lead
│ ├─ If anomalous: block key immediately, investigate usage logs
│ └─ For planned events (bulk jobs): pre-create a separate high-budget key with expiry
│
└─ Source: Budget Action fired (IAM Deny attached)
├─ All Bedrock inference now blocked for the inference role
├─ To re-enable: detach the deny policy manually or via budgets:ExecuteBudgetAction (REVERSE)
└─ Root-cause before re-enabling — do not reverse without understanding the overage
Known Gaps and Caveats (2026)
| Gap | Workaround |
|---|---|
| CAD cannot alert on per-model spend spikes without cost allocation tags | Use AIPs with Team + Model tags; activate tags in Billing console |
| AWS Budgets data is 24h stale — cannot stop real-time runaway jobs | LiteLLM hard limits are the real-time control; Budgets is the billing-layer backstop |
| Provisioned Throughput by Model Units cannot be resized after purchase | Use no-term (on-demand) Provisioned Throughput for workloads where you need resize flexibility |
EstimatedTPMQuotaUsage is per-account, not per-model, in some regions |
Verify metric dimensionality in your region; use ModelId dimension where available |
| LiteLLM spend tracking requires PostgreSQL; SQLite is not suitable for multi-replica production | Budget PostgreSQL with connection pooling (PgBouncer) for proxy fleets > 3 replicas |
| IAM principal cost attribution requires caller to use distinct roles or session tags | Single-role gateway patterns hide attribution; session tags are the minimum viable fix |
Cost Categories SplitChargeRules max 10 sources |
For organizations with > 10 shared Bedrock services, aggregate before splitting |