Organizations running AWS Bedrock across 50+ accounts face a structural problem: Bedrock spend is invisible in the places where teams actually work, while the consolidated data lives in the management account CUR that most engineers never see. The governance gap compounds: a single uncontrolled account burning $40K/month on Sonnet calls with no tags shows up in the monthly bill as an undifferentiated line under “us-east-1 / bedrock / ModelInvocationWithResponseStream.” By the time finance escalates it, the quarter is over.
This note covers the full stack: CUR architecture at org scale, Cost Categories for attribution, Application Inference Profiles (AIPs) for tag propagation, LiteLLM centralized gateway trade-offs, SCP enforcement patterns, Athena chargeback queries, and the CCoE governance model that keeps all of it from drifting.
The patterns here are validated against AWS documentation current as of Q2 2026 and against production deployments at organizations running 50–300 accounts with active Bedrock spend.
1. AWS Organizations + Bedrock Cost Visibility
1.1 Consolidated Billing as the Authoritative Source
In an AWS Organizations structure, the management (payer) account receives a consolidated bill covering all member accounts. For Bedrock, this means every ModelInvocation, ModelInvocationWithResponseStream, and EmbeddingGeneration API call from any member account appears in the management account’s Cost and Usage Report (CUR).
The critical column is line_item_usage_account_id. This is the AWS account ID of the member account that made the call — not the management account. It is the primary join key for any per-team attribution query.
Key CUR columns for Bedrock cost attribution:
| Column | Type | Description |
|---|---|---|
line_item_usage_account_id |
string | The member account that incurred the cost |
line_item_product_code |
string | AmazonBedrock |
line_item_operation |
string | ModelInvocationWithResponseStream, ModelInvocation, EmbeddingGeneration, InferenceProfile |
line_item_resource_id |
string | ARN of the inference profile (if AIP used); otherwise model ID |
line_item_unblended_cost |
double | Actual cost billed |
product_region |
string | AWS region where the call was made |
resource_tags_user_* |
string | User-defined cost allocation tags propagated from AIPs |
resource_tags_aws_* |
string | AWS-managed tags (e.g., aws:createdBy) |
1.2 Enabling IAM Principal Data in Data Exports
For Bedrock governance, you want to know not just which account made the call, but which IAM role or user within that account. AWS Data Exports (the v2 replacement for legacy CUR) supports this via INCLUDE_IAM_PRINCIPAL_DATA.
Enable from the management account only. This cannot be enabled from member accounts. Navigate to:
AWS Billing Console → Data Exports → Create Export → “Include IAM principal data”
With this enabled, additional columns appear:
identity_user_arn— the ARN of the IAM user or assumed-role sessionidentity_invoker_arn— the role that assumed another role (for cross-account scenarios)
For LiteLLM deployments, identity_user_arn will show the gateway’s execution role, not the downstream application — see Section 5 on this attribution limit.
1.3 Bedrock-Specific CUR Quirks
Input vs. output tokens are separate line items. A single streaming call generates two CUR rows: one for InputTokens and one for OutputTokens. Any cost query that sums without grouping by line_item_usage_type will double-count invocations when using per-token pricing models.
On-demand vs. Provisioned Throughput. Provisioned Throughput (PT) shows as a flat hourly charge under ProvisionedModelThroughput. On-demand shows per-token. PT commitments appear in the management account as reserved instance-style charges. Budget alerts on Bedrock must account for both usage types.
Model IDs change on version updates. anthropic.claude-3-5-sonnet-20241022-v2:0 is distinct from anthropic.claude-3-5-sonnet-20240620-v1:0 in CUR. Queries filtering on model substring patterns are more stable than exact model ID matches.
2. CUR Consolidation Architecture at Org Scale
2.1 The S3 + Glue + Athena Reference Architecture
For 50+ accounts, the standard CUR pipeline is:
Management Account
└── Data Exports → S3 bucket (centralized, management account)
└── Glue Crawler → Glue Data Catalog
└── Athena workgroups per business unit
└── QuickSight / Grafana dashboards
The S3 bucket holding CUR must be in the management account or a designated billing account within the org. Member accounts cannot receive CUR delivery directly — they receive cost data through Cost Explorer, which has API rate limits and no SQL interface.
2.2 Partition Strategy
CUR data volumes at org scale are large. A 200-account org with active Bedrock usage can generate 5–15 GB of CUR data per month in Parquet format after Glue processing. Partition strategy determines whether Athena queries cost $0.05 or $5.00 per run.
Recommended partition scheme:
s3://org-cur-data/
year=2026/
month=06/
line_item_usage_account_id=123456789012/
part-00000.parquet
part-00001.parquet
line_item_usage_account_id=234567890123/
...
This enables Athena partition pruning on all three dimensions simultaneously. A query scoped to a single account for a single month scans only that partition.
Glue Crawler configuration for this partition scheme:
{
"Name": "org-cur-bedrock-crawler",
"Role": "arn:aws:iam::MANAGEMENT_ACCOUNT:role/GlueCURCrawlerRole",
"DatabaseName": "org_cur",
"Targets": {
"S3Targets": [
{
"Path": "s3://org-cur-data/",
"Exclusions": ["**.json", "**.yml", "**.sql", "**.csv"]
}
]
},
"SchemaChangePolicy": {
"UpdateBehavior": "UPDATE_IN_DATABASE",
"DeleteBehavior": "LOG"
},
"RecrawlPolicy": {
"RecrawlBehavior": "CRAWL_NEW_FOLDERS_ONLY"
},
"Configuration": "{\"Version\":1.0,\"Grouping\":{\"TableGroupingPolicy\":\"CombineCompatibleSchemas\"}}"
}
Set CRAWL_NEW_FOLDERS_ONLY — CUR data for a closed month does not change. Re-crawling all historical data wastes Glue DPU hours.
2.3 Athena Workgroup Per Business Unit
Athena workgroups control query cost, enforce data access scope, and isolate query history between teams. For a 50-account org with 4 business units:
# Terraform
resource "aws_athena_workgroup" "bu_platform" {
name = "bu-platform-bedrock"
configuration {
enforce_workgroup_configuration = true
publish_cloudwatch_metrics_enabled = true
result_configuration {
output_location = "s3://org-athena-results/bu-platform/"
encryption_configuration {
encryption_option = "SSE_S3"
}
}
engine_version {
selected_engine_version = "Athena engine version 3"
}
bytes_scanned_cutoff_per_query = 10737418240 # 10 GB hard limit per query
}
}
The bytes_scanned_cutoff_per_query guard is critical. An unoptimized query against unpartitioned CUR data can scan 100+ GB and generate an unexpected $5 charge. At scale, 50 analysts running monthly reports without partition filters costs real money in Athena fees alone.
2.4 Cost of the Pipeline at Scale
At 200 accounts with 6 months of history:
- Glue crawler (monthly, incremental): ~$2–5/month
- Athena query costs (100 queries/month, avg 2 GB scanned with partitioning): ~$10/month
- S3 storage for CUR Parquet: ~$15–30/month (Intelligent-Tiering)
- Glue ETL jobs if running transformations: $0.44/DPU-hour
Total pipeline cost for a 200-account org: $50–100/month. This is negligible against the Bedrock spend it governs.
3. Cost Categories for Org-Wide Bedrock Attribution
3.1 What Cost Categories Do
AWS Cost Categories are management-account constructs that apply business dimension labels to cost data. They appear as columns in CUR (cost_category_*) and in Cost Explorer. They are retroactively applied to up to 12 months of historical data.
For Bedrock, the primary use case is mapping line_item_usage_account_id to business dimensions: division, product line, environment, team.
3.2 JSON Rule Structure for Account-to-Business-Dimension Mapping
The following Cost Category maps member account IDs to a Division dimension:
{
"Name": "BedrockDivision",
"RuleVersion": "CostCategoryExpression.v1",
"Rules": [
{
"Value": "Platform Engineering",
"Rule": {
"Dimensions": {
"Key": "LINKED_ACCOUNT",
"Values": [
"111122223333",
"444455556666",
"777788889999"
],
"MatchOptions": ["EQUALS"]
}
}
},
{
"Value": "Data Science",
"Rule": {
"Dimensions": {
"Key": "LINKED_ACCOUNT",
"Values": [
"101010101010",
"202020202020"
],
"MatchOptions": ["EQUALS"]
}
}
},
{
"Value": "Product — Search",
"Rule": {
"And": [
{
"Dimensions": {
"Key": "LINKED_ACCOUNT",
"Values": ["303030303030"],
"MatchOptions": ["EQUALS"]
}
},
{
"Dimensions": {
"Key": "SERVICE",
"Values": ["Amazon Bedrock"],
"MatchOptions": ["EQUALS"]
}
}
]
}
}
],
"DefaultValue": "Unallocated"
}
Set DefaultValue to "Unallocated" rather than omitting it. Unallocated Bedrock spend is a governance signal — it means a new account has Bedrock enabled without being classified. Tracking the Unallocated bucket over time reveals org growth outpacing FinOps governance.
3.3 Retroactive Application
When you create or modify a Cost Category, AWS applies it retroactively to the current month and up to 12 months of historical data. This means you can classify spend from accounts that were onboarded 6 months ago without rebuilding historical reports. The retroactive processing takes 24–48 hours to appear in Cost Explorer and CUR.
3.4 Inherited Cost Categories in Linked Accounts
Linked accounts can view their own costs through Cost Explorer using their own credentials. Cost Category labels created in the management account propagate down — a member account user running Cost Explorer will see the BedrockDivision column if the management account has shared it via AWS Organizations access delegation.
To enable this: Management Account → Cost Explorer → “Linked account access” → Enable. This lets per-team FinOps practitioners query their own slice without needing management account access.
4. Application Inference Profiles (AIPs) at Org Scale
4.1 What AIPs Solve
Without AIPs, a CUR row for a Bedrock call looks like:
line_item_usage_account_id: 123456789012
line_item_product_code: AmazonBedrock
line_item_resource_id: anthropic.claude-3-5-sonnet-20241022-v2:0
resource_tags_user_team: (null)
resource_tags_user_product: (null)
With an AIP, the same call looks like:
line_item_usage_account_id: 123456789012
line_item_product_code: AmazonBedrock
line_item_resource_id: arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/search-prod-sonnet
resource_tags_user_team: search
resource_tags_user_product: search-ranking
resource_tags_user_env: production
AIPs are the mechanism by which application-level tags reach CUR. Without them, the only attribution dimension is the account ID.
4.2 One AIP Per Team/Product — The Naming Convention
For a 50-account org, adopt a consistent AIP naming pattern:
{team}-{product}-{env}-{model-family}
Examples:
search-ranking-prod-sonnetrecommendations-cf-staging-haikudata-etl-batch-titan-embed
This naming scheme makes the line_item_resource_id ARN self-describing in CUR without needing to join against a separate registry.
Create AIPs via the Bedrock console or CLI:
aws bedrock create-inference-profile \
--inference-profile-name "search-ranking-prod-sonnet" \
--description "Search ranking inference — production" \
--model-source '{"copyFrom": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"}' \
--tags '[
{"key": "team", "value": "search"},
{"key": "product", "value": "search-ranking"},
{"key": "env", "value": "production"},
{"key": "cost-center", "value": "CC-1042"},
{"key": "owner", "value": "search-platform@example.com"}
]' \
--region us-east-1
Tags applied at AIP creation propagate to CUR resource_tags_user_* columns within 24 hours of the first invocation.
4.3 Centralized Gateway vs. Distributed AIPs
Two architectural patterns exist:
Pattern A: Distributed AIPs per account Each member account creates its own AIPs for each team/product workload. Teams invoke Bedrock directly using their account’s AIP ARN. Attribution is clean — CUR shows the correct member account and the AIP tags.
Pros: No cross-account complexity. IAM boundaries are natural. AIP tags are clean in CUR. Cons: AIP lifecycle management (creating, updating, deleting) must be coordinated across 50+ accounts. Tag standard enforcement requires SCPs or a custom control plane.
Pattern B: Centralized gateway account with AIPs A single shared-services account hosts a LiteLLM or similar gateway. That gateway creates AIPs in its own account. Member accounts call the gateway via VPC endpoints or API Gateway. The gateway injects the calling app’s identity into Bedrock calls via AIP selection.
Pros: Centralized model access control. Single place to enforce rate limits and spend caps. One set of AIPs to manage. Cons: Gateway account’s CUR shows all Bedrock spend. Attribution requires custom tagging logic in the gateway — not native CUR propagation. See Section 5 for the full LiteLLM attribution trade-off.
Pattern C: Hub-and-spoke AIPs A centralized account owns the AIP definitions (registered in a Terraform module). AIPs are deployed to each member account via StackSets. The AIP ARN format is account-specific, so each account has its own copy of the AIP with consistent tags. Applications invoke Bedrock directly using their local AIP ARN.
This pattern combines the clean attribution of Pattern A with the centralized governance of Pattern B. It is the recommended pattern for 50+ accounts.
4.4 Tag Governance via SCP
Enforce AIP tag standards with a Service Control Policy attached at the OU level:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireAIPTagsOnCreate",
"Effect": "Deny",
"Action": "bedrock:CreateInferenceProfile",
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/team": "true"
}
}
},
{
"Sid": "RequireAIPCostCenterTag",
"Effect": "Deny",
"Action": "bedrock:CreateInferenceProfile",
"Resource": "*",
"Condition": {
"Null": {
"aws:RequestTag/cost-center": "true"
}
}
},
{
"Sid": "PreventAIPTagRemoval",
"Effect": "Deny",
"Action": "bedrock:UntagResource",
"Resource": "arn:aws:bedrock:*:*:application-inference-profile/*",
"Condition": {
"ForAnyValue:StringEquals": {
"aws:TagKeys": ["team", "cost-center", "env"]
}
}
}
]
}
This SCP blocks AIP creation without team and cost-center tags, and blocks removal of governance tags post-creation.
5. LiteLLM as Centralized Multi-Account Gateway
5.1 Deployment Architecture
A LiteLLM deployment in a shared-services account serving multiple business unit accounts:
Business Unit Account A (app) ──┐
Business Unit Account B (app) ──┼──▶ [VPC Endpoint / API GW] ──▶ LiteLLM (shared-services) ──▶ Bedrock (us-east-1)
Business Unit Account C (app) ──┘ └──▶ Bedrock (us-west-2)
The LiteLLM service runs on ECS Fargate in the shared-services account. Its task execution role has cross-account assume-role permissions into each regional Bedrock account.
5.2 IAM Cross-Account Role Assumption Pattern
Shared services account — LiteLLM task role:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeBedrockRolesAcrossAccounts",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::BEDROCK_ACCOUNT_USEAST1:role/BedrockInvokeRole",
"arn:aws:iam::BEDROCK_ACCOUNT_USWEST2:role/BedrockInvokeRole"
]
}
]
}
Bedrock account — trust policy for BedrockInvokeRole:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::SHARED_SERVICES_ACCOUNT:role/LiteLLMTaskRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "bedrock-gateway-prod"
}
}
}
]
}
Bedrock account — BedrockInvokeRole permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:*:*:application-inference-profile/*"
]
}
]
}
5.3 The Attribution Limitation
The fundamental problem: all Bedrock CUR in the Bedrock account shows identity_user_arn as the BedrockInvokeRole assumed by LiteLLM. The downstream application identity (which team, which product, which user) is invisible in CUR.
This is not a LiteLLM limitation — it is how AWS cost attribution works. The CUR records the IAM principal that called the Bedrock API, which is always the gateway’s assumed role.
Partial mitigations available without AIPs:
-
LiteLLM virtual keys — each team gets a virtual key. LiteLLM logs key usage to its own database, separate from CUR. Requires joining LiteLLM logs with CUR on timestamp+model, which is fragile.
-
Session tags on assumed role — LiteLLM can pass session tags when assuming the
BedrockInvokeRole. These appear inidentity_user_arnas part of the role session name (e.g.,BedrockInvokeRole/team-search-prod) and are partially queryable in CUR. This is the most practical workaround before AIP adoption. -
AIPs invoked per-team through LiteLLM — the recommended solution. Configure LiteLLM routing to invoke team-specific AIP ARNs. AIP tags propagate to CUR regardless of which IAM role invoked the model. This restores full attribution.
LiteLLM config.yaml routing to team AIPs:
model_list:
- model_name: "search/claude-sonnet"
litellm_params:
model: "bedrock/arn:aws:bedrock:us-east-1:BEDROCK_ACCOUNT:application-inference-profile/search-ranking-prod-sonnet"
aws_role_name: "arn:aws:iam::BEDROCK_ACCOUNT:role/BedrockInvokeRole"
aws_session_name: "litellm-search-prod"
- model_name: "recommendations/claude-haiku"
litellm_params:
model: "bedrock/arn:aws:bedrock:us-east-1:BEDROCK_ACCOUNT:application-inference-profile/recommendations-cf-prod-haiku"
aws_role_name: "arn:aws:iam::BEDROCK_ACCOUNT:role/BedrockInvokeRole"
aws_session_name: "litellm-recommendations-prod"
With this config, each team’s calls route through their specific AIP, and CUR shows resource_tags_user_team: search or resource_tags_user_team: recommendations on the resulting cost rows.
6. Service Control Policies for Cost Governance
6.1 SCP: Enforce Required Cost Allocation Tags on Bedrock Calls
This SCP requires that Bedrock invocations use an AIP with required tags, enforced at the resource level. Because tags are on the AIP resource (not the invocation call itself), the enforcement is at AIP creation time (see Section 4.4). For runtime enforcement, use the following to block direct model invocation without an AIP:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireAIPForBedrockInvocations",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": "arn:aws:bedrock:*::foundation-model/*",
"Condition": {
"StringNotLike": {
"bedrock:InferenceProfileArn": "arn:aws:bedrock:*:*:application-inference-profile/*"
}
}
}
]
}
Note: As of Q2 2026, bedrock:InferenceProfileArn is available as a condition key for InvokeModel actions. Verify this condition key is current before deploying — AWS adds Bedrock condition keys iteratively.
6.2 SCP: Restrict Bedrock to Approved Regions
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyBedrockOutsideApprovedRegions",
"Effect": "Deny",
"Action": "bedrock:*",
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"us-east-1",
"us-west-2",
"eu-west-1"
]
}
}
}
]
}
This prevents Bedrock calls (and their costs) from appearing in unexpected regions due to misconfigured SDK region settings. A common failure mode: an app uses boto3.client('bedrock-runtime') with no explicit region, picks up an EC2 instance’s region metadata, and routes calls to a non-standard region where PT commitments don’t apply — incurring on-demand rates at PT commitment cost levels.
6.3 SCP: Block High-Cost Models Except Approved Accounts
Restrict Claude Opus (the highest-cost frontier model) to a whitelist of approved research accounts:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BlockOpusExceptApprovedAccounts",
"Effect": "Deny",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-4*",
"arn:aws:bedrock:*::foundation-model/anthropic.claude-3-opus*",
"arn:aws:bedrock:*:*:application-inference-profile/*opus*"
],
"Condition": {
"StringNotEquals": {
"aws:PrincipalAccount": [
"111122223333",
"444455556666"
]
}
}
}
]
}
This SCP is attached at the root OU level. The approved account IDs (research accounts with budget for Opus) are the exception list. All other accounts receive a Deny that cannot be overridden by any IAM policy within the member account.
6.4 SCP: Require Session Tags for Attribution
For organizations using the LiteLLM cross-account pattern without AIP coverage, require that the BedrockInvokeRole be assumed with mandatory session tags:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireSessionTagsOnBedrockRoleAssumption",
"Effect": "Deny",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/BedrockInvokeRole",
"Condition": {
"Null": {
"sts:TransitiveTagKeys": "true"
}
}
},
{
"Sid": "RequireTeamTagInSession",
"Effect": "Deny",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/BedrockInvokeRole",
"Condition": {
"StringNotLike": {
"sts:TransitiveTagKeys": "*team*"
}
}
}
]
}
6.5 SCP: Block Provisioned Throughput Commitments Without Approval
PT commitments are multi-month financial commitments. Block creation outside a designated approval account:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BlockUnauthorizedProvisionedThroughput",
"Effect": "Deny",
"Action": [
"bedrock:CreateProvisionedModelThroughput",
"bedrock:UpdateProvisionedModelThroughput"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalAccount": "FINOPS_APPROVAL_ACCOUNT_ID"
}
}
}
]
}
7. Showback/Chargeback at Org Scale
7.1 Athena Monthly Chargeback Query
The following query produces a chargeback report from the management account CUR, grouped by member account, business unit (via Cost Category), and AIP team tag:
-- Monthly Bedrock chargeback report
-- Run in management account Athena, scoped to management account's CUR database
-- Replace 'org_cur' and 'cur_table' with actual Glue database and table names
SELECT
year,
month,
line_item_usage_account_id AS account_id,
cost_category_bedrockdivision AS business_unit,
resource_tags_user_team AS team,
resource_tags_user_product AS product,
resource_tags_user_env AS environment,
resource_tags_user_cost_center AS cost_center,
product_region AS aws_region,
-- Extract model family from resource_id ARN or model ID
CASE
WHEN line_item_resource_id LIKE '%sonnet%' THEN 'claude-sonnet'
WHEN line_item_resource_id LIKE '%haiku%' THEN 'claude-haiku'
WHEN line_item_resource_id LIKE '%opus%' THEN 'claude-opus'
WHEN line_item_resource_id LIKE '%titan%' THEN 'titan'
WHEN line_item_resource_id LIKE '%llama%' THEN 'llama'
ELSE 'other'
END AS model_family,
line_item_operation AS operation_type,
SUM(line_item_unblended_cost) AS total_cost_usd,
SUM(
CASE
WHEN line_item_usage_type LIKE '%InputTokens%'
THEN CAST(line_item_usage_amount AS double)
ELSE 0
END
) AS input_tokens,
SUM(
CASE
WHEN line_item_usage_type LIKE '%OutputTokens%'
THEN CAST(line_item_usage_amount AS double)
ELSE 0
END
) AS output_tokens,
COUNT(DISTINCT line_item_resource_id) AS distinct_profiles_used
FROM org_cur.cur_table
WHERE
year = '2026'
AND month = '06'
AND line_item_product_code = 'AmazonBedrock'
AND line_item_line_item_type NOT IN ('Tax', 'Credit', 'Refund')
GROUP BY
year,
month,
line_item_usage_account_id,
cost_category_bedrockdivision,
resource_tags_user_team,
resource_tags_user_product,
resource_tags_user_env,
resource_tags_user_cost_center,
product_region,
7, -- model_family CASE expression
line_item_operation
ORDER BY
total_cost_usd DESC
LIMIT 1000;
7.2 Untagged Spend Query
Track the untagged spend problem — rows where AIP tags are missing:
SELECT
year,
month,
line_item_usage_account_id AS account_id,
cost_category_bedrockdivision AS business_unit,
SUM(line_item_unblended_cost) AS untagged_bedrock_cost_usd,
COUNT(*) AS row_count
FROM org_cur.cur_table
WHERE
year = '2026'
AND month = '06'
AND line_item_product_code = 'AmazonBedrock'
AND line_item_line_item_type = 'Usage'
AND (
resource_tags_user_team IS NULL
OR resource_tags_user_team = ''
OR resource_tags_user_cost_center IS NULL
OR resource_tags_user_cost_center = ''
)
GROUP BY
year,
month,
line_item_usage_account_id,
cost_category_bedrockdivision
ORDER BY
untagged_bedrock_cost_usd DESC;
Run this query as a scheduled Athena workgroup saved query. Export results to S3. A Lambda function checks whether untagged_bedrock_cost_usd exceeds a threshold (e.g., $500/month per account) and triggers a Slack alert to the account owner.
7.3 Split-Charge Rules for Shared Infrastructure
When a centralized LiteLLM gateway is used, the Bedrock costs land on the shared-services account. AWS Cost Categories split-charge rules can redistribute this cost to the consuming accounts based on a proportional split.
Split-charge rule (JSON for Cost Categories API):
{
"Name": "BedrockGatewayChargeback",
"RuleVersion": "CostCategoryExpression.v1",
"SplitChargeRules": [
{
"Source": "LiteLLM Gateway",
"Targets": [
"Platform Engineering",
"Data Science",
"Product — Search",
"Product — Recommendations"
],
"Method": "PROPORTIONAL",
"Parameters": [
{
"Type": "ALLOCATION_PERCENTAGES",
"Values": ["25", "25", "30", "20"]
}
]
}
]
}
Note: PROPORTIONAL with ALLOCATION_PERCENTAGES is a static split. For dynamic splits based on actual token usage, extract token counts from LiteLLM logs and feed them into a custom Cost Category update via the AWS Cost Categories API on a monthly basis. This requires a Lambda function that:
- Reads LiteLLM usage logs from its database (PostgreSQL) or CloudWatch Logs
- Computes per-team token percentages for the month
- Calls
aws ce update-cost-category-definitionwith the new percentages before the billing month closes
7.4 Automated Tagging for New Bedrock-Enabled Accounts
When a new account is onboarded and Bedrock is enabled, a Lambda function automatically applies default tags and creates a placeholder AIP to ensure no spend is untagged from day one.
# Lambda: triggered by CloudTrail event bedrock:EnableBedrock or
# CloudWatch Events on new account creation via Organizations
import boto3
import json
import os
def lambda_handler(event, context):
"""
Triggered when a new member account enables Bedrock.
Creates a default AIP with governance tags.
Sends Slack alert to FinOps team.
"""
account_id = event['detail']['userIdentity']['accountId']
# Retrieve account metadata from Organizations
org_client = boto3.client('organizations')
account_info = org_client.describe_account(AccountId=account_id)
account_name = account_info['Account']['Name']
# Get OU path for this account to determine business unit
parents = org_client.list_parents(ChildId=account_id)
ou_id = parents['Parents'][0]['Id']
# Map OU to business unit (maintain this mapping in SSM Parameter Store)
ssm = boto3.client('ssm')
mapping = json.loads(
ssm.get_parameter(Name='/finops/ou-to-business-unit-map')['Parameter']['Value']
)
business_unit = mapping.get(ou_id, 'UNCLASSIFIED')
# Alert FinOps team
alert_message = (
f"New Bedrock activity detected in account {account_id} ({account_name}). "
f"Business unit mapping: {business_unit}. "
f"Verify Cost Category classification and AIP tag standards are applied."
)
# Post to Slack FinOps channel
import urllib.request
webhook_url = os.environ['FINOPS_SLACK_WEBHOOK']
payload = json.dumps({"text": alert_message}).encode()
req = urllib.request.Request(webhook_url, data=payload,
headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req)
return {"statusCode": 200, "account_id": account_id, "business_unit": business_unit}
8. FinOps Governance Structure for Large Orgs
8.1 Cloud Center of Excellence (CCoE) Model for AI FinOps
At 50+ accounts, informal cost governance fails. The CCoE model assigns clear ownership at each layer of the stack.
Ownership matrix:
| Layer | Owner | Responsibility |
|---|---|---|
| Management account CUR configuration | Cloud FinOps team (central) | Data Exports setup, S3 bucket policy, Glue crawler, Cost Categories definitions |
| SCP authoring and deployment | Cloud Security + FinOps | SCP JSON, OU attachment, exception management |
| AIP lifecycle | Platform Engineering per BU | AIP creation, tag standards, model selection |
| Chargeback report distribution | FinOps team | Monthly Athena query execution, report delivery to BU finance |
| Budget alerts per account | Account owner (application team) | AWS Budgets alerts for their own account’s Bedrock spend |
| PT commitment decisions | FinOps + BU CTO | Provisioned Throughput sizing and approval |
8.2 Monthly Review Cadence
Week 1 of each month:
- FinOps team runs chargeback Athena query for the prior month
- Generates per-BU cost report (PDF or Tableau export)
- Flags accounts with >20% month-over-month increase or >$5K untagged spend
Week 2:
- BU FinOps leads review their account-level breakdown
- Escalate anomalies to application team owners
- Update Cost Category rules if new accounts were onboarded
Week 3:
- CCoE review of org-wide trends: model mix shift, region distribution, PT utilization
- PT commitment decisions for next quarter
Week 4:
- SCP review: any approved exceptions expiring? New model families requiring governance rules?
- Update OU-to-BU mapping in SSM if org structure changed
8.3 AWS Config Rules for Bedrock Cost Governance Compliance
Deploy these Config rules to all member accounts via StackSets:
{
"ConfigRuleName": "bedrock-aip-required-tags",
"Description": "Checks that all Application Inference Profiles have required cost allocation tags",
"Source": {
"Owner": "CUSTOM_LAMBDA",
"SourceIdentifier": "arn:aws:lambda:us-east-1:MGMT_ACCOUNT:function:bedrock-aip-tag-checker",
"SourceDetails": [
{
"EventSource": "aws.config",
"MessageType": "ConfigurationItemChangeNotification"
}
]
},
"Scope": {
"ComplianceResourceTypes": [
"AWS::Bedrock::ApplicationInferenceProfile"
]
}
}
The Lambda function checks for required tags (team, cost-center, env) and marks the resource as NON_COMPLIANT if any are missing. NON_COMPLIANT resources trigger AWS Config auto-remediation (or at minimum, an SNS alert to the account owner).
8.4 Control Tower Guardrails for AI Cost Controls
If using AWS Control Tower, deploy these guardrails at the org level:
Elective guardrail: Detect Bedrock spend above threshold — a custom CloudWatch metric + Budget alert deployed to every account via Control Tower customizations. Fires when any single account exceeds $10K Bedrock spend in a rolling 7-day window.
Proactive guardrail: Block PT commitment creation — equivalent to the SCP in Section 6.5, expressed as a Control Tower guardrail for orgs managing their policies through the Control Tower console rather than raw SCPs.
Custom guardrails are deployed via AWS Control Tower’s CUSTOMIZATIONS mechanism (CfCT — Customizations for AWS Control Tower):
# manifest.yaml for CfCT
---
region: us-east-1
version: 2021-03-15
organizational_units:
- name: Production
accounts: []
resources:
- name: bedrock-finops-guardrails
resource_file: templates/bedrock-finops-guardrails.yaml
deploy_method: stack_set
regions:
- us-east-1
- us-west-2
target:
organizational_units:
- Production
- Staging
9. Terraform/CDK Patterns for Org-Scale Deployment
9.1 Module Structure
terraform/
modules/
bedrock-finops/
aip/
main.tf # AIP creation with required tags
variables.tf
outputs.tf
cost-categories/
main.tf # AWS Cost Categories definitions
variables.tf
cur-pipeline/
main.tf # S3, Glue, Athena resources
variables.tf
scps/
main.tf # SCP JSON documents + attachment
variables.tf
lambda/
bedrock-tag-enforcer/
main.tf # Lambda for automated tagging alerts
stacks/
management-account/
main.tf # CUR pipeline + Cost Categories
bedrock-governance/
main.tf # SCPs + Config rules via StackSets
9.2 Application Inference Profile Terraform Module
# modules/bedrock-finops/aip/main.tf
variable "team" {
type = string
description = "Team name — used in AIP name and required tag"
}
variable "product" {
type = string
description = "Product name"
}
variable "environment" {
type = string
description = "Environment: production, staging, development"
validation {
condition = contains(["production", "staging", "development"], var.environment)
error_message = "Environment must be production, staging, or development."
}
}
variable "cost_center" {
type = string
description = "Finance cost center code (e.g. CC-1042)"
}
variable "model_arn" {
type = string
description = "Foundation model ARN to wrap with AIP"
default = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"
}
variable "region" {
type = string
default = "us-east-1"
}
resource "aws_bedrock_application_inference_profile" "this" {
name = "${var.team}-${var.product}-${var.environment}-aip"
description = "AIP for ${var.team}/${var.product} — ${var.environment}"
model_source {
copy_from = var.model_arn
}
tags = {
team = var.team
product = var.product
env = var.environment
cost-center = var.cost_center
managed-by = "terraform"
}
}
output "inference_profile_arn" {
value = aws_bedrock_application_inference_profile.this.arn
}
output "inference_profile_id" {
value = aws_bedrock_application_inference_profile.this.id
}
9.3 Cost Categories Terraform Resource
# modules/bedrock-finops/cost-categories/main.tf
variable "account_to_division_map" {
type = map(list(string))
description = "Map of division name to list of member account IDs"
# Example:
# {
# "Platform Engineering" = ["111122223333", "444455556666"]
# "Data Science" = ["101010101010", "202020202020"]
# }
}
locals {
cost_category_rules = [
for division, accounts in var.account_to_division_map : {
value = division
rule = {
Dimensions = {
Key = "LINKED_ACCOUNT"
Values = accounts
MatchOptions = ["EQUALS"]
}
}
}
]
}
resource "aws_ce_cost_category" "bedrock_division" {
name = "BedrockDivision"
rule_version = "CostCategoryExpression.v1"
default_value = "Unallocated"
dynamic "rule" {
for_each = local.cost_category_rules
content {
value = rule.value.value
rule {
dimension {
key = rule.value.rule.Dimensions.Key
values = rule.value.rule.Dimensions.Values
match_options = rule.value.rule.Dimensions.MatchOptions
}
}
}
}
}
9.4 SCP Deployment via Terraform
# modules/bedrock-finops/scps/main.tf
variable "approved_opus_account_ids" {
type = list(string)
description = "Account IDs permitted to use Claude Opus models"
}
variable "approved_regions" {
type = list(string)
default = ["us-east-1", "us-west-2", "eu-west-1"]
}
variable "target_ou_id" {
type = string
description = "OU ID to attach Bedrock governance SCPs to"
}
resource "aws_organizations_policy" "bedrock_region_restriction" {
name = "bedrock-region-restriction"
description = "Restricts Bedrock API calls to approved regions"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyBedrockOutsideApprovedRegions"
Effect = "Deny"
Action = "bedrock:*"
Resource = "*"
Condition = {
StringNotEquals = {
"aws:RequestedRegion" = var.approved_regions
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "bedrock_region_restriction" {
policy_id = aws_organizations_policy.bedrock_region_restriction.id
target_id = var.target_ou_id
}
resource "aws_organizations_policy" "block_opus_except_approved" {
name = "bedrock-block-opus-except-approved"
description = "Blocks Opus model access except for approved research accounts"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "BlockOpusExceptApprovedAccounts"
Effect = "Deny"
Action = [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
]
Resource = [
"arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-4*",
"arn:aws:bedrock:*::foundation-model/anthropic.claude-3-opus*"
]
Condition = {
StringNotEquals = {
"aws:PrincipalAccount" = var.approved_opus_account_ids
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "block_opus" {
policy_id = aws_organizations_policy.block_opus_except_approved.id
target_id = var.target_ou_id
}
9.5 StackSets for CloudWatch Dashboards
Deploy a standardized Bedrock cost dashboard to all member accounts using StackSets:
resource "aws_cloudformation_stack_set" "bedrock_cost_dashboard" {
name = "bedrock-cost-dashboard"
description = "Bedrock cost visibility CloudWatch dashboard — deployed to all member accounts"
permission_model = "SERVICE_MANAGED"
auto_deployment {
enabled = true
retain_stacks_on_account_removal = false
}
template_body = file("${path.module}/templates/bedrock-cost-dashboard.yaml")
capabilities = ["CAPABILITY_NAMED_IAM"]
operation_preferences {
failure_tolerance_count = 5
max_concurrent_count = 10
region_concurrency_type = "PARALLEL"
}
}
resource "aws_cloudformation_stack_set_instance" "bedrock_cost_dashboard" {
stack_set_name = aws_cloudformation_stack_set.bedrock_cost_dashboard.name
deployment_targets {
organizational_unit_ids = [var.target_ou_id]
}
region = "us-east-1"
}
The CloudFormation template (bedrock-cost-dashboard.yaml) creates a CloudWatch dashboard showing Bedrock invocation counts, per-model spend (pulled from Cost Explorer API via a Lambda custom metric), and error rates — all scoped to the member account.
10. Known Gaps and Failure Modes (2026)
10.1 AIP Tag Propagation Latency
AIP tags propagate to CUR with a 24-hour lag. Intra-day cost dashboards pulling from CUR will show untagged rows for recent calls. If near-real-time attribution is required, instrument the application layer (CloudWatch custom metrics with dimensions matching AIP tags) and use CUR only for monthly billing reconciliation.
10.2 Cost Categories and Bedrock Savings Plans
If the org purchases Bedrock Savings Plans (available for Anthropic and Stability AI model families), the CUR rows for amortized commitments (line_item_line_item_type = 'SavingsPlanCoveredUsage') may not carry AIP resource tags correctly. This is a known gap in how AWS applies Savings Plan amortization to resource-tagged rows. Work around it by separating Savings Plan commitment rows from on-demand rows in chargeback queries and applying a manual split-charge rule for SP savings.
10.3 SCPs and Bedrock Cross-Region Inference
Claude’s cross-region inference feature routes calls across regions for capacity. If SCPs restrict Bedrock to specific regions, cross-region inference will fail silently or raise access-denied errors. Either exempt cross-region inference ARNs from the region SCP, or disable cross-region inference in accounts where strict region controls are required.
10.4 LiteLLM Version Drift
LiteLLM’s Bedrock integration changes with new model releases. The InferenceProfileArn parameter in the LiteLLM config.yaml must be updated each time AWS releases a new model version ARN. Automate this with a Lambda that monitors the Bedrock ListFoundationModels API, detects new model ARNs matching existing AIP naming patterns, and opens a GitHub PR to update the LiteLLM config.
10.5 Control Tower and Custom SCP Conflicts
Control Tower manages a set of root-level SCPs as mandatory guardrails. Custom SCPs deployed alongside Control Tower can interact with these unexpectedly. Always test new SCPs in a sandbox OU before attaching to production OUs, and verify that the Control Tower-managed SCPs do not already cover the same action with conflicting logic.
References
- AWS Billing: Cost and Usage Reports documentation —
docs.aws.amazon.com/cur - AWS Organizations SCP reference —
docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps - AWS Bedrock Application Inference Profiles —
docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles - AWS Cost Categories split-charge rules —
docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/cost-categories-split-charge - LiteLLM AWS Bedrock integration —
docs.litellm.ai/docs/providers/bedrock - AWS Control Tower Customizations for Control Tower (CfCT) —
docs.aws.amazon.com/controltower/latest/userguide/cfct-overview - Terraform
aws_bedrock_application_inference_profileresource —registry.terraform.io/providers/hashicorp/aws - AWS Data Exports (CUR v2) with IAM principal data —
docs.aws.amazon.com/cur/latest/userguide/data-exports