DEV Community

Cover image for Stopping the $10,000 AWS Bedrock Bill: How We Built a Zero-Proxy Kill Switch for $1/Month
Nguyễn Tiến Huy
Nguyễn Tiến Huy

Posted on

Stopping the $10,000 AWS Bedrock Bill: How We Built a Zero-Proxy Kill Switch for $1/Month

As a Junior DevOps engineer holding the keys to the AWS kingdom, life was peak "unrestricted free will." I could spin up architecture, tear down stacks, and feel like a tech god. Then came the inevitable executive tap on the shoulder: "Show me the bill."

I pulled up AWS Cost Explorer. He saw the numbers, smiled, and signed off. Smooth sailing.

Then leadership dropped the big vision: "We are going full AI-first!"

Naturally, I handed him Amazon Bedrock. He was thrilled. And like every proper business executive, he eventually asked the classic follow-up: "Show me the bill."

I pulled up Cost Explorer again. Still green, still happy. Then came the twist that ruined my week: "Now break down the cost for EACH individual developer."

Here is where the corporate tragedy set in. Turns out we aren't running on a Payer Account. Which instantly meant:

  • Cost allocation tags on IAM users? Only the payer account can activate them. Not us.
  • CUR 2.0? Billing lives with the payer. And even with it, CUR refreshes a few times a day. That's an autopsy, not a guardrail.

Worse, leadership didn't just want visibility, they asked the million-dollar question: "Can we stop a developer before they set our budget on fire?"

AWS Budgets? Data lags 8–24 hours. By the time the alert lands, the agent loop has been burning tokens all night. A LiteLLM reverse proxy? Extra latency on every token, a shiny new single point of failure, and a pager that's now lowkey my personality.

So I did what any reasonable engineer does when backed into a corner: I built my own solution.

This is the engineering story of bedrock-cost-sentry: an event-driven, out-of-band IAM budget guard for Amazon Bedrock. Zero proxy, zero added latency, full /model freedom in Claude Code, and per-developer enforcement within 1–3 minutes, for about $1 a month.


1. The Context: What Are We Building?

At its core, bedrock-cost-sentry is a completely serverless, out-of-band control plane that monitors Bedrock consumption, calculates token-level dollar costs on the fly, and cuts off developers the second they cross their spending cap.

Developers do not connect to a custom domain or route their requests through a proxy. They talk directly to AWS Bedrock runtime endpoints in their chosen region using their standard IAM access keys:

export CLAUDE_CODE_USE_BEDROCK=1
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
Enter fullscreen mode Exit fullscreen mode

The Architecture in 5 Steps:

  1. Direct Invocation: The developer's machine talks directly to Amazon Bedrock. Latency penalty = 0ms.
  2. Telemetry Ingestion: Bedrock writes invocation metadata (caller IAM ARN, model ID, input/output/cache token counts) to Amazon CloudWatch Logs.
  3. Event Filtering: CloudWatch subscription filters stream matching log records to an AWS Lambda function (enforcer).
  4. Accounting & Idempotency: The Lambda checks an idempotency ledger table in DynamoDB, calculates the call cost using an internal pricing engine, and atomically increments the user's spending tally.
  5. Enforcement (The Kill Switch): If the user exceeds their configured dollar or token cap, the Lambda attaches an inline IAM Deny policy (BedrockBudgetDeny) directly to the user's IAM entity. Their very next request fails with AccessDenied.

2. The Problem: Why Do We Need This?

Why go through the trouble of building an event-driven architecture when AWS already has billing tools? Because enterprise cloud governance is full of edge cases that official sales decks never mention.

Limitation 1: The "Next-Day Autopsy" of AWS Budgets

AWS Budgets and AWS Cost Explorer rely on billing pipeline data exports. That data lands on a delay anywhere between 8 and 24 hours. If an engineer accidentally kicks off an agentic script that burns $300 an hour at midnight, an AWS Budget alert arriving at 11:00 AM the next morning is not a guardrail—it's a funeral notice.

Limitation 2: The Enterprise Org Payer Account Lockout

AWS natively supports tagging Bedrock spend using Application Inference Profiles. In theory, you could give each developer an inference profile tagged User = alice, and group spend in Cost Explorer. But Cost Allocation Tags can only be activated in the AWS Organization Payer (Management) Account.

