The new Bedrock product attributes in Cost Explorer are great when a human remembers to open the console. Humans do not. Bedrock cost automation closes that gap: a small scheduled Lambda that pulls per-model, per-token-type spend from the Cost Explorer API every morning, computes deltas and cache-hit ratios, and pushes them where your team already looks. Here is the build, the code, and the traps that come with a dimension that is barely a week old.
The shape of the pipeline
Keep it boring. Boring pipelines survive reorgs.
- EventBridge Scheduler fires once a day, a few hours after midnight UTC, so the previous day’s cost rows have had time to settle.
- A Python Lambda calls
GetCostAndUsagegrouped by themodelandinferenceTypekeys of thePRODUCT_ATTRIBUTEdimension, plus one filtered call for the attribute-less bucket. - CloudWatch custom metrics hold daily cost per model and the cache-hit ratio, so you get graphs and alarms for free-ish.
- An SNS topic sends a short digest to email, or to Slack through whatever webhook bridge you already run.
No CUR, no Athena, no warehouse. If you want token-level forensics, that heavier stack still has its place; this job answers “what changed yesterday, and on which model.”
Least-privilege IAM for the job
The Cost Explorer side needs two read actions. Add CloudWatch and SNS write for the outputs, scoped to the namespace and the topic. Cost Explorer actions do not support resource-level scoping, hence the wildcard on that statement.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ce:GetCostAndUsage", "ce:GetDimensionValues"],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "cloudwatch:PutMetricData",
"Resource": "*",
"Condition": {"StringEquals": {"cloudwatch:namespace": "FinOps/Bedrock"}}
},
{
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "arn:aws:sns:us-east-1:111122223333:bedrock-showback"
}
]
}
The Lambda: pull, aggregate, publish
The function asks for the last two full days at DAILY granularity so it can compute a day-over-day delta in one request. GetCostAndUsage accepts up to two group-by keys, so model and inference type come back together. It paginates on NextPageToken, because a busy account with many models will hit page limits sooner than you think.
import boto3, datetime, os
from collections import defaultdict
ce = boto3.client("ce", region_name="us-east-1")
cw = boto3.client("cloudwatch")
sns = boto3.client("sns")
def fetch(start, end, **extra):
args = dict(
TimePeriod={"Start": start, "End": end},
Granularity="DAILY",
Metrics=["UnblendedCost", "UsageQuantity"],
**extra,
)
while True:
resp = ce.get_cost_and_usage(**args)
yield from resp["ResultsByTime"]
if not resp.get("NextPageToken"):
break
args["NextPageToken"] = resp["NextPageToken"]
def handler(event, context):
today = datetime.date.today()
start = (today - datetime.timedelta(days=2)).isoformat()
end = today.isoformat()
cost = defaultdict(lambda: defaultdict(float)) # day -> model -> $
tokens = defaultdict(lambda: defaultdict(float)) # model -> type -> qty
for day in fetch(start, end, GroupBy=[
{"Type": "PRODUCT_ATTRIBUTE", "Key": "model"},
{"Type": "PRODUCT_ATTRIBUTE", "Key": "inferenceType"}]):
d = day["TimePeriod"]["Start"]
for g in day["Groups"]:
model, itype = g["Keys"]
cost[d][model] += float(g["Metrics"]["UnblendedCost"]["Amount"])
if d == start:
continue
tokens[model][itype.lower()] += float(g["Metrics"]["UsageQuantity"]["Amount"])
absent = sum(float(r["Total"]["UnblendedCost"]["Amount"]) for r in fetch(
start, end,
Filter={"ProductAttributes": {"Key": "provider", "MatchOptions": ["ABSENT"]}}))
if not cost:
return
days = sorted(cost)
prev, last = days[0], days[-1]
metrics, lines = [], []
for model, usd in sorted(cost[last].items(), key=lambda x: -x[1]):
delta = usd - cost[prev].get(model, 0.0)
t = tokens[model]
cache_read = sum(v for k, v in t.items() if "cache read" in k)
plain_in = sum(v for k, v in t.items() if "input" in k and "cache" not in k)
ratio = cache_read / (cache_read + plain_in) if (cache_read + plain_in) else 0.0
dims = [{"Name": "Model", "Value": model or "unlabeled"}]
metrics += [
{"MetricName": "DailyCost", "Dimensions": dims, "Value": usd, "Unit": "None"},
{"MetricName": "CacheHitRatio", "Dimensions": dims, "Value": ratio, "Unit": "None"},
]
lines.append(f"{model or 'unlabeled'}: ${usd:,.2f} ({delta:+,.2f}) cache {ratio:.0%}")
for i in range(0, len(metrics), 20):
cw.put_metric_data(Namespace="FinOps/Bedrock", MetricData=metrics[i:i+20])
lines.append(f"No-provider spend (Nova, legacy Grok, etc.): ${absent:,.2f}")
sns.publish(TopicArn=os.environ["TOPIC_ARN"],
Subject=f"Bedrock showback {last}",
Message="\n".join(lines))
Point the schedule at the function, set TOPIC_ARN, and give it a 60-second timeout. The boto3 Cost Explorer reference has the full response shapes if you want to extend it.

