Guidance for per-user cost attribution and hard budget enforcement on Amazon Bedrock AgentCore Gateway
This Guidance shows how to attribute inference cost per user on the Amazon Bedrock AgentCore Gateway inference target, and how to enforce daily and monthly hard budgets before the model call happens.
- Overview
- Why cost governance matters for Claude Code and Claude Desktop
- Problem statement
- Architecture overview
- Key features
- Deployment
- Testing
- How it works
- Configuration
- Performance
- Operations
- Cleanup
- Repository structure
- Additional resources
Amazon Bedrock AgentCore Gateway is a fully-managed AI gateway that provides a single, secure entry point for agentic traffic, connecting agents to tools, other agents, and large language models. It converts APIs, Lambda functions, and existing services into MCP-compatible tools; fronts agents and HTTP services through passthrough targets; and routes inference requests across multiple model providers through a unified, model-based routing endpoint. This Guidance is scoped to the inference-target side of that surface: users running Codex CLI or Claude Code against Bedrock through the gateway. It plugs two AWS Lambda interceptors into the gateway so every LLM call is attributed to the user who made it, and enforces hard daily and monthly budgets per person before the call reaches the model.
As an example, Claude Code is Anthropic's agentic coding assistant for terminals and IDEs. Claude Desktop bundles chat, autonomous agents (Cowork), and coding features into a single application. Both charge by API token consumption, and both are used interactively — spend accumulates in real-time conversational loops, not in scheduled jobs that traditional cloud cost tools were designed for.
Anthropic's own Claude Code cost documentation reports that across enterprise deployments the average cost is around $13 per developer per active day and $150–250 per developer per month, with 90% of users staying under $30/active day. That same page notes agent teams running in plan mode use approximately 7× more tokens than standard sessions, and per-developer costs vary widely with model choice, codebase size, and how many instances a user runs in parallel.
At those numbers, "who spent what" and "cap that user before they overspend" stop being nice-to-haves.
When Amazon Bedrock is called through an AgentCore Gateway inference target, Bedrock records the gateway's IAM role as the caller's identity. On the bedrock-mantle endpoint, IAM principal attribution, per-request metadata tagging, and application inference profiles are not supported; the finest built-in cost granularity is per-project per-day dollars in CUR 2.0. Gateways provide RPM throttling per target, not per-request cost control, and there is no built-in mechanism to block a request before it reaches the model when a user is over budget.
This sample adds two things on top of the gateway:
- Per-user cost attribution — every LLM call is attributed to the JWT identity that made it, with per-user + per-team + per-model spend metrics in CloudWatch and a ledger in DynamoDB.
- Hard budget enforcement — daily and monthly per-user caps, evaluated in the REQUEST interceptor before the model call. Over-budget requests get HTTP 403 with a
BUDGET_EXCEEDEDerror code.
- Per-user, per-team, per-model token + $ telemetry via CloudWatch EMF (
AgentCore/InferenceUsagenamespace) - Hard daily + monthly budget enforcement — blocks on either limit with HTTP 403,
BUDGET_EXCEEDEDcode, response body naming which cap was hit and when it resets - Per-user budget overrides via a single DynamoDB
update-item— no redeploy, propagates within ≤60 seconds - Long-term archive to S3 — every reset writes gzipped JSONL to a Hive-partitioned S3 prefix with lifecycle transitions (Standard → IA → Glacier → Deep Archive). Admins gets year-of-metrics via Athena.
- Admin spend dashboard — an optional second stack (
cloudformation/admin-dashboard.yaml) puts Glue + Athena over that archive and builds an Amazon Quick Sight dashboard (spend over time per user, top spenders, cap utilization) end-to-end in CloudFormation - Existing OIDC / SSO support — any OIDC-compliant IdP (Cognito, Okta, Entra ID, Auth0, Ping) plugs in via the gateway's
CUSTOM_JWTauthorizer. The bundled Cognito stack is a working reference; swap in your corporate IdP by changing the discovery URL and client_id.
claude-budget-demo.mp4
codex-budget-demo.mp4
Everything is in one CloudFormation stack — Cognito, AgentCore Gateway, interceptors, DDB, KMS, rollover pipeline, and a custom resource that creates the inference target + attaches both interceptors. The step-by-step is below; Testing walks through verifying it end-to-end.
export REGION=us-east-1
export STACK=agentcore-inference-sample
export CFN_BUCKET=<your-cfn-staging-bucket> # e.g. cf-templates-<hash>-us-east-1
aws cloudformation deploy --region $REGION \
--stack-name $STACK \
--template-file cloudformation/standalone.yaml \
--s3-bucket $CFN_BUCKET \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
DomainPrefix=agentcore-sample-<globally-unique> \
DailyBudgetUsd=50 \
MonthlyBudgetUsd=1000Then create at least one Cognito user (aws cognito-idp admin-create-user ...) and configure the PKCE auth helper — auth-helper/README.md has the Codex CLI + Claude Code wiring.
Verify the sample end-to-end by pointing Codex CLI or Claude Code at the deployed gateway. This exercises the same code path any real user would.
Grab the outputs first — the auth helper and CLI configs both need them:
export DISCOVERY_URL=$(aws cloudformation describe-stacks --region $REGION --stack-name $STACK \
--query "Stacks[0].Outputs[?OutputKey=='DiscoveryUrl'].OutputValue" --output text)
export CLIENT_ID=$(aws cloudformation describe-stacks --region $REGION --stack-name $STACK \
--query "Stacks[0].Outputs[?OutputKey=='ClientId'].OutputValue" --output text)
export GW_URL=$(aws cloudformation describe-stacks --region $REGION --stack-name $STACK \
--query "Stacks[0].Outputs[?OutputKey=='InferenceBaseUrl'].OutputValue" --output text)
export SPEND_TABLE=$(aws cloudformation describe-stacks --region $REGION --stack-name $STACK \
--query "Stacks[0].Outputs[?OutputKey=='SpendTableName'].OutputValue" --output text)The auth helper reads two env vars: AGENTCORE_OIDC_DISCOVERY_URL and AGENTCORE_OIDC_CLIENT_ID. Because Codex CLI and Claude Code both invoke the helper as a subprocess, those env vars need to be visible in every shell that launches your CLI — the simplest way is to append them to your shell profile once. From then on, codex exec ... or claude will just work.
Append to ~/.zshrc (or ~/.bashrc if that's your shell):
cat >> ~/.zshrc <<EOF
# AgentCore Gateway sample — auth helper
export AGENTCORE_OIDC_DISCOVERY_URL="$DISCOVERY_URL"
export AGENTCORE_OIDC_CLIENT_ID="$CLIENT_ID"
EOFReload:
source ~/.zshrcThen run the helper once manually to complete the first browser sign-in. A tab opens, you sign in as the Cognito user you created in step 2 of Deployment, the tab closes, and the token is cached at ~/.config/agentcore-gateway/tokens.json. Subsequent invocations serve from cache and silently refresh — no browser.
python3 auth-helper/oidc-token.py | head -c 40; echo "..."Codex CLI — add to ~/.codex/config.toml:
model = "openai.gpt-5.4"
model_provider = "agentcore-gateway"
[model_providers.agentcore-gateway]
name = "AgentCore Gateway"
base_url = "<GW_URL>"
wire_api = "responses"
[model_providers.agentcore-gateway.auth]
command = "/absolute/path/to/auth-helper/oidc-token.py"
refresh_interval_ms = 300000Then use Codex normally:
codex exec "write a bash one-liner that lists the top 5 largest files in the current dir"Claude Code — Claude Code doesn't have an apiKeyHelper-style command for fetching a bearer token, and its apiKeyHelper path sends the value as x-api-key (which Bedrock rejects when paired with Authorization). Two-step setup instead:
-
Refresh the token before each session (or on a shell hook / cron) and export it into the shell that will launch Claude Code:
export ANTHROPIC_AUTH_TOKEN=$(python3 /absolute/path/to/auth-helper/oidc-token.py)
-
Add the rest to
~/.claude/settings.json:{ "env": { "ANTHROPIC_BASE_URL": "<InferenceBaseUrl WITHOUT the /v1 suffix>", "ANTHROPIC_MODEL": "anthropic.claude-haiku-4-5", "ANTHROPIC_SMALL_FAST_MODEL": "anthropic.claude-haiku-4-5", "ANTHROPIC_CUSTOM_HEADERS": "anthropic-version: bedrock-2023-05-31", "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1" } }
Full wiring details, including troubleshooting, are in auth-helper/README.md.
The Cognito username (the sign-in name) is what the interceptor uses as the user identity. Extract it from your cached token:
export ME=$(python3 -c "
import json, base64, pathlib
t = json.loads(pathlib.Path.home().joinpath('.config/agentcore-gateway/tokens.json').read_text())['access_token']
p = t.split('.')[1] + '=' * (-len(t.split('.')[1]) % 4)
print(json.loads(base64.urlsafe_b64decode(p))['username'])
")
echo "signed-in user: $ME"Then confirm the RESPONSE Lambda recorded the call, the spend row exists, and EMF metrics landed:
# 1. RESPONSE Lambda logged the call
aws logs tail /aws/lambda/${STACK}-usage-record --region $REGION --since 2m --format short \
| grep usage_recorded
# 2. Your spend row exists in DynamoDB
aws dynamodb get-item --region $REGION --table-name $SPEND_TABLE \
--key "{\"pk\":{\"S\":\"USER#$ME\"}}"
# 3. CloudWatch EMF metrics landed (~30s propagation)
aws cloudwatch list-metrics --region $REGION --namespace AgentCore/InferenceUsageLower your cap so the next call hits it, wait 60s for the in-Lambda cache to expire, then fire another CLI call:
aws dynamodb update-item --region $REGION --table-name $SPEND_TABLE \
--key "{\"pk\":{\"S\":\"USER#$ME\"}}" \
--update-expression "SET daily_cap_usd = :d" \
--expression-attribute-values '{":d":{"N":"0.0001"}}'
sleep 61
codex exec "hi"
# → CLI surfaces a BUDGET_EXCEEDED error with limit_hit=daily and resets_atReset by removing the override:
aws dynamodb update-item --region $REGION --table-name $SPEND_TABLE \
--key "{\"pk\":{\"S\":\"USER#$ME\"}}" \
--update-expression "REMOVE daily_cap_usd"A single inference request flows through the following steps:
- Client (Codex / Claude Code / clients) POSTs to
/inference/v1/*with an OIDC bearer JWT - Gateway validates the JWT signature via JWKS from the IdP's discovery URL
- REQUEST interceptor decodes the JWT payload, stashes
(user, team)in a DDB context table keyed byREQUEST_ID, and reads the user's row (pk = USER#<user>) from SpendTable in oneGetItem. Checksdaily_spend >= daily_capfirst, thenmonthly_spend >= monthly_cap. Either hits → HTTP 403 withlimit_hit+resets_atin the body. Otherwise passthrough. - Gateway calls Bedrock Mantle (SigV4) → provider generates response
- RESPONSE interceptor decodes the response body, extracts
usage, fetches the user identity via REQUEST_ID, computes cost, emits CloudWatch EMF, atomicallyADDon bothdaily_spend_usdandmonthly_spend_usd, passthrough - At local-midnight (configurable IANA timezone), a rollover Lambda reads each user's counter, writes it to S3 as gzipped JSONL under
period=daily/year=YYYY/month=MM/day=DD/…, then atomically subtracts the archived amount from the counter — preserving any in-flight increments
The parameters most likely to change. Full reference in docs/configuration.md.
| Parameter | Default | What |
|---|---|---|
DomainPrefix |
(required) | Globally-unique Cognito domain prefix (must be unique across all pools in the region) |
DailyBudgetUsd |
50 |
Default per-user daily cap in USD. Per-user overrides in DDB take precedence |
MonthlyBudgetUsd |
1000 |
Default per-user monthly cap in USD |
RolloverTimezone |
Etc/UTC |
IANA timezone for daily/monthly counter rollover (America/New_York, Asia/Kolkata, …). DST-aware |
EnforceBudget |
"true" |
"false" = log-only "watch mode" |
UserClaim |
username |
JWT claim used as user identity (Cognito access tokens carry username, not email) |
ProvisionedConcurrency |
1 |
PC per interceptor Lambda. 0 = on-demand |
+93 ms at p50, +63 ms at p90 with PC=1. Full methodology and breakdown in docs/performance.md.
Day-2 recipes (change a budget, check spend, deploy code, query the S3 archive, stand up the admin dashboard) in docs/operations.md.
Prefer a UI over update-item? python3 scripts/admin_ui.py --stack $STACK runs a local console over the ledger — previews every cap change and audits it. Setup in docs/admin-ui.md.
# 1. Delete the stack. The custom resource detaches interceptors + deletes the
# inference target before CFN deletes the Gateway resource (verified clean).
aws cloudformation delete-stack --region $REGION --stack-name $STACK
aws cloudformation wait stack-delete-complete --region $REGION --stack-name $STACK
# 2. SpendTable, KMS CMK, and the S3 archive bucket are DeletionPolicy: Retain.
# Delete them manually when you're sure you don't need the history.
aws dynamodb delete-table --region $REGION --table-name ${STACK}-user-spend
aws kms schedule-key-deletion --region $REGION \
--key-id alias/${STACK}-data --pending-window-in-days 7
# (find your S3 archive bucket in the stack Outputs before deleting it)inference-usage-interceptor/
├── README.md # this file
├── cloudformation/
│ ├── standalone.yaml # one stack: Cognito + Gateway + interceptors + rollover + attach
│ └── admin-dashboard.yaml # optional: Glue + Athena + Amazon Quick Sight over the S3 archive
├── auth-helper/ # PKCE token helper
│ ├── oidc-token.py
│ └── README.md
├── scripts/
│ ├── admin_ui.py # local admin console — server (stdlib + boto3)
│ ├── admin_ui.html # local admin console — single-file UI, no build step
│ └── attach_interceptors.py # (legacy) manual attach — standalone stack does this automatically
└── docs/
├── architecture.md # diagram + numbered request lifecycle
├── configuration.md # all CFN params + env vars
├── operations.md # day-2 recipes
├── admin-ui.md # local admin console setup
└── performance.md # benchmarks + methodology
- FinOps for Claude Code and Claude Desktop on Amazon Bedrock — background on cost governance patterns for this workload
- AgentCore Gateway QUICKSTART — names the per-user attribution gap this sample closes
- Gateway Interceptors developer guide
- Interceptor payload types — HTTP vs. MCP envelope shapes
- Bedrock Mantle CloudWatch metrics — the
AWS/BedrockMantlenamespace (Project/Model dimensions, no user) - Bedrock cost management overview — per-user attribution not supported on Mantle today
- Inference target tutorial — the reference sample this project extends
