@polycode-projects/bedrock-meter-proxy
v1.10.1
Published
Loopback HTTP sidecar exposing @polycode-projects/bedrock-meter's pre-flight cost cap and tmct fallback to any Lambda runtime, packaged as a Lambda layer/extension.
Downloads
2,110
Maintainers
Readme
@polycode-projects/bedrock-meter-proxy
A loopback HTTP sidecar wrapping @polycode-projects/bedrock-meter's pre-flight cost
cap and tmct fallback, packaged as an AWS Lambda layer/extension. Any Lambda
runtime — Java, Python, .NET, whatever — gets metering, capping, and the
tmct fallback over http://127.0.0.1:<port>, without touching the Bedrock
SDK or a JS/JVM interop layer directly.
Why a sidecar, not a per-language SDK
@polycode-projects/bedrock-meter is a Node library. A Lambda function written in
another language can't import it. Rather than hand-porting the cap-check
and fallback logic into every runtime bedrock-meter should support, this
package runs the existing Node runtime as a small, always-local HTTP server
inside the same execution environment, reachable only from the function
that owns it.
The HTTP contract
Deliberately a native JSON API, not a byte-faithful mimic of Bedrock's wire format. SigV4 verification on a loopback, single-tenant socket buys nothing — there is no other principal on that socket to authenticate against — and a plain JSON contract is callable from any language's stdlib HTTP client with zero SDK dependency, which is the actual goal.
POST /v1/converse
body: {
modelId: string,
messages: [...], // mirrors Bedrock's ConverseCommandInput.messages
system?: [...], // mirrors ConverseCommandInput.system
inferenceConfig?: {...},
toolConfig?: {...},
guardrailConfig?: {...}, // mirrors ConverseCommandInput.guardrailConfig;
// ConverseStreamCommandInput's extra field
// (streamProcessingMode) is accepted too, used
// only when stream: true
scope?: { graphId?, sessionUuid?, origin? },
needs?: { contextTokens?, reasoning?, tools?, structured? },
stream?: boolean // see "Streaming" below
}
responses (stream absent or false):
200 { path: "bedrock", output: <real ConverseCommand response>,
cost: { usd_micros, gbp_micros, model_id } }
200 { path: "tmct-fallback", reason: "in-envelope",
output: { answer, narration, goal, canonicalised, dialogueAct,
factsUsed, factsLearned, digest? },
cost: { usd_micros: 0, gbp_micros: 0, model_id: "tmct" } }
factsUsed the sources the answer leaned on, one entry per citation:
{ provenance, title?, url? }. `provenance` is the verbatim
citation the answer carried; `title`/`url` are added
whenever it names a reference article, so a repeat ask
citing the bare tag reference:simplewiki:Benchmark@7557838
resolves to the same openable revision the first ask
spelled out in full.
factsLearned the facts the turn WROTE: each tmct fact row
({ id, subject, predicate, object, provenance, … }) plus
`text`, the triple rendered as a sentence, and
`title`/`url` on the same terms as above. Empty when the
turn wrote nothing — what a repeat ask answered out of
memory looks like.
402 { path: "refused", reason: "refused-over-cap", receipt: <cap decision> }
5xx { path: "error", reason: "bedrock-error" | "sidecar-timeout", message }
GET /v1/budget
query: graphId?, sessionUuid?, scopeKey?, scopeId?, projectedTokens?
200 { as_of, remaining: { day, global_remaining_p, global_cap_p,
session_remaining_p, session_cap_p,
month, monthly_remaining_p, monthly_cap_p },
today: <CounterRow>, month: <CounterRow>,
attribution: <AttributionRow> | null,
forecast: { projected_tokens, projected_cost_p,
calls_remaining_global, calls_remaining_monthly,
calls_remaining_session, limiting_budget, calls_remaining } }
400 { path: "error", reason: "bad-request", message } # e.g. scopeKey without scopeId
Read-only: this endpoint never writes to the ledger and never reserves
budget — it reads the same ledger this sidecar's own /v1/converse calls
write, over @polycode-projects/bedrock-meter's makeBudgetTool().
GET /healthz -> 200 { ok: true, uptime_s } # liveness only, no store/AWS check
GET / -> 200 { name, version, mode, caps, fallback: { envelope, target },
guardrail: { request_field, applied_on_fallback },
endpoints, tools, skills }
# tools/skills are the budget query's MCP tool descriptor and
# A2A skill descriptor, for discovery — the query itself is
# still GET /v1/budget above, not this route.Streaming
stream: true reuses the same route and body shape. A bad request or a
cap-breach refusal is decided before any header is written, so those stay
the same one-shot 400/402 JSON as above — never ndjson.
Once the request is actually going to stream (real Bedrock or the
tmct-fallback path), the response is 200 with Content-Type:
application/x-ndjson, chunked, one JSON object per line:
{ type: "delta", text: string } // one per completed text chunk
{ type: "tool-use", id: string, name: string, input: object } // one per completed tool-use block
{ type: "usage", inputTokens: number, outputTokens: number } // once
{ type: "stop", reason: string } // terminal, always lastOn the real Bedrock path these come off ConverseStreamCommand's own
event stream. On the tmct-fallback path (tmct is not itself token-streaming)
they're synthesized as a single burst: one delta with the full answer, a
usage frame at {inputTokens: 0, outputTokens: 0} (tmct's $0 guarantee),
and a stop frame — no tool-use frame, since tmct doesn't call tools.
A failure once the response is already committed to 200/ndjson can't
retroactively become a 5xx — it ends the stream with a final
{type: "stop", reason: "error"} frame instead. Only a failure before the
first frame (the real dispatch call itself throwing) still produces the
one-shot 502 { path: "error", reason: "bedrock-error" } above.
SSE framing was considered and rejected here for the same reason it was rejected on the non-streaming contract: on a loopback, single-tenant socket it buys nothing over plain ndjson.
Guardrails
Send guardrailConfig in Bedrock's own shape and it goes straight through to
both the buffered and streaming Bedrock call, unchanged.
If you omit trace, the sidecar sets it to "enabled". Bedrock only reports
billable guardrail units inside the trace, so a request with no trace would
meter its guardrail spend at zero. Send trace: "disabled" yourself if you
want that, and accept the cost: the guardrail's spend won't be priced.
The sidecar has no guardrail of its own and never injects one. If your IAM
conditions Converse on bedrock:GuardrailIdentifier, the sidecar runs under
that same role, so your request must carry the identifier the grant names.
The tmct fallback path applies no guardrail. It answers from a local
deterministic graph with no Bedrock call to attach a guardrail to. GET /
reports this as guardrail.applied_on_fallback: false.
guardrail_id lands on the ledger row for any turn that carried a
guardrailConfig, so a reconcile can tell a screened turn from an unscreened
one.
needs is passed straight through to the tmct fallback envelope gate. If
omitted, the proxy derives a conservative estimate from the request body
itself (a rough characters-per-token heuristic over the message/system
text) rather than defaulting to "always in envelope."
The 402 for a cap-breach refusal is deliberate: a caller can branch on the
status code alone — retryable 5xx vs. not-retryable 402 — without
parsing the response body.
Security: loopback only, no auth, by design
The server binds 127.0.0.1 only. This is hardcoded, not an option or
an env var — there is no legitimate reason to widen it, since this is a
single-tenant socket inside one Lambda execution environment, and doing so
"for flexibility" would be a real widening of the attack surface for zero
benefit. There is no authentication for the same reason: on a loopback,
single-tenant socket, no other principal exists to authenticate against.
GET /v1/budget carries the same property for the same reason: a spend
readout is operational intelligence, not a secret, and the thing that makes
skipping auth safe here is the socket, not the sensitivity of what it
returns.
The caller's responsibility: timeout and fallback posture
This is intra-namespace loopback traffic. A slow response means the sidecar itself is broken (or booting), not that Bedrock is slow — so the calling code should set its own short timeout on requests to this proxy, much shorter than it would tolerate for a direct Bedrock call.
The proxy does not decide what happens if it is unreachable (connection refused, timeout) — that is a policy choice for the consumer, and different consumers will want different answers:
- Fail loud: treat an unreachable proxy as a hard error. Correct when cost control is a compliance requirement, not a nicety.
- Degrade to direct, unmetered Bedrock: treat the proxy as best-effort observability, and fall through to calling Bedrock directly (unmetered, uncapped) if the sidecar can't be reached. Correct when availability matters more than metering that one call.
Document whichever choice your calling code makes; the proxy has no opinion.
Packaging: a wrapper script, not Node SEA
Node's Single Executable Application (SEA) feature requires the whole
application to be pre-bundled into one file via esbuild. tmct cannot be
esbuild-bundled — it reads its reference corpus off the filesystem, uses
createRequire, and lazily requires node:sqlite — so SEA is not an option
here. Instead, build/build-layer.mjs assembles a layer zip containing a
real linux-x64 Node binary plus the unbundled .mjs source tree
(src/, node_modules/, including the workspace-linked
@polycode-projects/bedrock-meter, tmct, and the optional
@aws-sdk/client-bedrock-runtime):
layer.zip
extensions/bedrock-meter-proxy # shim; the only file the Extensions
# loader execs directly
bedrock-meter-proxy/
node # real linux-x64 node binary
src/... + node_modules/... # proxy + runtime + tmct, unbundledbuild/build-layer.mjs requires npm, tar, and zip on PATH, and
network access to download the pinned Node build. Its output
(packages/proxy/build/) is gitignored — the git repo does not commit a
prebuilt layer. The published npm tarball does, though: CI runs
build:layer before npm publish, so node_modules/@polycode-projects/bedrock-meter-proxy/build/layer.zip
is a real, built zip once you npm install the package — a non-Node CDK
app's Code.fromAsset (or any other asset-loading step) can point straight
at that path without running the build script itself. Actually deploying
and smoke-testing the layer against a real Lambda is a separate,
operator-run step (not exercised by this package's own test suite, which
runs the HTTP contract as a plain node:http server with no AWS involved).
Metrics
createServer({ metrics, metricsScopeKeys }) accepts the same neutral
metrics exporter the runtime's makeAccountant does — wrap an OpenTelemetry
Meter with the runtime's otelMetricsExporter(), or pass your own
callback. This is programmatic-only today: the standalone
bedrock-meter-proxy entrypoint (src/cli.mjs, src/extension/register.mjs)
has no environment-variable wiring for it, since a JS callback cannot be
passed in through an env var — that would need a config-module-path
mechanism this package has not designed.
Environment variables
Read by both src/cli.mjs (a standalone process) and
src/extension/register.mjs (the Lambda Extension):
| Variable | Default | Meaning |
|----------------------------------|---------------------------------------|---------|
| BEDROCK_METER_PROXY_PORT | 8790 | Port the server listens on (always on 127.0.0.1) |
| BEDROCK_METER_TMCT_REPO_PATH | a fresh temp dir / /tmp/bedrock-meter-proxy-tmct | Where the embedded tmct session's sqlite memory + session log live |
| DDB_TABLE_NAME | unset (uses memoryStore()) | If set, uses dynamoStore() for the ledger instead |
| AWS_REGION | SDK default resolution | Region for dynamoStore() and the real Bedrock client |
Cap overrides (DAILY_GLOBAL_CAP_P etc.) are read directly by
@polycode-projects/bedrock-meter's cap-check — this package does not re-surface
them under different names.
Lambda Extensions API registration
src/extension/register.mjs registers for the SHUTDOWN event only, not
INVOKE. This extension does no per-invocation work — it just needs the
HTTP server up before the function's first invoke and closed cleanly at
shutdown — and subscribing to INVOKE would force the Lambda runtime to
wait on this extension's own Next call after every single invocation for
a sidecar with nothing invoke-scoped to do. See the comments in
register.mjs for the exact registration payload and the AWS documentation
it was verified against.
Two things this package cannot self-verify without a real deployed Lambda:
the packaged Node binary's actual glibc/kernel compatibility inside a live
execution environment, and CPU-throttling behavior during Init for an
extension that starts a long-lived server rather than returning quickly.
Both are call-outs for the operator-run smoke test against a real test
stack, not something the unit test suite here can cover.
