See also (wiki): llm-cost-attribution · agentic-token-governance
Scope: Enterprise teams with historical Bedrock API calls that were not tagged at invocation time — no inference profiles, no IAM session tags. Goal: produce a defensible FinOps report attributing that historical spend to projects and teams.
Research date: 2026-06-16
Contract strength classification: see Verification Ledger at the end.
No single AWS mechanism retroactively attributes untagged Bedrock spend to projects at request granularity. The practical path is a three-layer stack:
-
AWS Cost Categories (retroactive up to 12 months) — apply rule-based labels to CUR line items using the dimensions that were present at billing time: IAM role ARN (
line_item_iam_principal, available from April 8 2026 if the new CUR export was enabled), linked account, usage type, region. This is the highest-fidelity path if IAM principal data is present in your CUR. -
Bedrock invocation logs → Athena — if logging was enabled historically, reconstruct per-request attribution using
identity.arnandrequestMetadatafields already written to S3. This gives token-level detail and estimated cost but does not equal invoiced dollars. -
LiteLLM SpendLogs → reconciliation bridge — LiteLLM records
team_id,user,model,spend(rate-card price),prompt_tokens,completion_tokens,startTime, andrequest_idper call. Join to CUR at the (model × date) grain to convert LiteLLM’s estimated spend ratios into allocated invoiced dollars.
Hard constraint confirmed by official docs: CUR 2.0 does not carry a per-request identifier. There is no requestId column in CUR line items. The finest grain in CUR is per-usage-type per hour (or per day). Any join to invocation logs is therefore a proportional allocation, not a 1:1 record match.
1. Can You Retroactively Tag AWS Costs?
1a. Cost Allocation Tags — Backfill
AWS launched retroactive tag backfill in March 2024. The rules:
- Lookback window: Up to 12 calendar months before the backfill request date.
- Hard requirement: The resource tag must have been physically attached to the AWS resource during the period being backfilled. Backfill activates a tag’s visibility in billing data; it does not inject tag values that were never written.
- Bedrock implication: Bedrock inference has no “resource” in the traditional sense. The only attributable dimensions in billing are the IAM principal ARN (
line_item_iam_principal) and application inference profile ARNs. If no IAM principal tags were attached to the calling roles at the time of the calls, backfill cannot synthesize values that were never there. - What backfill does help: If you tagged IAM roles with
team=mantlesix months ago but never activated the tag in the Billing console, today you can activate it and backfill up to 12 months. CUR will be reprocessed and historical data will carry the tag values from the months when the tag was attached. - Mechanics: Console → Billing → Cost Allocation Tags → Backfill Tags → choose start month. One backfill per 24 hours. Updates Cost Explorer, CUR/Data Exports automatically within 24 hours.
CLI equivalent:
aws ce update-cost-allocation-tags-status \
--cost-allocation-tags-status TagKey=team,Status=Active
# Backfill only available via console as of June 2026;
# no direct CLI command for the backfill trigger itself.
1b. IAM Principal Data in CUR 2.0 — Not Retroactive
The INCLUDE_IAM_PRINCIPAL_DATA configuration on a CUR 2.0 export was launched April 8, 2026. It adds:
line_item_iam_principal— full IAM ARN of the Bedrock calleriamPrincipal/<key>columns in the tags column group — IAM principal tags with that prefix
Retroactivity: none. If your CUR export was created before April 8 2026 and you have not enabled this configuration, you must create a new CUR export with INCLUDE_IAM_PRINCIPAL_DATA = TRUE. Existing exports cannot be patched retroactively. Data is forward-looking only from the date the new export starts delivering.
Implication for historical spend: CUR line items prior to your new export’s start date will not carry line_item_iam_principal. For that window, Cost Categories rules using USAGE_TYPE (to isolate a model) plus LINKED_ACCOUNT (to isolate a team’s account) are the only native CUR-side attribution handles.
2. AWS Cost Categories as Post-Hoc Attribution
What Cost Categories Are
Cost Categories are rule-based cost grouping constructs that sit in the billing pipeline and add a categorical label to every matching CUR line item. Unlike tags (which require the tag to have been written to a resource), Cost Categories apply rules against billing-time dimensions that already exist in CUR.
Retroactive Lookback
Up to 12 months. When you create or edit a Cost Category, you choose an effective start month. You can set that month up to 12 months in the past. AWS reprocesses historical CUR data and backfills the category column. This is the most powerful retroactive mechanism available natively.
Supported Dimensions for Rules
The Cost Category rule engine operates on these dimensions (as of June 2026):
| Dimension | API key | Notes |
|---|---|---|
| Service | SERVICE |
e.g., Amazon Bedrock |
| Linked account | LINKED_ACCOUNT |
AWS account ID |
| Linked account name | LINKED_ACCOUNT_NAME |
— |
| Usage type | USAGE_TYPE |
e.g., USE1-Claude4.6Sonnet-input-tokens |
| Region | REGION |
— |
| Record type | RECORD_TYPE |
Usage, Tax, Credit, etc. |
| Billing entity | BILLING_ENTITY |
AWS vs. Marketplace |
| Cost allocation tags | existing activated tags | — |
| Another Cost Category | nested categories | — |
IAM_PRINCIPAL is not a supported dimension for Cost Category rules. Cost Categories cannot filter on line_item_iam_principal directly. The IAM principal ARN only enters rules indirectly, via activated iamPrincipal/<key> cost allocation tags (which require tag activation and are not retroactive for the pre-April-2026 window).
What This Means for Post-Hoc Role-Based Attribution
If your historical CUR (pre-April 2026) does not carry line_item_iam_principal, the highest-fidelity native approach is:
Usage type → model → Cost Category value
Because Bedrock line_item_usage_type encodes the model name, you can write rules like:
USAGE_TYPEcontainsClaude4.6Sonnet→ Cost Category:Claude-SonnetSERVICE=Amazon BedrockANDLINKED_ACCOUNT=123456789012→ Cost Category:Team-Mantle
This gives you model-level and account-level breakdowns retroactively. It does not give you role-level attribution from old CUR data without the line_item_iam_principal column.
CLI: Create a Cost Category with Retroactive Effective Date
aws ce create-cost-category-definition \
--name "Bedrock-Project-Attribution" \
--rule-version "CostCategoryExpression.v1" \
--effective-start "2025-07-01T00:00:00Z" \
--rules '[
{
"Value": "Project-Mantle",
"Rule": {
"And": [
{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Bedrock"], "MatchOptions": ["EQUALS"]}},
{"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": ["111122223333"], "MatchOptions": ["EQUALS"]}}
]
}
},
{
"Value": "Project-Atlas",
"Rule": {
"And": [
{"Dimensions": {"Key": "SERVICE", "Values": ["Amazon Bedrock"], "MatchOptions": ["EQUALS"]}},
{"Dimensions": {"Key": "USAGE_TYPE", "Values": ["Claude4.6Sonnet"], "MatchOptions": ["CONTAINS"]}}
]
}
}
]' \
--default-value "Unattributed-Bedrock"
# To update and push retroactive effective date back further:
aws ce update-cost-category-definition \
--cost-category-arn "arn:aws:ce::123456789012:costcategory/..." \
--effective-start "2025-06-01T00:00:00Z" \
--rule-version "CostCategoryExpression.v1" \
--rules '[...]' \
--default-value "Unattributed-Bedrock"
Cost Categories update CUR within 24 hours of creation/edit.
3. Bedrock Invocation Logs as the Retroactive Attribution Layer
What the Log Record Contains
Every Bedrock invocation log entry written to S3 (gzipped JSON batches) or CloudWatch Logs has this structure:
{
"schemaType": "ModelInvocationLog",
"schemaVersion": "1.0",
"timestamp": "2024-01-15T12:00:00Z",
"accountId": "123456789012",
"region": "us-east-1",
"requestId": "abcd1234-5678-efgh-ijkl-mnopqrstuvwx",
"operation": "Converse",
"modelId": "anthropic.claude-sonnet-4-20250514-v1:0",
"identity": {
"arn": "arn:aws:sts::123456789012:assumed-role/BedrockRole-Mantle/session-name"
},
"requestMetadata": {
"team": "mantle",
"environment": "production"
},
"input": {
"inputContentType": "application/json",
"inputBodyJson": {},
"inputTokenCount": 512
},
"output": {
"outputContentType": "application/json",
"outputBodyJson": {},
"outputTokenCount": 128
}
}
Key attribution fields available without any caller-side instrumentation:
identity.arn— automatically captured, always present; contains the role name and session namemodelId— maps directly to CURline_item_usage_typetimestamp— maps to CUR billing hour/dayinput.inputTokenCount,output.outputTokenCount— for cost estimation from token counts
Optional caller-set field:
requestMetadata— only present if the caller passedX-Amzn-Bedrock-Request-Metadataheader orrequestMetadatabody field. If logging was enabled but requestMetadata was not passed, this field is absent.
Does requestId Appear in CUR?
No. Confirmed by official AWS documentation:
“Neither classic CUR nor CUR 2.0 includes a per-request identifier on its line items. Both aggregate cost by usage type over an hour or a day.”
The requestId in invocation logs has no corresponding column in CUR. There is no 1:1 join possible between an individual log record and a CUR line item.
Athena Table DDL for Invocation Logs
CREATE EXTERNAL TABLE IF NOT EXISTS bedrocklogsdb.bedrock_invocation_logs (
schemaType STRING,
schemaVersion STRING,
timestamp TIMESTAMP,
accountId STRING,
region STRING,
requestId STRING,
operation STRING,
modelId STRING,
identity STRUCT<arn: STRING>,
requestMetadata MAP<STRING, STRING>,
input STRUCT<
inputContentType: STRING,
inputTokenCount: INT,
inputBodyJson: STRING
>,
output STRUCT<
outputContentType: STRING,
outputTokenCount: INT,
outputBodyJson: STRING
>
)
PARTITIONED BY (datehour STRING)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES ('serialization.format' = '1')
LOCATION 's3://<your-log-bucket>/AWSLogs/<account-id>/BedrockModelInvocationLogs/<region>/'
TBLPROPERTIES (
"projection.enabled" = "true",
"projection.datehour.type" = "date",
"projection.datehour.range" = "2024/01/01/00,NOW",
"projection.datehour.format" = "yyyy/MM/dd/HH",
"projection.datehour.interval" = "1",
"projection.datehour.interval.unit" = "HOURS",
"storage.location.template" = "s3://<your-log-bucket>/AWSLogs/<account-id>/BedrockModelInvocationLogs/<region>/${datehour}"
);
Set up partitions:
# Use Glue crawler instead of manual MSCK for gzipped JSON
aws glue create-crawler \
--name bedrock-logs-crawler \
--role arn:aws:iam::123456789012:role/GlueCrawlerRole \
--database-name bedrocklogsdb \
--targets '{"S3Targets": [{"Path": "s3://<your-log-bucket>/AWSLogs/123456789012/BedrockModelInvocationLogs/us-east-1/"}]}' \
--schedule '{"ScheduleExpression": "cron(0 2 * * ? *)"}'
aws glue start-crawler --name bedrock-logs-crawler
Attribution Query: Token Usage by IAM Role and Model
-- Token usage by IAM role (project proxy) and model
-- Apply Bedrock per-token rates to get estimated cost
SELECT
identity.arn AS iam_principal,
modelId,
DATE_TRUNC('day', timestamp) AS usage_date,
SUM(input.inputTokenCount) AS total_input_tokens,
SUM(output.outputTokenCount) AS total_output_tokens,
COUNT(*) AS total_requests,
-- Estimate cost — replace rates with current Bedrock pricing page values
SUM(input.inputTokenCount) * 0.000003 AS est_input_cost_usd,
SUM(output.outputTokenCount) * 0.000015 AS est_output_cost_usd
FROM bedrocklogsdb.bedrock_invocation_logs
WHERE datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00'
GROUP BY identity.arn, modelId, DATE_TRUNC('day', timestamp)
ORDER BY est_input_cost_usd DESC;
-- If requestMetadata.team was set by some callers
SELECT
requestMetadata['team'] AS team,
modelId,
SUM(input.inputTokenCount) AS total_input_tokens,
SUM(output.outputTokenCount) AS total_output_tokens,
SUM(input.inputTokenCount) * 0.000003 +
SUM(output.outputTokenCount) * 0.000015 AS est_cost_usd
FROM bedrocklogsdb.bedrock_invocation_logs
WHERE datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00'
AND requestMetadata IS NOT NULL
GROUP BY requestMetadata['team'], modelId
ORDER BY est_cost_usd DESC;
-- Extract project from role ARN (assumes role naming convention includes project)
-- e.g., arn:aws:sts::...:assumed-role/BedrockRole-Mantle/session → Mantle
SELECT
REGEXP_EXTRACT(identity.arn, 'assumed-role/([^/]+)/', 1) AS role_name,
modelId,
SUM(input.inputTokenCount) AS input_tokens,
SUM(output.outputTokenCount) AS output_tokens,
COUNT(*) AS calls
FROM bedrocklogsdb.bedrock_invocation_logs
WHERE datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00'
GROUP BY REGEXP_EXTRACT(identity.arn, 'assumed-role/([^/]+)/', 1), modelId
ORDER BY input_tokens DESC;
The CUR Reconciliation Join
Since CUR aggregates to usage-type × hour, the join is proportional allocation:
-- Step 1: Compute each role's share of tokens for a given model+day from logs
WITH log_shares AS (
SELECT
DATE_TRUNC('day', timestamp) AS usage_date,
-- Normalize modelId to match CUR usage_type (requires a lookup or CASE)
CASE
WHEN modelId LIKE '%claude-sonnet-4%' THEN 'Claude4.6Sonnet'
WHEN modelId LIKE '%claude-haiku%' THEN 'ClaudeHaiku'
WHEN modelId LIKE '%nova-lite%' THEN 'NovaPro'
ELSE modelId
END AS model_key,
REGEXP_EXTRACT(identity.arn, 'assumed-role/([^/]+)/', 1) AS role_name,
SUM(input.inputTokenCount) AS role_input_tokens,
SUM(output.outputTokenCount) AS role_output_tokens
FROM bedrocklogsdb.bedrock_invocation_logs
WHERE datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00'
GROUP BY 1, 2, 3
),
total_by_model_day AS (
SELECT
usage_date,
model_key,
SUM(role_input_tokens) AS total_input_tokens,
SUM(role_output_tokens) AS total_output_tokens
FROM log_shares
GROUP BY 1, 2
),
shares AS (
SELECT
ls.usage_date,
ls.model_key,
ls.role_name,
ls.role_input_tokens * 1.0 / NULLIF(t.total_input_tokens, 0) AS input_share,
ls.role_output_tokens * 1.0 / NULLIF(t.total_output_tokens, 0) AS output_share
FROM log_shares ls
JOIN total_by_model_day t
ON ls.usage_date = t.usage_date AND ls.model_key = t.model_key
),
-- Step 2: Pull CUR line items for Bedrock, aggregated by model+day
cur_bedrock AS (
SELECT
DATE_TRUNC('day', CAST(line_item_usage_start_date AS TIMESTAMP)) AS usage_date,
line_item_usage_type,
SUM(line_item_unblended_cost) AS invoiced_cost
FROM "your_cur_database"."COST_AND_USAGE_REPORT"
WHERE line_item_product_code = 'AmazonBedrock'
AND line_item_line_item_type = 'Usage'
GROUP BY 1, 2
)
-- Step 3: Allocate invoiced cost to roles using log-derived shares
SELECT
s.usage_date,
s.role_name,
cur.line_item_usage_type,
s.input_share,
cur.invoiced_cost * s.input_share AS allocated_cost_usd
FROM shares s
JOIN cur_bedrock cur
ON s.usage_date = cur.usage_date
AND cur.line_item_usage_type LIKE '%' || s.model_key || '%'
ORDER BY s.usage_date, allocated_cost_usd DESC;
Caveats on this join:
- Token counts in logs may not perfectly match CUR due to cache tokens (read/write), batch API pricing, provisioned throughput, and rounding. Always reconcile totals first before trusting the allocation.
- The model_key mapping requires manual maintenance as modelId strings change with new model versions.
- Cross-region inference routes through a different usage type (
-cross-region-globalsuffix); ensure your join handles both.
4. AWS Cost Anomaly Detection
Monitor Types Available
Cost Anomaly Detection supports four monitor types:
- AWS Services — monitors all AWS services; Bedrock is included. A single managed monitor covers the entire service.
- Linked Accounts — monitors per account; can be scoped to detect Bedrock-driven anomalies within an account.
- Cost Allocation Tags — monitors per tag value; useful after IAM principal tags are activated.
- Cost Categories — monitors per Cost Category value; most actionable once your retroactive Cost Categories are in place.
Bedrock-Specific Monitoring
There is no “per-model” monitor type. You cannot scope a monitor to a single Bedrock model ID. The practical approach:
- Create a Cost Category with Bedrock model or project buckets (as described in Section 2).
- Create a Cost Anomaly Detection monitor of type Cost Category, scoped to the new category.
- Each project bucket then gets anomaly detection on its allocated spend.
AWS CLI:
# Create anomaly monitor scoped to a Cost Category
aws ce create-anomaly-monitor \
--anomaly-monitor '{
"MonitorName": "Bedrock-Project-Attribution-Monitor",
"MonitorType": "CUSTOM",
"MonitorSpecification": {
"CostCategories": {
"Key": "Bedrock-Project-Attribution",
"Values": ["Project-Mantle", "Project-Atlas"]
}
}
}'
# Create subscription to alert
aws ce create-anomaly-subscription \
--anomaly-subscription '{
"SubscriptionName": "Bedrock-Attribution-Alerts",
"MonitorArnList": ["arn:aws:ce::123456789012:anomalymonitor/..."],
"Subscribers": [{"Address": "finops@example.com", "Type": "EMAIL"}],
"Threshold": 50,
"Frequency": "DAILY"
}'
Attribution value: Cost Anomaly Detection tells you when spend spikes, not why or which request. It is an alerting mechanism, not an attribution mechanism. Use it alongside the Cost Categories + invocation logs stack, not instead of it.
5. Third-Party FinOps Tools
CloudHealth (VMware/Broadcom)
Ingests CUR natively. Supports cost allocation rules, custom groupings, and report scheduling. For Bedrock attribution, it operates on the same CUR dimensions as native AWS Cost Categories — it cannot add line_item_iam_principal to historical data that AWS did not put there. No special Bedrock retroactive capability beyond what CUR contains.
Apptio Cloudability (IBM)
Finance-grade chargeback reporting, audit-ready exports. Like CloudHealth, it ingests CUR and applies allocation rules post-ingest. Useful for enterprises running a monthly cloud cost close — it produces CFO-level reports with multi-level cost hierarchies. For historical Bedrock attribution, it will work if you first enrich CUR via Cost Categories (so the dimension is present when Cloudability ingests). Does not independently reconstruct IAM principal attribution from CloudTrail or invocation logs.
Finout
Finout’s “Virtual Tags” layer can ingest CUR and other data sources (CloudWatch metrics, invocation logs via S3/API), merge them, and apply retroactive reallocation rules across the combined dataset. They explicitly support “retroactively tag and reallocate unattributed spend” by pulling token count data from CloudWatch and merging it with CUR cost. This is the most capable third-party option for enterprises that have invocation logging enabled but lacked billing-time tags. Caveat: the merged cost is still an estimate based on token-count ratios; it is not invoice-accurate at the project level without CUR-side attribution handles.
Vantage
Multi-cloud FinOps with a dedicated AI/LLM cost section. Offers per-model Bedrock spend breakdowns. Retroactive attribution depth is limited to what CUR and Cost Explorer expose. Supports FOCUS 1.x export. Lighter-weight than Cloudability for teams that don’t need chargeback reports.
FOCUS (FinOps Open Cost and Usage Specification)
FOCUS 1.3 was ratified December 2025; FOCUS 1.4 was ratified June 2026. AWS supports FOCUS 1.0 exports via Data Exports (GA). FOCUS normalizes columns like BilledCost, EffectiveCost, ResourceId, ServiceName, SubAccountId, Tags across providers.
For Bedrock retroactive attribution: FOCUS does not add retroactive capability. It normalizes whatever AWS puts in CUR into a cross-provider schema. The line_item_iam_principal column is not yet part of the FOCUS spec as of v1.4; it appears as a provider-specific extension. FOCUS is most useful for organizations running Bedrock alongside Azure OpenAI or GCP Vertex — a single schema to query both.
Open Source Options
No purpose-built open source tool for Bedrock post-hoc attribution exists as of June 2026. The closest patterns:
- aws-samples/bedrock-agents-security-log-summarizer — Glue + Athena pipeline for Bedrock logs, not billing-focused
- DIY stack described in Section 6 below
6. Practical Architecture: 90-Day Retroactive Attribution
Inputs available:
- (a) Bedrock invocation logs in S3 — up to 90 days of history,
identity.arnpresent for all records - (b) LiteLLM SpendLogs in PostgreSQL —
request_id,team_id,user,model,spend,prompt_tokens,completion_tokens,startTime,endTime,metadata - © CUR 2.0 in S3/Athena — invoiced dollars aggregated by usage type × day (no requestId, no IAM principal if export predates April 8 2026)
Architecture Diagram
┌─────────────────────────────────────────────────────────────────────┐
│ Source Layer │
│ │
│ S3: Bedrock invocation logs Postgres: LiteLLM_SpendLogs │
│ (identity.arn, modelId, tokens, (request_id, team_id, user, │
│ requestId, timestamp) spend, model, startTime) │
│ │
│ S3/Athena: CUR 2.0 │
│ (invoiced cost by usage_type × day) │
└──────────────┬──────────────────────────────────────┬───────────────┘
│ │
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────────────┐
│ Bedrock Logs → Athena │ │ LiteLLM Export → S3 / Athena │
│ (Glue crawler, hourly part) │ │ (pg_dump or Airbyte connector) │
│ │ │ or direct Athena Federated Query │
└──────────────┬───────────────┘ └──────────────────┬───────────────┘
│ │
└──────────────┬───────────────────────┘
│
▼
┌───────────────────────────────────┐
│ Attribution Layer (Athena SQL) │
│ │
│ 1. Log-based share computation: │
│ tokens per role per model/day │
│ │
│ 2. LiteLLM team_id join: │
│ enrich role→team mapping │
│ │
│ 3. CUR proportional allocation: │
│ role_share × CUR_invoiced_cost │
└───────────────┬───────────────────┘
│
▼
┌───────────────────────────────────┐
│ FinOps Report Layer │
│ (QuickSight / Superset / Redash) │
│ │
│ Output: project × model × │
│ month → allocated invoiced $ │
│ + estimated cost (LiteLLM) │
│ + reconciliation gap % │
└───────────────────────────────────┘
Step 1: Export LiteLLM SpendLogs to S3
-- In Postgres: export to CSV for S3 upload or use Airbyte
COPY (
SELECT
request_id,
call_type,
spend,
total_tokens,
prompt_tokens,
completion_tokens,
"startTime",
"endTime",
model,
model_group,
custom_llm_provider,
api_base,
"user",
team_id,
organization_id,
end_user,
request_tags::text AS request_tags,
metadata::text AS metadata,
session_id,
agent_id
FROM "LiteLLM_SpendLogs"
WHERE "startTime" >= NOW() - INTERVAL '90 days'
)
TO '/tmp/litellm_spendlogs.csv'
WITH (FORMAT CSV, HEADER);
Then upload to S3 and create Athena table:
aws s3 cp /tmp/litellm_spendlogs.csv s3://your-finops-bucket/litellm-logs/
CREATE EXTERNAL TABLE litellm_spendlogs (
request_id STRING,
call_type STRING,
spend DOUBLE,
total_tokens INT,
prompt_tokens INT,
completion_tokens INT,
starttime TIMESTAMP,
endtime TIMESTAMP,
model STRING,
model_group STRING,
custom_llm_provider STRING,
api_base STRING,
user_id STRING,
team_id STRING,
organization_id STRING,
end_user STRING,
request_tags STRING,
metadata STRING,
session_id STRING,
agent_id STRING
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY ','
LOCATION 's3://your-finops-bucket/litellm-logs/'
TBLPROPERTIES ('skip.header.line.count'='1');
Step 2: Build the Attribution Bridge
-- Cross-reference LiteLLM team_id with Bedrock log identity.arn
-- This works when LiteLLM was calling Bedrock under a known role
-- and you can map role → team from IAM or your CMDB.
-- Create role→team mapping table (manual or from IAM tagging)
CREATE TABLE role_team_map AS
SELECT *
FROM (VALUES
('BedrockRole-Mantle', 'Project-Mantle'),
('BedrockRole-Atlas', 'Project-Atlas'),
('BedrockRole-Shared', 'Shared-Platform')
) AS t(role_name, project_name);
-- Alternatively, derive team_id from LiteLLM logs if LiteLLM called Bedrock
-- (requestId in LiteLLM SpendLogs == requestId in Bedrock invocation logs)
WITH litellm_bedrock_join AS (
SELECT
l.request_id,
l.team_id,
l.model,
l.prompt_tokens,
l.completion_tokens,
l.spend AS litellm_estimated_spend,
b.identity.arn AS iam_principal,
b.timestamp
FROM litellm_spendlogs l
JOIN bedrocklogsdb.bedrock_invocation_logs b
ON l.request_id = b.requestId -- works only if LiteLLM requestId = Bedrock requestId
WHERE b.datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00'
)
SELECT
team_id,
model,
DATE_TRUNC('day', timestamp) AS usage_date,
SUM(prompt_tokens) AS total_prompt_tokens,
SUM(completion_tokens) AS total_completion_tokens,
SUM(litellm_estimated_spend) AS litellm_estimated_cost
FROM litellm_bedrock_join
GROUP BY 1, 2, 3;
Note on the LiteLLM → Bedrock requestId join: LiteLLM’s request_id is the LiteLLM-internal UUID assigned at the proxy layer, not necessarily the Bedrock requestId. To use this join, verify that LiteLLM stores the downstream Bedrock requestId in its metadata JSON column. If it does not, fall back to joining at (model × timestamp) with a 1-second tolerance window, which is probabilistic but usable for aggregate allocation.
-- Fallback join: LiteLLM × Bedrock on timestamp proximity + model match
-- Only use when requestId join is not available
SELECT
l.team_id,
l.model,
l.request_id AS litellm_id,
b.requestId AS bedrock_id,
l.prompt_tokens,
l.completion_tokens
FROM litellm_spendlogs l
JOIN bedrocklogsdb.bedrock_invocation_logs b
ON l.model LIKE '%' || SPLIT_PART(b.modelId, '.', 2) || '%'
AND ABS(TO_UNIXTIME(l.starttime) - TO_UNIXTIME(b.timestamp)) < 2 -- within 2 seconds
WHERE b.datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00';
Step 3: CUR Reconciliation — Convert Estimates to Invoice Dollars
-- Final reconciliation: allocate invoiced CUR cost to teams
-- using the log-derived share of tokens per model per day
WITH team_token_shares AS (
SELECT
DATE_TRUNC('day', b.timestamp) AS usage_date,
CASE
WHEN b.modelId LIKE '%claude-sonnet-4%' THEN 'Claude4.6Sonnet'
WHEN b.modelId LIKE '%claude-haiku%' THEN 'ClaudeHaiku'
WHEN b.modelId LIKE '%nova-lite%' THEN 'NovaPro'
ELSE SPLIT_PART(b.modelId, '.', 2)
END AS model_key,
COALESCE(l.team_id,
rtm.project_name,
REGEXP_EXTRACT(b.identity.arn, 'assumed-role/([^/]+)/', 1)
) AS project,
SUM(b.input.inputTokenCount) AS input_tokens,
SUM(b.output.outputTokenCount) AS output_tokens
FROM bedrocklogsdb.bedrock_invocation_logs b
LEFT JOIN litellm_spendlogs l ON l.request_id = b.requestId
LEFT JOIN role_team_map rtm
ON REGEXP_EXTRACT(b.identity.arn, 'assumed-role/([^/]+)/', 1) = rtm.role_name
WHERE b.datehour BETWEEN '2025/10/01/00' AND '2026/04/01/00'
GROUP BY 1, 2, 3
),
model_day_totals AS (
SELECT usage_date, model_key,
SUM(input_tokens) AS total_input,
SUM(output_tokens) AS total_output
FROM team_token_shares
GROUP BY 1, 2
),
team_shares AS (
SELECT
ts.usage_date,
ts.model_key,
ts.project,
ts.input_tokens * 1.0 / NULLIF(md.total_input, 0) AS input_share,
ts.output_tokens * 1.0 / NULLIF(md.total_output, 0) AS output_share
FROM team_token_shares ts
JOIN model_day_totals md
ON ts.usage_date = md.usage_date AND ts.model_key = md.model_key
),
cur_daily AS (
SELECT
DATE_TRUNC('day', CAST(line_item_usage_start_date AS TIMESTAMP)) AS usage_date,
line_item_usage_type,
-- Extract model key from usage type (adjust regex per your usage type patterns)
REGEXP_EXTRACT(line_item_usage_type, '-(Claude[^-]+|Nova[^-]+|gpt[^-]+)-', 1) AS model_key,
CASE
WHEN line_item_usage_type LIKE '%-input-tokens%' THEN 'input'
WHEN line_item_usage_type LIKE '%-output-tokens%' THEN 'output'
WHEN line_item_usage_type LIKE '%-cache-read%' THEN 'cache_read'
WHEN line_item_usage_type LIKE '%-cache-write%' THEN 'cache_write'
ELSE 'other'
END AS token_type,
SUM(line_item_unblended_cost) AS invoiced_cost
FROM "your_cur_database"."COST_AND_USAGE_REPORT"
WHERE line_item_product_code = 'AmazonBedrock'
AND line_item_line_item_type = 'Usage'
AND line_item_usage_start_date >= '2025-10-01'
GROUP BY 1, 2, 3, 4
)
-- Final allocation table
SELECT
ts.usage_date,
ts.project,
cd.line_item_usage_type,
cd.token_type,
cd.invoiced_cost,
CASE
WHEN cd.token_type = 'input' THEN cd.invoiced_cost * ts.input_share
WHEN cd.token_type = 'output' THEN cd.invoiced_cost * ts.output_share
ELSE cd.invoiced_cost / COUNT(*) OVER (PARTITION BY cd.usage_date, cd.model_key)
END AS allocated_cost_usd,
ts.input_share,
ts.output_share
FROM team_shares ts
JOIN cur_daily cd
ON ts.usage_date = cd.usage_date
AND cd.model_key = ts.model_key
ORDER BY ts.usage_date, ts.project, cd.token_type;
Step 4: Reconciliation Check
Before trusting allocations, verify total reconciliation. The gap should be < 5%; larger gaps indicate missing log coverage (e.g., requests made when logging was off) or cache tokens not counted in logs.
-- Reconciliation: sum of allocated costs should equal sum of CUR invoiced costs
SELECT
DATE_TRUNC('month', usage_date) AS month,
SUM(allocated_cost_usd) AS total_allocated,
SUM(invoiced_cost) AS total_invoiced,
SUM(invoiced_cost) - SUM(allocated_cost_usd) AS reconciliation_gap,
100.0 * (SUM(invoiced_cost) - SUM(allocated_cost_usd)) / SUM(invoiced_cost) AS gap_pct
FROM (
-- ... final allocation query above ...
)
GROUP BY 1
ORDER BY 1;
Decision Matrix: Which Mechanism for What
| Situation | Best mechanism | Retroactive? | Invoice-accurate? |
|---|---|---|---|
| Separate AWS accounts per team | Cost Categories on LINKED_ACCOUNT |
Yes, 12 months | Yes |
IAM roles named per project, CUR export has line_item_iam_principal (post April 8 2026) |
CUR query on line_item_iam_principal |
No (forward only) | Yes |
| IAM roles named per project, CUR export lacks IAM principal data | Invocation logs → Athena → role-based share × CUR | Yes (if logs exist) | Estimated; ~±5% |
LiteLLM proxy used with team_id set |
LiteLLM SpendLogs join to Bedrock logs | Yes (if logs exist) | Estimated |
| No invocation logs, no role separation | Cost Categories on USAGE_TYPE (model-level only) |
Yes, 12 months | Yes (model-level, not project-level) |
| On-going attribution (future spend) | Enable IAM principal data in new CUR export + activate tags | n/a | Yes |
Immediate Action Plan
- Today: Create new CUR 2.0 export with
INCLUDE_IAM_PRINCIPAL_DATA = TRUE. Add IAM principal tags (project,team) to all Bedrock-calling roles. Activate those tags in Billing console. - Today: Check whether Bedrock invocation logging was enabled before your attribution gap window. If yes, proceed to Step 3.
- This week: Run Glue crawler against the Bedrock log S3 bucket. Validate Athena table returns records. Run the token-by-role query as a sanity check.
- This week: Create Cost Categories with retroactive effective date covering your attribution window, using
LINKED_ACCOUNTandUSAGE_TYPErules. This handles the CUR-side attribution for model- and account-level breakdowns immediately. - Next two weeks: Export LiteLLM SpendLogs to S3/Athena. Build the three-way join (logs × LiteLLM × CUR) for the full allocated cost report.
- Ongoing: Set up Cost Anomaly Detection monitors scoped to your new Cost Categories so future attribution drift triggers alerts.
Verification Ledger
| Claim | Source | Confidence |
|---|---|---|
| Tag backfill lookback is 12 months | AWS Billing docs — Backfill cost allocation tags | Official docs verified |
| Tag backfill requires tag was historically attached to resource | Same source | Official docs verified |
| Cost Categories retroactive lookback is 12 months | AWS What’s New, Sept 2022 | Official docs verified |
| Cost Categories supported dimensions: SERVICE, LINKED_ACCOUNT, USAGE_TYPE, REGION, RECORD_TYPE, BILLING_ENTITY | CostCategoryRule API | Official docs verified |
| IAM_PRINCIPAL is not a Cost Categories dimension | API reference inspection; absence confirmed | Official docs verified (by absence) |
line_item_iam_principal column in CUR 2.0, launched April 8 2026 |
CUR 2.0 table dictionary; AWS What’s New | Official docs verified |
| IAM principal data in CUR is not retroactive | Bedrock IAM attribution docs | Official docs verified |
| CUR does not contain per-requestId line items | Bedrock CUR understanding docs | Official docs verified |
Invocation log requestId has no CUR counterpart |
Per-request metadata docs | Official docs verified |
Invocation log schema: all fields including identity.arn, requestMetadata |
Model invocation logging docs | Official docs verified |
| Athena DDL with hourly projection partitioning | AWS BI blog — Bedrock Athena/QuickSight | Official docs verified |
| LiteLLM SpendLogs schema (Prisma) | litellm/schema.prisma on GitHub | Source code verified |
| Finout Virtual Tags for retroactive Bedrock reallocation | Finout blog | Community/vendor claim |
| FOCUS 1.3 ratified Dec 2025, 1.4 ratified June 2026 | FOCUS site | Official docs verified |
| Cost Anomaly Detection now supports Cost Categories monitors | AWS What’s New Nov 2025 | Official docs verified |
Remaining Gaps
- LiteLLM
request_id↔ BedrockrequestIdmapping: Not confirmed in LiteLLM source. Needs local experiment: enable LiteLLM debug logging and check whether the downstream Bedrock requestId is written tometadata. If not, the timestamp-proximity join is the fallback. - Cache token handling in logs: Invocation logs record
inputTokenCountandoutputTokenCountbut the docs do not confirm whether cache read/write tokens appear as separate fields in the log schema or are folded into input. This affects reconciliation accuracy for cache-heavy workloads. - Cost Categories + IAM principal tags combination: Whether a Cost Category rule can match on an
iamPrincipal/teamtag (after activation) with retroactive application needs a live test. The dimensions list includes “cost allocation tags” but retroactive coverage requires the tag to have been present in historical data — which for IAM principal tags means the IAM principal had the tag and was making Bedrock calls and the CUR export hadINCLUDE_IAM_PRINCIPAL_DATAenabled — a chain that doesn’t hold for pre-April-2026 data. - bedrock-mantle endpoint gap: IAM principal attribution and invocation logging do not yet cover
bedrock-mantle(Responses API) calls. If any historical spend came through that endpoint, it is unattributable via either mechanism.