Reading the cache-hit ratio correctly
The ratio above is cache read tokens divided by all non-output input tokens for the day. A high, stable number on a chat workload with a long system prompt means prompt caching is doing its job. A sudden drop usually means someone edited the prompt prefix and broke the cache, which shows up as a cost spike one line above. Two caveats: UsageQuantity units can differ between line items, so trust the trend more than the absolute value, and cache write tokens are a separate line you may want to track as a third metric.
Put a CloudWatch alarm on DailyCost per premium model with a static threshold, and a second alarm on CacheHitRatio falling below its usual band. That pair catches most real incidents before the monthly bill does.
Why daily, not hourly
Every Cost Explorer API request is billed; the per-request rate is on the cost management pricing page. A daily job with pagination makes a handful of calls. Hourly polling multiplies that by 24 for data that lags anyway, and hourly granularity in Cost Explorer is a separate opt-in with its own charges. For runaway-spend detection inside the day, native Cost Anomaly Detection already exists and costs you no code. Let it watch the minutes; let your Lambda own the narrative.
Mapping models to business units with Cost Categories
Showback gets political the moment someone asks “whose model is that.” Cost Categories can map model or provider values to buckets such as “Customer Support AI” or “Internal Tools.” Build the rules in the console first and confirm the resulting values with a quick query. One gotcha carries over: when you call GetCostCategories, GetDimensionValues or GetTags with a ProductAttributes filter, a SERVICE filter is required alongside it, while GetCostAndUsage does not need one. Wrap that rule in a helper function so nobody rediscovers it at midnight.
Bedrock cost automation pitfalls
- Empty providers. Nova and
xai.grok-4.6report no provider, as Classmethod’s testing showed. That is why the job reports the ABSENT total explicitly instead of trusting provider sums. - The September 1 floor. Product attribute data starts 2026-09-01 UTC. Backfill scripts that reach further back fail with a validation error, so clamp start dates.
- SERVICE naming drift. Claude and GPT bill under “(Amazon Bedrock Edition)” service names, and new model families can bring new ones. Group by attributes, never by a hard-coded service list.
- Metric cardinality. Each model is a new CloudWatch dimension value. Twenty models is fine; a model zoo in every dev account adds up.
At Enkompass (enkompass.net), this is our home turf. We build scheduled showback jobs and per-model anomaly alarms on the Cost Explorer API for teams running several Bedrock providers in production. If you’d rather have seasoned cloud people handle it instead of learning the hard way at 2 AM, let’s talk. Reach out through our contact page or call us directly at +1-828-436-5667 — we’ll help you turn this from a someday-project into a done-deal.