In any mid-to-large organization, engineering teams are relegated to member accounts or sandbox accounts. Access to the payer account is guarded by corporate finance and enterprise cloud teams behind a fortress of bureaucracy. If you cannot activate tags at the root payer level, Cost Explorer per-user cost allocation simply does not work.

Now I get why people rage-quit corporate for startups

Limitation 3: Why Proxies (LiteLLM / API Gateway) Weren't the Answer

The alternative was spinning up a proxy container. But proxies introduce heavy trade-offs:

  • Single Point of Failure: If your proxy crashes, the entire engineering department stops coding.
  • Latency & Streaming: Streaming completions (InvokeModelWithResponseStream, ConverseStream) require persistent HTTP chunking or WebSockets. Proxies frequently drop long-running connections.
  • Tooling Friction: You have to force developers and CLI tools to override their base URLs, bypass enterprise certificate authorities, and reconfigure their environments.

I'm not signing up to be the single point of failure for every coding agent in the company. We needed a system that observed reality out-of-band without getting in the developer's way and keep my job safe.


3. The Solution: How Does It Work?

Let's break down the mechanics of the stack into its key engineering components.

Component 1: Metadata-Only Ingestion (Zero Plaintext Prompts)

To count tokens, you must enable Bedrock Model Invocation Logging. But turning on logging often triggers an immediate veto from InfoSec: "Are we logging sensitive company code and user prompts to CloudWatch?!"

The answer is no. Tokens live in the metadata, so we turn off every payload flag. Compliance 101: follow the rules. Can't follow the rules? Don't get caught. Getting caught? I don't know you. (Author not liable. This is satire. Mostly.)

In modules/bedrock-logging/main.tf, we explicitly turn off payload delivery:

