TL;DR
- Amazon Bedrock has five distinct mechanisms for attributing inference cost, and I tested all five live in an account: application inference profiles, Projects/Workspaces (the same resource, two API surfaces), IAM identity tags, STS session tags, and request metadata joined to invocation logs.
- Four of the five reach real AWS billing data (Cost Explorer / Cost and Usage Report). All four are bound to a resource or an identity you provision ahead of time — none vary per individual API call.
- The fifth, request metadata, is the only one that’s per-call, but it lands in logs, not the bill. You compute the dollar figure yourself, from token counts — a real number, but not necessarily the invoiced one, since it can’t see discounts or commitments applied at the account level.
- For a multi-tenant SaaS product, per-customer billing isn’t a cloud-tagging problem at all — it’s an application-layer metering problem, and AWS’s own Well-Architected guidance says so directly.
The five mechanisms
Every one of these was tested with real API calls against a live Bedrock account, not read off a docs page. Each does something genuinely different, and picking the wrong one for your use case is where most cost-attribution confusion starts.
1. Application inference profiles — tag a model, not a call
An inference profile is a named resource that wraps a specific model. Tag the profile, route calls through it, and the tag shows up on the resulting billing line items.
aws bedrock create-inference-profile \
--inference-profile-name "team-search-claude" \
--model-source copyFrom="arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-haiku-4-5" \
--tags Key=team,Value=search Key=cost-center,Value=eng-42
Every call that specifies this profile’s ARN as the model ID gets billed under it, and the team / cost-center tags become groupable dimensions in Cost Explorer once activated. The catch: one profile per model per cost dimension. Ten teams sharing five models means fifty profiles to create and keep in sync.
2. Projects and Workspaces — one resource, two entry points
A Project is a billing-scoped container that can span multiple models, which fixes the per-model constraint of inference profiles. Workspaces are the same underlying resource, referenced from a different API surface — not two systems to set up, one resource with two names depending on which API you’re calling.
curl -X POST https://bedrock-mantle.us-east-1.api.aws/v1/organization/projects \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "search-team-project", "tags": {"team": "search", "cost-center": "eng-42"}}'
That’s the Chat Completions surface — an OpenAI-shaped API, scoped to the project via an OpenAI-Project: proj_xxxx header. The same project, called from the Anthropic Messages surface instead, uses a different header name and needs anthropic_version in the body:
curl -X POST https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-workspace: proj_xxxx" \
-d '{
"model": "anthropic.claude-sonnet-5",
"max_tokens": 50,
"anthropic_version": "bedrock-2023-05-31",
"messages": [{"role": "user", "content": "Say ok in one word"}]
}'
I called both surfaces against the same project ID and confirmed both route to the identical billing bucket — same tags, same cost line item, whichever header you use to get there.
3. IAM identity tags — tag the caller, not the call
Tag the IAM role or user making the calls, and the tag rides along on every request that role makes.
aws iam tag-role --role-name bedrock-search-service \
--tags Key=team,Value=search Key=cost-center,Value=eng-42
Once the tag key is activated as a cost allocation tag, it shows up prefixed iamPrincipal/team in the billing export’s tag column, alongside the caller’s ARN in a dedicated line_item_iam_principal field. This is the mechanism that scales best across many models without creating a resource per dimension — one tagged role covers every model that role touches.
The limit is architectural, not a missing feature: the tag lives on the identity, not the request. If one shared service role serves ten thousand different end-customers, the bill sees one tagged identity, not ten thousand.
4. Session tags — a different tag per login, not per role
STS lets you attach tags at the moment a role is assumed, distinct from the role’s own static tags. This is the mechanism that gets you closer to per-tenant, because a federated identity provider can mint a differently-tagged session for every login.
Wiring this up: a Cognito Identity Pool (or any OIDC/SAML identity provider — Okta, Auth0, Entra ID all support the identical pattern) maps a custom user attribute to a principal tag.
aws cognito-identity set-principal-tag-attribute-map \
--identity-pool-id "us-east-1:xxxx" \
--identity-provider-name "cognito-idp.us-east-1.amazonaws.com/us-east-1_xxxx" \
--principal-tags tenant_id=custom:tenant_id
The IAM role being assumed has to explicitly trust sts:TagSession, not only sts:AssumeRoleWithWebIdentity — leaving it off doesn’t produce an obvious tagging error, it produces an unrelated-looking InvalidIdentityPoolConfigurationException. I hit this omission once and it cost me twenty minutes tracing it back to the trust policy.
Getting the tag to actually show up in the billing export requires two separate switches, both easy to miss: INCLUDE_IAM_PRINCIPAL_DATA has to be turned on in the export’s table configuration, and the export’s own query has to explicitly select the tags column — a separate column from resource_tags, which is where I looked first and found nothing. Missing either switch produces a report with no error and no tenant breakdown, which reads exactly like the tag isn’t working when it actually is.
This mechanism scales to per-tenant, but the cost is real: minting a session per tenant means caching credentials properly. A naive implementation that re-authenticates on every inbound request will hit identity-provider rate limits well before it hits any meaningful production traffic — Cognito’s GetCredentialsForIdentity, for instance, defaults to 200 requests per second, account-wide. The fix is the same one every AWS SDK’s credential provider already implements: cache the session for its lifetime, refresh a few minutes before expiry, never mint one per request.
5. Request metadata — the only per-call mechanism, and it skips the bill
Every other mechanism attributes cost by who’s calling. This one attributes by what’s in the call, and it’s the only one that’s genuinely per-request.
aws bedrock-runtime converse \
--model-id us.anthropic.claude-haiku-4-5-20251001-v1:0 \
--messages '[{"role":"user","content":[{"text":"hi"}]}]' \
--request-metadata '{"customer_id":"cust-042","feature":"chat-widget"}'
That metadata lands in CloudWatch invocation logs — automatically, alongside token counts — the moment you turn on model invocation logging. It does not appear anywhere in Cost Explorer or the Cost and Usage Report. To get a dollar figure, you query the logs and multiply by rate.
The part that’s easy to get wrong: Bedrock bills four token types per call, not two — input, output, cache read, and cache write — each at a different rate, and all four sit in the same log record. Cache reads are priced roughly 90% below standard input; cache writes are priced roughly 25% above it. Skip the cache fields and any workload using prompt caching gets badly undercounted, since a cache-write call can carry more cost in its cache-write tokens than in its input and output tokens combined. The query needs all four:
filter ispresent(requestMetadata.customer_id)
| fields requestMetadata.customer_id as customer_id,
input.inputTokenCount as inputTokens,
input.cacheReadInputTokenCount as cacheReadTokens,
input.cacheWriteInputTokenCount as cacheWriteTokens,
output.outputTokenCount as outputTokens,
(input.inputTokenCount * INPUT_RATE)
+ (input.cacheReadInputTokenCount * CACHE_READ_RATE)
+ (input.cacheWriteInputTokenCount * CACHE_WRITE_RATE)
+ (output.outputTokenCount * OUTPUT_RATE) as estCostUSD
| stats sum(inputTokens), sum(cacheReadTokens), sum(cacheWriteTokens), sum(outputTokens), sum(estCostUSD) by customer_id
I confirmed this live by forcing a real cache write then a real cache read of the same ~1,360-token system prompt with a cachePoint checkpoint on the Converse call, and pricing out the actual token breakdown each returned:
| Call | Input tokens | Cache write tokens | Cache read tokens | Output tokens | Total cost |
|---|---|---|---|---|---|
| Cache write | 32 | 1,359 | 0 | 62 | $0.006122 |
| Cache read | 32 | 0 | 1,359 | 63 | $0.001449 |
Same ~1,360 tokens of prompt content both times. The write call costs over four times more than the read call, entirely because of which of the two cache rates applies — and on the write call, the cache-write tokens alone account for the large majority of the total. A query that only reads inputTokenCount/outputTokenCount never sees that 1,359-token line item at all.
I haven’t compared this number against an actual invoice line item, but AWS’s own cost-management guidance is explicit that a token-times-published-rate estimate doesn’t account for volume discounts, committed spend, or whatever pricing tier the account is on. It’s a real, defensible number for a dashboard once all four token types are in it — not a substitute for the invoice.
Where Athena and Glue fit — the same mechanism, a table that survives production volume
CloudWatch Logs Insights is fine for a quick check, but it doesn’t build a table — every query rescans raw logs within whatever time window you give it. The path that scales is logging to S3 instead of CloudWatch, then querying that S3 data with Athena, cataloged by Glue.
The one thing worth getting right up front: don’t let a Glue crawler auto-infer the table schema. Bedrock omits a field like cacheWriteInputTokenCount entirely when a call doesn’t write to cache, rather than writing a zero — so a crawler pointed at a mix of cache-write and non-cache-write logs sees two different shapes and creates two separate tables instead of one. At real volume that becomes one new table per schema variant, which breaks any query written against a stable column set. Define the table explicitly instead:
CREATE EXTERNAL TABLE bedrock_invocation_logs (
requestid string,
requestmetadata map<string,string>,
input struct<
inputTokenCount:int,
cacheReadInputTokenCount:int,
cacheWriteInputTokenCount:int
>,
output struct<outputTokenCount:int>
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
WITH SERDEPROPERTIES ('ignore.malformed.json'='true')
LOCATION 's3://your-bucket/bedrock-logs/AWSLogs/.../BedrockModelInvocationLogs/us-east-1/'
The same four-token cost math runs as a normal Athena SELECT, over a table that stays stable regardless of which optional fields any individual log record happens to include.
The map, end to end
Four paths converge on the same billing platform and inherit the same shape: attribution follows a resource or an identity, aggregated by day and usage type, never by individual request. The fifth path breaks out to the request layer and trades the aggregation ceiling for an estimate — through either destination, the estimate is only right if it accounts for all four token types Bedrock actually bills. There’s no version of this that gives you both a per-request breakdown and an invoice-accurate number, on Bedrock, today.
Where this stops being an AWS problem
If you’re running a multi-tenant SaaS product on Bedrock, the natural next question is whether any of the five mechanisms above solve per-customer cost — team A pays for tenant X’s usage, and you need to know the exact number.
They don’t, and the reason isn’t a gap in Bedrock specifically. AWS’s own Well-Architected SaaS Lens states the design point directly: measuring per-tenant consumption in a shared-resource architecture requires the application itself to instrument tenant activity and correlate it with billing data afterward — the billing report alone was never going to enumerate an unbounded, growing customer base. The reference pattern in the same guidance is: capture tenant activity at the request layer (request counts, token counts, whatever correlates with cost in your architecture), store it, then apply that consumption ratio against the aggregate AWS bill for the period. That’s mechanism five above, generalized — the SaaS billing layer sits next to CUR, reading from it, not inside it.
I went and checked whether another provider had actually solved this differently rather than only packaged it better. Google’s Vertex AI lets a single service account attach a label to every individual request and have that label reach the actual Cloud Billing export — no per-tenant credential required, which is a real architectural difference from minting a tagged STS session per tenant. But the mechanism has a limit stated plainly in Google’s own documentation: each label key holds at most 1,000 unique values, for the lifetime of the billing account, silently dropping anything past that with no error surfaced anywhere. A product with a few hundred tenants gets real per-tenant billing visibility with none of the identity plumbing Bedrock’s session-tag pattern needs. A product that expects to grow past a thousand tenants hits the same wall — later, and more quietly than a missing feature would announce itself.
So what
Pick the mechanism by what you’re actually trying to attribute, not by which one sounds most granular. Team, department, cost center — tag the role or the resource, and it’s done; that’s what all four billing-linked mechanisms are actually built for. Per-customer, in a product with a growing and unbounded tenant base — nothing in Bedrock’s billing layer, or in Vertex’s once you check the fine print, gives you that natively past a bounded scale. Build it where AWS’s own architecture guidance says to build it: instrument the request layer, correlate against the bill yourself, and treat the result as a well-reasoned estimate rather than a substitute for the invoice.
I haven’t found a platform that closes this gap natively — a request-level tag that lands in real, invoice-accurate billing data with no cardinality ceiling. I don’t know if that’s a hard problem or an unbuilt one.