@polycode-projects/bedrock-meter-cdk
v1.10.1
Published
AWS CDK construct that provisions the store, flags, budget, alarms, dashboard and correct Bedrock IAM for @polycode-projects/bedrock-meter.
Downloads
2,142
Maintainers
Readme
@polycode-projects/bedrock-meter-cdk
The cost plane for @polycode-projects/bedrock-meter, as one AWS CDK construct.
new BedrockMeter(...) provisions the DynamoDB ledger table, the SSM control
flags, an AWS monthly Budget, the daily-cap CloudWatch alarms and dashboard,
and an optional Guardrail. grantMeteredBedrock(role) attaches the correct
3-way Bedrock IAM split — the Converse-vs-InvokeModel GuardrailIdentifier
condition-key trap, pre-solved.
Why
bedrock-meter meters and caps Bedrock spend in-process, before each call
returns — far ahead of AWS's own actuals (CloudWatch token metrics trail by
~13–40s; Cost Explorer / CUR dollars settle next-day). Because cost is known
pre-flight, you can throttle, degrade, and alarm — enforcement, not just
observation. This construct stands up the AWS-side resources the runtime reads
and writes, and gets the notoriously fiddly Bedrock IAM right the first time.
Install
npm install @polycode-projects/bedrock-meter-cdkA plain aws-cdk-lib TypeScript construct (built with tsc), published to
npm under the @polycode-projects scope on the GitLab project registry. Point the
scope at the registry with a project-local .npmrc:
@polycode-projects:registry=https://gitlab.com/api/v4/projects/<project-id>/packages/npm/Peer deps: aws-cdk-lib ^2.150.0 and constructs ^10.3.0. Node >=20.
Quickstart
import { BedrockMeter } from "@polycode-projects/bedrock-meter-cdk";
// In a Stack:
const meter = new BedrockMeter(this, "Meter", {
envName: "prod",
capDailyGbp: 7.9,
capMonthlyGbp: 237,
guardrail: true, // provision a PII/content Guardrail and wire its IAM
});
// Grant a Lambda (or any IGrantable) the correct, least-privilege Bedrock IAM:
meter.grantMeteredBedrock(myLambda.role);
// Point the runtime at the provisioned table:
myLambda.addEnvironment("DDB_TABLE_NAME", meter.table.tableName);Props
| Prop | Type | Default | Meaning |
| --- | --- | --- | --- |
| envName | string | "ci" | Label woven into resource names + SSM paths. |
| capDailyGbp | number | 7.9 | Daily spend cap in GBP (≈ $10 at fx 0.79). |
| capMonthlyGbp | number | 237 | Monthly spend cap in GBP. |
| fxGbpUsd | number | 0.79 | GBP→USD rate used to derive the USD AWS Budget. |
| guardrail | boolean | false | Provision a PII/content Guardrail and wire it into the IAM grant. |
| namespace | string | "BedrockMeter/Budget" | CloudWatch EMF namespace the runtime emits into. |
| modelIds | string[] | Nova + Titan set | Foundation-model ids InvokeModel* is restricted to. |
| budgetNotifyEmails | string[] | [] | Emails to subscribe to the AWS Budget at 50/80/100%. |
| taggedSpendModelIds | string[] | [] | On-demand foundation-model ids to provision a cost-allocation-tagged Application Inference Profile for — see Cost-allocation-tagged spend below. |
| costAllocationTagKey | string | "bedrock-meter-metered" | Tag key stamped on every provisioned profile. |
| costAllocationTagValue | string | "true" | Tag value stamped on every provisioned profile. |
Exposed resources
After construction these are public fields for further wiring:
| Field | Type | What |
| --- | --- | --- |
| table | dynamodb.Table | The single-table ledger (pk/sk, TTL, PITR). |
| capParam | ssm.StringParameter | /bedrock-meter/<env>/cap — JSON daily/monthly caps. |
| enabledParam / degradedParam | ssm.StringParameter | Kill-switch + degraded-mode flags. |
| budget | budgets.CfnBudget | Monthly AWS Budget in USD, filtered to Amazon Bedrock. |
| alarms | cloudwatch.Alarm[] | Daily-cap alarms at 80 / 95 / 100%. |
| dashboard | cloudwatch.Dashboard | Daily-spend graph + single-value widget. |
| guardrail? | bedrock.CfnGuardrail | The optional Guardrail (when guardrail: true). |
| taggedProfileArns | Record<string, string> | modelId -> Application Inference Profile ARN, one entry per id in taggedSpendModelIds. |
grantMeteredBedrock(grantee, opts?)
Bedrock IAM is split three ways because the GuardrailIdentifier condition key
behaves differently across actions. This method emits all three, correctly:
Converse/ConverseStream→Resource: "*"(plus aGuardrailIdentifiercondition when a guardrail is configured).InvokeModel/InvokeModelWithResponseStream→ restricted to the foundation-model ARNs (and EU inference-profile ARNs), with no condition.ApplyGuardrail→ the guardrail ARN (only when a guardrail exists).
Plus SSM read on the control flags, DynamoDB read/write on the ledger table, and
pricing:GetProducts (us-east-1) for the live price tier.
Pass opts.guardrailIdentifiers to grant a guardrail this construct didn't
provision — one you deployed elsewhere. Each entry is a guardrail id or a
full ARN; a bare id is qualified into the stack's own account and region for
ApplyGuardrail. It unions with any guardrail the construct did provision, so
the GuardrailIdentifier condition and the ApplyGuardrail grant cover both:
meter.grantMeteredBedrock(myLambda.role, {
guardrailIdentifiers: ["my-existing-guardrail-id"],
});Without this option, a grantee whose guardrail lives outside this construct
gets a Converse grant with no GuardrailIdentifier condition and no
ApplyGuardrail at all.
Cost-allocation-tagged spend (taggedSpendModelIds)
Bedrock Converse/InvokeModel calls aren't taggable directly — there is no
per-call resource to attach a cost-allocation tag to. The mechanism this
construct uses is an AWS::Bedrock::ApplicationInferenceProfile: a taggable
resource whose ARN you pass as modelId on your Bedrock call instead of the
raw model id. Every call routed through the profile ARN is then attributed to
that profile's tags in Cost Explorer / CUR.
const meter = new BedrockMeter(this, "Meter", {
envName: "prod",
taggedSpendModelIds: ["amazon.nova-lite-v1:0", "amazon.nova-micro-v1:0"],
});
// meter.taggedProfileArns["amazon.nova-lite-v1:0"] -> the profile ARN.
// Pass that ARN as `modelId` on your ConverseCommand/InvokeModelCommand
// instead of "amazon.nova-lite-v1:0" for that call's spend to be tagged.The exact convention, documented in full because a non-TypeScript
consumer (a Java/Maven CDK app, say — bedrock-meter-cdk is a plain TS
construct, not jsii, so it can't be imported from another language) needs
to reproduce this by hand rather than call the construct directly:
- Profile name:
bedrock-meter-<envName>-<slug>, where<slug>is the model id lowercased with every run of non-[a-z0-9]characters collapsed to a single hyphen (no leading/trailing hyphen) — e.g.amazon.nova-lite-v1:0→amazon-nova-lite-v1-0. The exact algorithm isslugifyModelId(), also exported by this package if you're calling it from TypeScript/JavaScript. modelSource.copyFrom:arn:aws:bedrock:<region>::foundation-model/<modelId>— same-region, on-demand foundation models only.- Tags:
{ Key: costAllocationTagKey, Value: costAllocationTagValue }(defaultbedrock-meter-metered/true) plus{ Key: "bedrock-meter-env", Value: envName }.
Constraints, not bugs: embedding models (e.g. Titan-embed) don't support
Application Inference Profiles — AWS rejects those at deploy time, this
construct doesn't pre-filter the list. Cross-region (eu.-prefixed) profile
ids aren't supported by taggedSpendModelIds either — only same-region,
on-demand foundation models.
The tag key still needs manual activation. Provisioning the tag is not
the same as it appearing in Cost Explorer/CUR — activate
costAllocationTagKey once, per account, under AWS Billing → Cost allocation
tags, before it's queryable (and expect a real settling delay after that:
tags typically become visible in Cost Explorer/CUR ~24h after first tagged
spend — see reports/EVIDENCE_LOG.md for what that looks like in practice
for this repo's own evidence pipeline, which uses this exact mechanism with
its own tag key).
Links
- Repository & full docs: https://gitlab.com/polycode-projects/bedrock-meter
- Runtime library:
@polycode-projects/bedrock-meter - Issues: https://gitlab.com/polycode-projects/bedrock-meter/-/issues
Licence
Apache-2.0 © 2026 Polycode Limited.