resource "aws_bedrock_model_invocation_logging_configuration" "this" {
  logging_config {
    # Token counts live in metadata — full prompt/response text is disabled.
    # Avoids logging proprietary source code in plaintext and cuts
    # CloudWatch ingest cost by orders of magnitude.
    embedding_data_delivery_enabled = false
    image_data_delivery_enabled     = false
    text_data_delivery_enabled      = false

    cloudwatch_config {
      log_group_name = aws_cloudwatch_log_group.bedrock.name
      role_arn       = aws_iam_role.bedrock_logging.arn
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

By setting text_data_delivery_enabled = false, Bedrock logs only the invocation metadata: caller identity, model identifier, and token numbers. Not a single line of code or prompt text enters CloudWatch. This also shrinks log payload sizes by ~99%, slashing ingestion and retention bills to pennies.

Component 2: Multi-Region Ingest, Centralized State

Developers don't stick to one region. Claude Sonnet might run in us-east-1, while regional quotas push someone to ap-southeast-1 (Singapore) or us-west-2 (Oregon).

Because Bedrock invocation logging is a regional singleton, each tracked region needs its own logging configuration and a local instance of the enforcer Lambda. However, spinning up separate DynamoDB tables across regions would fracture budgets.

Instead, regional enforcers stream events across AWS's backbone into a single, centralized DynamoDB table in us-east-1. Because DynamoDB rows are partitioned by IAM username (userId), Singapore usage and US usage fan into the exact same budget row:

# Primary region stores the central table; regional Lambdas point explicitly to it
dynamodb = boto3.resource("dynamodb", region_name=os.environ.get("DYNAMODB_REGION"))
TABLE_NAME = os.environ["DYNAMODB_TABLE"]
Enter fullscreen mode Exit fullscreen mode

Why us-east-1? Honest answer: I picked it because it felt like the safest region. Oldest, biggest, the one AWS surely babysits the hardest. Then I remembered which region keeps showing up in outage post-mortems. Yes, that one. Bold choice, junior me.

It turned out to be the right call anyway, for reasons I only appreciated later:

  • The kill switch lives there. IAM is "global," but its control plane endpoint (iam.amazonaws.com) sits in us-east-1. So does Cost Explorer (ce:GetCostAndUsage). Keeping budget state next to the APIs that act on it means less cross-region plumbing.
  • Most traffic is already there. Claude calls land mostly in us-east-1, so the busiest enforcer writes locally. Singapore pays a ~200ms cross-region write on an async pipeline whose logs already arrive 1–3 minutes late. Nobody will ever notice.
  • Global Tables would lie to us. Multi-region DynamoDB Global Tables resolve conflicts with last-writer-wins. Two regions doing an atomic ADD costUsd on the same row at the same moment can silently drop one increment. A budget counter that undercounts is worse than no counter. One table means atomic stays atomic.

And if us-east-1 has one of its famous bad days? Enforcement pauses, but it fails open. Developers keep shipping; they just get a few unmetered minutes of free will. Which, to be fair, is exactly what they had before I showed up.

Yes, I know. One table is a single point of failure. I also know a fleet of replica tables, just to protect a few dozen budget counters, is the kind of architecture that looks great in a diagram and terrible on the bill I'm supposed to be shrinking. So the table gets cheap insurance instead: deletion protection and point-in-time recovery (PITR), both on. And if PITR ever lets me down, the real source of truth never lived in DynamoDB anyway. Every invocation's token counts sit in CloudWatch Logs (30-day retention), so I can replay them and rebuild every counter from scratch. We've already run a smaller version of this for real: after the Grok double-billing bug in Pain Point 4, we corrected the counters straight from the invocation log. Is it fun? Absolutely not. It's a long afternoon of Logs Insights queries and atomic ADDs. But "painful but possible" is a recovery plan, and it costs $0 a month.

Component 3: The Pricing Engine & Model Precedence

Log events don't report dollar amounts; they report token integers (inputTokenCount, outputTokenCount, cacheReadInputTokenCount, cacheWriteInputTokenCount).

Disclaimer: This enforcer speaks fluent Claude. Other providers have their own dialects of "token." Grok, for example, quietly stuffs cached tokens into inputTokenCount, and our enforcer happily billed them twice, about 3x the real cost (full story in Pain Point 4). New model family? Reconcile against Cost Explorer before you trust it.

The enforcer computes the cost in Python using an internal price table:

PRICE_PER_1K = {
    # modelId substring: {input, output, cacheWrite, cacheRead}  (excerpt)
    "claude-fable-5-1":  {"input": Decimal("0.010"),  "output": Decimal("0.050"),  "cacheWrite": Decimal("0.0125"),  "cacheRead": Decimal("0.00025")},
    "claude-fable-5":    {"input": Decimal("0.010"),  "output": Decimal("0.050"),  "cacheWrite": Decimal("0.0125"),  "cacheRead": Decimal("0.001")},
    "claude-opus-5-5":   {"input": Decimal("0.004"),  "output": Decimal("0.020"),  "cacheWrite": Decimal("0.005"),   "cacheRead": Decimal("0.0002")},
    "claude-opus-4-1":   {"input": Decimal("0.015"),  "output": Decimal("0.075"),  "cacheWrite": Decimal("0.01875"), "cacheRead": Decimal("0.0015")},
    "claude-opus-4":     {"input": Decimal("0.005"),  "output": Decimal("0.025"),  "cacheWrite": Decimal("0.00625"), "cacheRead": Decimal("0.0005")},
    "claude-sonnet-5-5": {"input": Decimal("0.002"),  "output": Decimal("0.010"),  "cacheWrite": Decimal("0.0025"),  "cacheRead": Decimal("0.0001")},
    "claude-sonnet-5":   {"input": Decimal("0.003"),  "output": Decimal("0.015"),  "cacheWrite": Decimal("0.00375"), "cacheRead": Decimal("0.0003")},
    "claude-sonnet-4":   {"input": Decimal("0.003"),  "output": Decimal("0.015"),  "cacheWrite": Decimal("0.00375"), "cacheRead": Decimal("0.0003")},
    "claude-haiku-5-5":  {"input": Decimal("0.0001"), "output": Decimal("0.0005"), "cacheWrite": Decimal("0.000125"),"cacheRead": Decimal("0.00001")},  # prompts > 100k tokens use a separate, pricier tier
    "claude-haiku-4":    {"input": Decimal("0.001"),  "output": Decimal("0.005"),  "cacheWrite": Decimal("0.00125"), "cacheRead": Decimal("0.0001")},
    "grok-4.7":          {"input": Decimal("0.002"),  "output": Decimal("0.006"),  "cacheWrite": Decimal("0.002"),   "cacheRead": Decimal("0.0005")},
    # ...plus Claude 3.x, Qwen, Nova Canvas (priced per image, so $0 per token)
}
# Fallback to the Opus 4.1 price so unlisted models never silently undercount
DEFAULT_PRICE = PRICE_PER_1K["claude-opus-4-1"]
Enter fullscreen mode Exit fullscreen mode

Important

Substrings and Dictionary Ordering: In Python 3.7+, dictionary iteration preserves insertion order. Notice how "claude-sonnet-5-5" precedes "claude-sonnet-5", "claude-fable-5-1" precedes "claude-fable-5", and "claude-opus-4-1" precedes "claude-opus-4". If you match shorter substrings first, your newer, cheaper models get billed at the legacy rate. Every model generation also needs its own key: "grok-4.6" does not match grok-4.7, and a miss silently bills a Grok call at the Opus rate (~7x over-count).

[!WARNING]
Token fields don't mean the same thing for every model family. For Claude, inputTokenCount excludes cached tokens, so input * price + cacheRead * price is correct. For Grok on bedrock-runtime, inputTokenCount includes the cached tokens, so the same formula bills every cached token twice (once at the full input rate, once at the cache-read rate). Grok needs (input - cacheRead) as billable input. See Pain Point 4 for how we found out.

If an unknown model arrives, it deliberately falls through to DEFAULT_PRICE (the Opus 4.1 rate). In financial engineering, an accidental over-count that triggers an alert is an annoyance; an accidental under-count that lets an unbudgeted model burn thousands of dollars is a disaster.

So yes, our numbers run a little hot. In our monthly audits, the sentry's total lands roughly 5–8% above Cost Explorer. The enforcer prices everything at public list rates, while the real invoice quietly applies discounts and credits it never tells us about. Add the occasional unknown model billed at Opus prices, and the report is always a bit pessimistic. And that's the point. A budget guard that's slightly paranoid blocks a developer one request early. A budget guard that's slightly optimistic sends finance a $4,000 surprise. I'll take paranoid every time. The rule we actually watch is direction, not size: if the sentry ever reads below Cost Explorer, something is being missed, and that's a bug, not a rounding error.

Component 4: The IAM Kill Switch

When a user crosses their budget ceiling, how do you sever their connection without tearing down their access keys?

You inject an inline policy directly onto their IAM user entity:

def attach_deny_policy(user_name):
    iam.put_user_policy(
        UserName=user_name,
        PolicyName="BedrockBudgetDeny",
        PolicyDocument=DENY_POLICY_DOCUMENT,
    )
Enter fullscreen mode Exit fullscreen mode

In AWS IAM evaluation logic, an explicit Deny always supersedes any Allow. Even if the user belongs to an AdministratorAccess group or holds broad developer policies, the inline Deny halts further Bedrock calls on their very next API attempt. In IAM, an explicit Deny outranks everything. AdministratorAccess? Cute. Over budget means over budget, admin or not.


4. Implementation: Let's Get Our Hands Dirty

Let's dive into the core implementation details and patterns that make this system bulletproof.

Step 1: Defining Budgets in Terraform

Budgets and users are declared in terraform.tfvars:

users = [
  {
    username           = "dev-alice"
    team               = "platform"
    cost_limit_usd     = 250        # Monthly limit in USD
    input_token_limit  = 0          # 0 = unlimited on this axis
    output_token_limit = 0
    tags               = { project = "backend-ai" }
    period             = "month"
  },
  {
    username           = "dev-bob-agentic"
    team               = "automation"
    cost_limit_usd     = 20         # Daily sandbox allowance
    input_token_limit  = 0
    output_token_limit = 0
    tags               = { project = "rag-testing" }
    period             = "day"
  }
]

alert_emails    = ["devops-alerts@example.com"]
alert_threshold = 0.8  # Warn via SNS at 80% of limit
Enter fullscreen mode Exit fullscreen mode

Step 2: The Terraform State vs. Runtime Mutation Trap

Here is a classic infrastructure-as-code conundrum:
You want to declare user budgets in Terraform (aws_dynamodb_table_item). But the enforcer Lambda continuously updates that exact same row at runtime (costUsd, inputTokensUsed, blocked).

If you run terraform apply next week to adjust an alert email, Terraform will see that costUsd = 142.50 differs from the declared costUsd = 0 in your code. By default, Terraform will happily overwrite the row, wiping out live usage counters and unblocking every blocked user!

To fix this, we split seeding from updating in modules/dynamodb/main.tf:

resource "aws_dynamodb_table_item" "user_budgets" {
  for_each   = { for u in var.users : u.username => u }
  table_name = aws_dynamodb_table.budgets.name
  hash_key   = aws_dynamodb_table.budgets.hash_key

  item = jsonencode({
    userId          = { S = each.value.username }
    costLimitUsd    = { N = tostring(each.value.cost_limit_usd) }
    costUsd         = { N = "0" }
    blocked         = { BOOL = false }
    # ... other counters ...
  })

  # Ignore changes to live operational data!
  lifecycle {
    ignore_changes = [item]
  }
}

# Replaced whenever a limit changes, so the provisioner re-runs;
# updates ONLY limits via UpdateItem
resource "terraform_data" "budget_limits" {
  for_each = { for u in var.users : u.username => u }

  triggers_replace = {
    cost_limit_usd = each.value.cost_limit_usd
    period         = each.value.period
  }

  depends_on = [aws_dynamodb_table_item.user_budgets]

  provisioner "local-exec" {
    command = "python3 ${path.module}/sync_limits.py"
    environment = {
      TABLE_NAME = aws_dynamodb_table.budgets.name
      USER_ID    = each.key
      COST_LIMIT = tostring(each.value.cost_limit_usd)
      PERIOD     = each.value.period
      # ...
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The initial seed sets up the row with ignore_changes = [item]. Whenever you change a budget ceiling in terraform.tfvars, terraform_data fires a focused Python script (sync_limits.py) that executes an UpdateItem exclusively targeting costLimitUsd and resetPeriod. Live spend counters remain untouched.

Step 3: Marker-First Idempotency

CloudWatch log subscriptions deliver events with at-least-once semantics. If AWS retries an enforcer batch, or if Lambda encounters transient network issues, the same log event can be processed multiple times. Without deduplication, retried calls would double-count usage and prematurely block innocent developers.

In lambda-src/enforcer.py, we implement a dedicated idempotency ledger:

def claim_request(request_id):
    """Marker-first idempotency using DynamoDB condition expressions."""
    if not PROCESSED_TABLE or not request_id:
        return True
    try:
        dynamodb.Table(PROCESSED_TABLE).put_item(
            Item={
                "requestId": request_id,
                "ttl": int(time.time()) + PROCESSED_TTL_SECONDS
            },
            ConditionExpression="attribute_not_exists(requestId)",
        )
        return True
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            return False  # Already counted! Skip it.
        raise
Enter fullscreen mode Exit fullscreen mode

Notice the critical design choice: The marker is claimed before incrementing the user's budget.
If the Lambda crashes after claiming the marker but before incrementing the budget, we under-count by a fraction of a cent. But if we incremented first and crashed before marking, a retry would double-count spend and trigger an unfair block. In billing systems, always fail in favor of the developer.

Step 4: The Inspection CLI (report.py)

No need to wade through the AWS Management Console to inspect usage. A standalone Python script scans the central DynamoDB table and generates rollups in seconds:

# Rollup total spend grouped by team
python3 report.py --table bedrock-cost-sentry-budgets --region us-east-1 --group-by team
Enter fullscreen mode Exit fullscreen mode

Sample output:

Cost by tag 'team':
  group                    cost($)  users           input          output       cacheRead      cacheWrite
  platform                  184.20      4       1,240,512         412,080      18,940,210       4,120,500
  automation                 18.50      2         180,400          84,200       1,450,000         210,000

Per-user:
  user              cost($)   limit($)      %  blocked          input         output      cacheRead     cacheWrite
  dev-alice          142.10     250.00     57    False        910,200        290,100     12,400,000      2,900,000
  dev-bob             18.50      20.00     93    False        180,400         84,200      1,450,000        210,000

  TOTAL                     202.70                                 1,420,912         496,280
Enter fullscreen mode Exit fullscreen mode

5. Pain Points & Hard-Won Lessons

Building systems on top of AWS telemetry sounds trivial until reality hits you in the face. Here are the scars we earned along the way:

1. The "Action: bedrock:InvokeModel*" IAM Trap

When writing the IAM deny policy, almost every engineer instinctively writes:

{
  "Effect": "Deny",
  "Action": ["bedrock:InvokeModel*"],
  "Resource": "*"
}
Enter fullscreen mode Exit fullscreen mode

It looks comprehensive. It passes code review. And it completely fails to block Claude Code.

Claude Code and modern Bedrock clients do not invoke raw models with InvokeModel. They use the Bedrock Converse API (bedrock:Converse and bedrock:ConverseStream). In AWS IAM, InvokeModel* does not match Converse!

Your deny policy must be explicit:

DENY_POLICY_DOCUMENT = json.dumps({
    "Version": "2012-10-17",
    "Statement": [{
        "Sid": "DenyBedrockOverBudget",
        "Effect": "Deny",
        "Action": [
            "bedrock:InvokeModel",
            "bedrock:InvokeModelWithResponseStream",
            "bedrock:Converse",
            "bedrock:ConverseStream",
            "bedrock-mantle:CreateInference",
            "bedrock-mantle:GetInference",
            "bedrock-mantle:CancelInference",
            "bedrock-mantle:CallWithBearerToken",
        ],
        "Resource": "*"
    }]
})
Enter fullscreen mode Exit fullscreen mode

2. The Zero-Token Streaming Ghost Record

When we first enabled deduplication, token counts started randomly dropping by 50%. It turns out that for streaming calls (InvokeModelWithResponseStream and ConverseStream), Bedrock frequently emits two distinct CloudWatch log events under the exact same requestId.

The first event is an initial handshake containing metadata where every token field is 0.
The second event arrives milliseconds later carrying the actual token totals.

If your deduplicator claims the requestId on that first zero-token event, it locks the ID in DynamoDB. When the second event with the real 150,000 tokens arrives, the enforcer assumes it's a duplicate and ignores it!

The best part? Nothing broke. No error, no alarm, no DLQ message. I only caught it in the monthly audit, sentry vs. Cost Explorer, and by then the money was already gone: one developer was $100 on our books and about 3x that on the real bill. But I have a crush on her, so I let it slide (´▽`ʃ♡ƪ) (The CEO knows nothing about this. Lucky me. Please don't forward this post.)

The fix was a simple guard: we checked for both events.

3. The Ghost Bearer Token

During deployment, we ran into a bizarre bug: CreateInferenceProfile kept throwing AccessDenied, even though our IAM deployment role had full administrator rights.

After three hours of head-scratching, we found the culprit: an engineer's shell had an exported environment variable:
AWS_BEARER_TOKEN_BEDROCK="ey..."

The AWS CLI and Boto3 silently prioritize this bearer token over standard IAM profiles, invisibly redirecting Bedrock calls to a completely different AWS account where the credentials were unauthorized. Always sanitize your shell before running Terraform:

bash
unset AWS_BEARER_TOKEN_BEDROCK

4. Grok: Two Endpoints, Two Billing Surprises

Amazon Bedrock Mantle (which hosts models like xAI's Grok) does not publish to standard Bedrock invocation logs, and its CloudTrail CreateInference data event carries no token counts. To track it, we configured a multi-region CloudTrail data event trail for call counts, paired with a Lambda poller running every 5 minutes. The poller bills a closed hour only after AWS Cost Explorer (ce:GetCostAndUsage) and CloudWatch AWS/BedrockMantle metrics agree, then splits that hour's tokens across users by call count.

Then we checked our monthly bill:
Cost Explorer API requests cost $0.01 per request.
Polling every 5 minutes: 12 calls/hr * 24 hrs * 30 days = 8,640 requests ≈ $86.40/month.

We were spending nearly $90 a month in API polling charges to monitor a barely used model! Once our clients moved Grok to the bedrock-runtime endpoint (which does write invocation logs, so the normal enforcer prices it in real time), we disabled the poller (state = "DISABLED" in Terraform) and left the Mantle path in place as a fallback.

The second surprise came after the move to runtime. Moving Grok to bedrock-runtime made the enforcer price it in real time, and our report happily showed one engineer at $187 for the month. Cost Explorer said the whole Grok line was far smaller. We compared token counts for one model over the same window:

Token type Invocation log Cost Explorer
output 1,644,844 1,644,844
cache read 64,101,456 64,101,456
input 71.7M 8.5M

Output and cache-read matched to the token, so Cost Explorer was tracking runtime calls (a common suspicion: it is, under the same xai.grok-* usage types as Mantle). Input did not: the log's input is roughly Cost Explorer's input plus the cache reads. Grok reports cached tokens inside inputTokenCount; Claude does not. Our formula charged them at the input rate and again at the cache-read rate, inflating the Grok number about 3x.

We deployed the fix and backfilled the DynamoDB counters from the invocation log (subtracting the double-charged cache tokens per request, never resetting), so the live numbers were right again without losing history. The lesson is the same as the substring trap above: a price table is not enough, you also need to know what each provider's token fields mean, and you should reconcile against the invoice-side numbers (Cost Explorer usage quantities) per model, not just in total.

Honestly, if I were CEO, everyone would be on Claude. Period. Every new model someone "just wants to try" means I get to study a fresh set of token quirks and patch the enforcer around them. Multi-model is a great strategy for the company and a terrible one for my weekends. They don't pay me enough for this!!!!!!


6. Trade-offs & Limitations: What's the Catch?

No architecture is without trade-offs. Here is where bedrock-cost-sentry shines, and where it falls short:

Feature / Property Reverse Proxy (LiteLLM / Kong) AWS Native Budgets Bedrock Cost Sentry (This Architecture)
Added Latency 10ms – 100ms+ 0ms 0ms (Direct Path)
Developer Experience Custom URLs & Endpoints Native AWS SDK Native AWS SDK (CLAUDE_CODE_USE_BEDROCK=1)
Enforcement Speed Instant (In-band) 8 – 24 hours (Useless) Near-real-time (1 – 3 min lag)
Model Switching Requires proxy config Limited Unrestricted (/model works natively)
Single Point of Failure Yes (Proxy down = Devs blocked) No No (Direct AWS communication)
Monthly Infra Cost $40 – $150+ (EC2/ECS/ALB) Free ~$0.70 (Serverless Pay-per-use)

Not an estimate. This is Cost Explorer for October 2026, filtered to the services the stack uses:

Item October 2026
CloudWatch alarms ~$0.60
Lambda $0.07
DynamoDB $0.02
CloudTrail data events $0.007
SNS email alerts $0.002
CloudWatch Logs ingest (metadata only) < $0.01
Total ~$0.70

Honest Limitations:

  1. The "Soft Ceiling" Overshoot: Because CloudWatch logs take 1 to 3 minutes to batch and deliver, enforcement is asynchronous. The current request always finishes; the user is blocked on their next request. If an engineer runs an enormous 200k-token call right as they sit at 99% of their budget, they will slightly overshoot their cap. Set budgets with 10% headroom.
  2. IAM Users Only: The enforcer attaches inline policies using iam:PutUserPolicy. This requires long-lived IAM Users. If your developers authenticate via AWS IAM Identity Center (SSO) with ephemeral assumed roles (arn:aws:sts::...:assumed-role/), attaching user-level inline policies won't work without adapting the architecture to session revocation or SCPs. Long-term Bedrock API keys are fine: they are bound to an IAM user, so the same inline Deny (including bedrock-mantle:CallWithBearerToken) cuts them off.
  3. Manual Price Tables: AWS does not offer an official, real-time pricing API for Bedrock token rates. The price dictionary in enforcer.py must be maintained as AWS drops rates or releases new model tiers. A missed model is never free: it falls back to the Opus rate and over-counts until you add its key.
  4. List Price, Not Invoice Price: The cap compares spend at public list price. If your account has a negotiated discount (ours bills at roughly 80% of list), the guard trips earlier than the invoice would suggest. That is the safe direction, but set caps with it in mind.

7. Wrapping Up

Quick recap for the people who scrolled straight to the bottom (I see you):

  • The problem: leadership wanted per-developer Bedrock costs and a way to stop someone before the bill caught fire. We aren't the payer account, so no cost allocation tags and no CUR. AWS Budgets lag 8–24 hours. A proxy means latency, a new single point of failure, and me on call forever.
  • The fix: read the invocation logs Bedrock already writes (metadata only, no prompts), price every call in a Lambda, keep a running total per IAM user in one DynamoDB table, and drop an explicit IAM Deny the moment someone crosses their cap. Three regions, no proxy, 1–3 minutes from overspend to blocked.
  • The bill: about $0.70 a month for the stack, guarding roughly $1,130 of Bedrock. Plus one $46 lesson about pollers.
  • The scars: IAM wildcards that don't cover Converse, streaming calls that log zero tokens first, a bearer token pointing at the wrong account, Grok counting cached tokens twice, and a corporate proxy that hates Terraform state.

It's not perfect. It's a soft ceiling, it trusts a price table I maintain by hand, and an admin in the same account can still delete their own Deny. But it's cheap, it stays out of the developers' way, and it answers both of leadership's questions.

So the next time someone taps my shoulder and says "Show me the bill," I open report.py, not a resignation letter.

If your org is going "AI-first" and you're watching the Bedrock graph with sweaty palms: the data to build your own kill switch is already in your account. You just have to wire it up. Then go touch grass. You earned it.


Top comments (0)