@polycode-projects/bedrock-meter
v1.10.1
Published
Real-time, in-process, pre-flight AWS Bedrock spend metering and capping.
Maintainers
Readme
@polycode-projects/bedrock-meter
Know the cost of every AWS Bedrock call — before it returns. Then cap, degrade, or alarm on it.
bedrock-meter is a tiny, in-process library you wrap your BedrockRuntimeClient
with. It prices every Converse / InvokeModel call pre-flight, meters spend
into a pluggable store, and enforces GBP spend caps before the call is allowed
to run. No sidecar, no agent, no AWS round-trip on the hot path.
Why
- Finest granularity, immediately, and before the spend. It prices every Bedrock call per-request, in-process, before the call returns — versus AWS's own actuals, which trail: CloudWatch token metrics arrive ~13–40s later, and Cost Explorer / CUR dollar data only settles next-day (~24–48h). You get the number pre-flight. This is the core differentiator.
- Built on (1): throttle, fall back, alarm. Because the cost is known
before the call, you can refuse or cap it (
onCapExceeded) and alarm — enforcement, not just observation. You pick the model; the meter meters it and never swaps it. - A grounded $0 fallback instead of an outage. (Roadmap.) When the cap
trips, calls can fall back to an embedded deterministic no-LLM engine
(tmct) that answers from grounded memory or refuses honestly — the
dispatchFallback/TMCT_ENVELOPEexports are the seam.
A metered battery (Nova Lite + Nova Micro + Titan embed) reconciled to 0% token drift versus CloudWatch in our evidence harness.
Install
npm install @polycode-projects/bedrock-meterThe package is published to the GitLab project npm registry under the
@polycode-projects scope. Point the scope at the registry with a project-local
.npmrc (public npm is planned):
@polycode-projects:registry=https://gitlab.com/api/v4/projects/<project-id>/packages/npm/Node >=20. TypeScript types are bundled (index.d.ts). The AWS SDK packages
are optionalDependencies — install them only for dynamoStore() / the live
pricing tier; the default in-memory + pinned setup needs no AWS deps.
Quickstart
Wrap your client, get pre-flight capping plus per-call pricing:
import { meter } from "@polycode-projects/bedrock-meter";
import { BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
const client = meter(new BedrockRuntimeClient({ region: "eu-west-2" }), {
caps: { dailyGbp: 3 }, // refuse calls once today's £3 cap is hit
onCapExceeded: "throw", // 'throw' | 'degrade' | (decision, command) => …
scope: { tenant: "acme" }, // attribute spend to arbitrary keys
});
await client.send(new ConverseCommand({ /* … */ })); // metered + capped
const today = await client.meter.spentToday(); // { gbp_micros, requests, … }No AWS needed — memoryStore()
For tests, demos, or local dev, swap in the in-memory store and the pinned price table. Nothing touches AWS:
import { meter, memoryStore, pinned } from "@polycode-projects/bedrock-meter";
// A fake client that returns a Converse-shaped usage block.
const fake = {
async send() {
return { usage: { inputTokens: 1000, outputTokens: 500 } };
},
};
const client = meter(fake, {
caps: { dailyGbp: 1 },
store: memoryStore(),
priceFeed: pinned(),
});
// estimate() projects GBP pence for a hypothetical call, pre-flight:
console.log(client.meter.estimate({ inputTokens: 1000, outputTokens: 500 }));The explain CLI
Inspect pricing from the shell without writing any code:
npx bedrock-meter explain amazon.nova-lite-v1:0 --in 1000 --out 500
# → { model, usd, gbp, fx_gbp_usd, … }
# Prompt-cache tokens are billed on TOP of --in, so they get their own flags.
# Add --cache-write-5m / --cache-write-1h when you know which cache was written.
npx bedrock-meter explain amazon.nova-pro-v1:0 --in 1000 --cache-read 20000 --cache-write 2000
# Explaining a call routed through an inference profile: pass the underlying
# foundation model id, not the profile ARN. The ARN is opaque, so it prices at
# the unknown-model fallback shape and reports known: false.
npx bedrock-meter prices # dump the pinned price table
npx bedrock-meter caps # show the default capsForecasting a batch
estimate() and forecast() answer different questions and both stay
available. estimate() is synchronous, flat-rate and model-agnostic — the
same projection checkBeforeTurn uses, so it always agrees with the cap.
forecast() is async and model-specific, priced through the same feed the
ledger records with, so it agrees with the receipts.
const { totals, headroom } = await client.meter.forecast([
{ modelId: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", inputTokens: 12_000, outputTokens: 2_000 },
{ modelId: "amazon.nova-pro-v1:0", inputTokens: 4_000, outputTokens: 1_000, cacheReadInputTokens: 30_000 },
]);
totals.gbp; // priced cost of the batch, in GBP
headroom.fits_global_day; // does it fit in what's left of today's cap?The headroom block reports both figures rather than picking one:
batch_priced_p is the model-specific cost the ledger would record, and
batch_cap_p is what the cap counters would actually accumulate at
cap-check's flat rates. The fits_* flags use batch_cap_p, since that is
what moves against those caps. Token counts only — the library has no
tokenizer and never takes prompt text. Nothing is written and no decision is
taken; the caller decides.
API
| Export | Kind | Purpose |
| --- | --- | --- |
| meter(client, opts) | fn | Wrap a client; returns the same client with .send patched and .meter attached. |
| client.meter.spentToday() | fn | Today's counters (requests, tokens, usd_micros, gbp_micros). |
| client.meter.estimate({inputTokens, outputTokens}) | fn | Project the GBP pence cost of a call, pre-flight. |
| client.meter.forecast(items, scope?) | fn | Price a batch of hypothetical calls and report it against the remaining budget. |
| makeForecast({priceFeed, fx, capCheck}) | fn | The same batch forecaster without wrapping a client. |
| CapExceededError | class | Thrown by .send() when a cap is hit and onCapExceeded: 'throw'. |
| memoryStore() / dynamoStore(opts) | fn | Store seam — in-memory (no AWS) vs DynamoDB. |
| pinned() / awsPricingApi(opts) / layered(feeds) | fn | Price-feed seam — committed table, live AWS Pricing API, fall-through stack. |
| scope(obj) / scopeKey(obj) | fn | Normalise an attribution object / make a stable cache key. |
| computePrice({priceFeed, fx, modelId, inputTokens, outputTokens, cacheReadInputTokens, cacheWriteInputTokens, guardrailTextUnits}) | fn | Price one invocation in integer micros. Never throws. |
| capBasisInputTokens(usage) | fn | A turn's cache tokens as the plain input tokens they cost at cap-check's flat rate. |
| guardrailUnitsFromResponse(resp, text?) | fn | Billable guardrail text units from an ApplyGuardrail response. |
| guardrailTextUnitsOf(response) | fn | Billable guardrail text units across a Converse response's inline trace assessments as well as a standalone ApplyGuardrail usage block. |
| makeAccountant / makeCounters / makeAttribution / makeCapCheck / makeTurnBudget | fn | The injectable building blocks behind meter(). |
| dispatchFallback / makeTmctTarget / httpDispatch / TMCT_ENVELOPE / inEnvelope | fn/const | The tmct $0 fallback: envelope gate + metered dispatch to a tmct endpoint (priced £0). |
| readTmctEnvelopeStamp() | fn | Which tmct backs the fallback, and which AGENTBENCH run stamped its published capability figures — read from the installed package. Lazy, memoized, never rejects. |
| runtimeFlags | namespace | Kill-switch / degraded / high mode flag resolvers. |
| makeBudgetTool | fn | Read-only budget/usage/forecast query over the metered ledger, plus MCP/A2A descriptors (see below). |
Budget as an agent tool
The budget query is exposed as data plus mountable descriptors, so any MCP server or A2A agent can serve it. bedrock-meter depends on no MCP or A2A SDK.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { makeBudgetTool, toMcpToolResult, dynamoStore } from "@polycode-projects/bedrock-meter";
const budget = makeBudgetTool({ store: dynamoStore({ tableName: "…" }) });
const server = new McpServer({ name: "bedrock-meter", version: "1.0.0" });
server.registerTool(
budget.descriptor.name,
{ description: budget.descriptor.description, inputSchema: budget.descriptor.inputSchema },
async (input) => toMcpToolResult(await budget.handle(input)),
);For A2A, budget.skill is an AgentSkill and budgetAgentCard({ url, version })
builds a minimal AgentCard advertising it.
The handler is read-only. It reads the same ledger meter() writes and never
writes to it — the store it holds is wrapped in a guard that throws on any write.
Subpath exports
| Subpath | Purpose |
| --- | --- |
| @polycode-projects/bedrock-meter/schema | Ledger key shapes and currency helpers, for a reconcile/extract job that needs the same pk/sk a live meter() call writes. |
| @polycode-projects/bedrock-meter/cap | The daily/monthly cap resolver, for an ops Lambda reading the same resolved caps meter() sees. |
| @polycode-projects/bedrock-meter/budget-tool | The read-only budget/usage/forecast handler and its MCP/A2A descriptors, standalone. |
| @polycode-projects/bedrock-meter/runtime-flags | The kill-switch / degraded / high mode flag resolvers standalone. |
| @polycode-projects/bedrock-meter/fallback-http | makeTmctTarget / httpDispatch / TmctDispatchError with zero dependency on the-mechanical-code-talker. Importing the package root also reaches makeEmbeddedTmctTarget, and a single-file bundler (esbuild --bundle) statically inlines its whole tmct dependency tree — tens of megabytes of unpacked tmct in your bundle, even when the embedded target is never called. If you only dispatch to a remote tmct serve process, import this subpath instead of the root. |
meter() options
| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| caps.dailyGbp | number | — | Global daily cap, in GBP. |
| caps.monthlyGbp | number | — | Monthly cap, in GBP. |
| caps.perScopeDailyGbp | number | — | Per-scope (e.g. per-session) daily cap, in GBP. |
| store | Store | memoryStore() | Where counters live. |
| priceFeed | PriceFeed | pinned() | Price source. |
| priceModelIds | map | fn | — | Price a call made against an inference-profile ARN as the underlying model. |
| fx | number | fn | {rate} | pinned rate | GBP↔USD override. |
| scope | object | {} | Attribution keys applied to every call. |
| onCapExceeded | 'throw' | 'degrade' | fn | 'throw' | Cap-breach policy (see below). |
| modes.degraded | boolean | false | Run in degraded mode. |
Caps are always in GBP. onCapExceeded:
'throw'— throwCapExceededErrorand make the call (default).'degrade'— let the call through, surfacing the decision onclient.meter.lastCapDecisionfor you to inspect.- a function
(decision, command) => any— your handler; its return value is returned from.send()(e.g. swap to a cheaper model, return a cached reply).
Seams
- Store —
memoryStore()(no AWS) ordynamoStore({ tableName }). Both implement the same single-table interface with an atomic counteradd. - Price feed —
pinned()(committed JSON table, offline fallback),awsPricingApi({ store })(live us-east-1 Pricing API, cached), andlayered([live, pinned()])to try live first and fall back. No feed ever throws on a price miss.
Prompt-cache pricing
Bedrock reports cache tokens separately, and its inputTokens count
excludes them: the total input you are billed for is
inputTokens + cacheReadInputTokens + cacheWriteInputTokens. The meter reads
all three, prices them separately, records them as their own columns, and
charges them against the spend cap.
The pinned table carries cache_read_per_1k_usd and cache_write_per_1k_usd
per model, plus cache_write_1h_per_1k_usd for the models that offer a
1-hour cache alongside the default 5-minute one. Two rules make sure a cache
token is never priced too low, since too low is a cap bypass while too high is
only a false alarm:
- A model entry with no pinned cache price is charged a bound rather than a guess: the input rate for a read (every measured family bills a read at 0.1x input, so the input rate can only over-state) and twice the input rate for a write.
- A cache write whose cache lifetime the response never reported is charged at
the highest write rate that model has. On a model with only a 5-minute cache
that is its ordinary write rate; on one that also offers a 1-hour cache it is
the 1-hour rate, because there is no way to tell which was written. Send and
read Converse's
cacheDetailsbreakdown to be charged the real split.
The live Pricing API feed answers null for every cache unit — its
inferenceType filter cannot express one — so layered([live, pinned()])
falls through to the pinned cache prices rather than silently reusing the
input price.
- Scope — any
{ key: value }set ({ tenant, user, feature }); the accountant keeps one lifetime attribution counter per non-empty key.
Cost-allocation tags and price identity
Routing a call through an Application Inference Profile is the only way AWS
attributes the spend to that profile's cost-allocation tags. To do it you send
the profile ARN as the modelId. The ARN is opaque: nothing in it names the
foundation model it copies from, so only you know what it should price as.
Left unmapped, the ARN misses the price table and prices at the documented
unknown-model fallback shape.
priceModelIds tells the meter what an ARN prices as. Give it a map, or a
function if you resolve the id at call time:
const client = meter(new BedrockRuntimeClient({}), {
priceModelIds: {
"arn:aws:bedrock:eu-west-2:123456789012:application-inference-profile/abc123":
"amazon.nova-micro-v1:0",
},
});The call itself is untouched. The command still carries the ARN, so the spend still lands under the profile's tags. Each ledger row then records three more columns:
model_id— unchanged: the id the call was made with, ARN and all.price_model_id— the id the price was looked up under.price_model_source—"command"or"override".price_model_known— did the feed's own table carryprice_model_id?falseis the row to alert on: the price came from the unknown-model fallback, not from that model's real price.nullmeans the feed exposes no model table and cannot say.
A billing reconcile joins Cost Explorer's tagged dollars on model_id and
groups the meter's own rows on price_model_id, with no mapping table on the
side. client.meter.forecast() prices through the same map, so a forecast by
ARN matches the row that call later writes.
Mapping an ARN can move a price down, since an unmapped ARN often prices as a dearer model than the real one. That is a correction to the priced ledger, not a weakening of the cap: the cap counters run on model-agnostic flat rates and this option does not touch them.
Metrics
Spend and usage metrics are emitted through an injectable exporter. Nothing is
emitted, and no event is even built, unless you inject one. The EMF
DailyGbpMicros line to stdout is unaffected either way.
const client = meter(new BedrockRuntimeClient({}), {
metrics: (event) => console.log(JSON.stringify(event)),
});Two event types arrive: "request", once per metered invocation, carrying the
priced cost, model, operation, outcome and latency; and "window", once per
counter increment, carrying the running day total. Money is always integer
micros.
For OpenTelemetry, bridge your own Meter — bedrock-meter depends on no
OpenTelemetry package:
import { metrics } from "@opentelemetry/api";
import { meter, otelMetricsExporter } from "@polycode-projects/bedrock-meter";
const client = meter(new BedrockRuntimeClient({}), {
metrics: otelMetricsExporter(metrics.getMeter("my-service")),
metricsScopeKeys: ["tenant"],
});metricsScopeKeys is an allowlist, and it defaults to empty. scope is an
arbitrary key/value set; flattening it onto metric attributes wholesale would
mint an unbounded time series per session, so keys are opted in by name.
Where the prices come from
Every price this library uses can be traced to where it came from and when it was fetched. No tier below ever makes a network call at runtime — refreshing prices is something you run yourself, at build time or by hand.
Three tiers, checked in order, per model row — overriding one model's price never orphans the rest:
| Tier | File (in your working directory) | Env override | Written by |
| --- | --- | --- | --- |
| user override | bedrock-meter.config.json | BEDROCK_METER_CONFIG | you, by hand |
| generated update | bedrock-meter.prices.json | BEDROCK_METER_PRICES | update-prices |
| shipped default | the committed data/pricing-pinned.json | — | our CI |
bedrock-meter update-prices
Fetches AWS's public bulk price list and the ECB's daily exchange rates, and writes a provenance-stamped price file:
# Fetch fresh prices and rates, write them next to your project (the generated tier).
npx bedrock-meter update-prices
# See what would change without writing anything.
npx bedrock-meter update-prices --dry-run
# Refresh this package's own shipped default (CI does this on a schedule).
npx bedrock-meter update-prices --pinned| Flag | Default | Meaning |
| --- | --- | --- |
| --region <r> | AWS_REGION or eu-west-2 | Which region's price list to read. |
| --out-file <path> | ./bedrock-meter.prices.json | Where to write the generated file. |
| --pinned | off | Write the shipped default instead of the generated file — refuses outside a checkout of this repo. |
| --dry-run | off | Print the drift report; write nothing. |
| --max-drift <pct> | 25 | Refuse to write if any row moves further than this. |
| --currencies a,b,c | every ECB currency | Restrict which FX rates to fetch. |
| --models id,id | every model already priced | Add new models to price, without dropping any. |
| --no-fx / --no-prices | off | Skip one side of the refresh. |
Both sources are credential-free:
- AWS's public bulk price list — no key or
@aws-sdk/client-pricingneeded, just AWS's own published data. - The ECB's daily euro foreign exchange reference rates — updated on
TARGET working days, reused under the ECB's free-of-charge terms. Every
generated file cites the ECB as source in its
sources[]block.
Some models have no machine-readable AWS list price (recent Anthropic models,
for instance), or a measured price that genuinely disagrees with the list.
Those rows carry a hold, so a refresh reports the list price beside the
held one instead of overwriting a real measurement:
"amazon.nova-lite-v1:0": {
"input_per_1k_usd": 0.00006,
"hold": { "reason": "measured from Cost Explorer spend; list price is 40% higher", "measured_at": "2026-05-30" }
}Per-row provenance
bedrock-meter prices --provenance shows which tier answered each model's
price and when it was fetched — the one-command answer to "why is this call
priced this cheap":
npx bedrock-meter prices --provenance
# → { "amazon.nova-lite-v1:0": { …, "provenance": { "tier": "pinned", "source": "aws-price-list", "fetched_at": "…" } } }bedrock-meter fx prints the resolved rate table with its own source and
fetch date.
Currency display
The ledger and the cap engine still work in GBP and USD internally — nothing about enforcement changes. Everything else can display in any currency the ECB publishes a rate for:
npx bedrock-meter explain amazon.nova-lite-v1:0 --in 1000 --out 500 --currency EUR
npx bedrock-meter caps --currency JPYSet BEDROCK_METER_CURRENCY to pick a default display currency without
passing --currency every time.
Caps also take a currency-neutral form beside the existing GBP fields — supply one or the other, not both:
caps: { daily: { amount: 12, currency: "USD" } } // instead of caps.dailyGbpmakeCounters() can emit an extra DailySpendMicros EMF gauge in your
chosen currency, opt-in and alongside the existing DailyGbpMicros line,
never instead of it:
makeCounters(store, { displayCurrency: "EUR" });Links
- Repository & full docs: https://gitlab.com/polycode-projects/bedrock-meter
- CDK construct:
@polycode-projects/bedrock-meter-cdk - Issues: https://gitlab.com/polycode-projects/bedrock-meter/-/issues
Licence
Apache-2.0 © 2026 Polycode Limited.
