@plumbus/ai-bedrock 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ # Bedrock pricing — pull, normalize, mount
2
+
3
+ **Exact path in a consumer app:** `node_modules/@plumbus/ai-bedrock/instructions/pricing.md`
4
+
5
+ Prescriptive recipe for operators and agents. Read this before wiring `AI_BEDROCK_PRICING_FILE` or debugging `$0` costs.
6
+
7
+ ## 60-second answer
8
+
9
+ 1. Bedrock APIs return **tokens only** — never USD.
10
+ 2. **Production:** mount `AI_BEDROCK_PRICING_FILE` with rates keyed by Bedrock family ids you use. Do not rely on auto-download completeness.
11
+ 3. Rates *can* be bootstrapped from the **public** AWS Price List (no IAM):
12
+ `https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/{AmazonBedrock|AmazonBedrockFoundationModels}/current/{region}/index.json`
13
+ 4. Auto-download is **best-effort** (extract SDK ids from offer attrs / `usagetype` when present; limited generative display-name inference). Unkeyed rows produce **no cost at all** — the `cost` field is omitted rather than reported as `$0`, so unpriced spend is never recorded as free.
14
+
15
+ ## Why this exists
16
+
17
+ Amazon Bedrock **does not return USD** on Converse / ConverseStream / InvokeModel. Responses include `usage` (input/output/cache tokens) only. Plumbus multiplies that usage by rates this package loads.
18
+
19
+ | Mode | Source of rates | When to use |
20
+ |------|-----------------|-------------|
21
+ | **Pricing file (required for reliable production / k8s)** | You mount a normalized JSON keyed by **Bedrock model family ids** | Locked-down NetworkPolicy, identical rates across replicas, models AWS Price List cannot key cleanly |
22
+ | **Auto-download (best-effort / local)** | Package fetches AWS Price List and tries to key rows to model ids | Dev convenience only — AWS does **not** publish a stable modelId↔price join |
23
+
24
+ When `pricingFilePath` / `AI_BEDROCK_PRICING_FILE` is set, **auto-download is skipped** (file wins).
25
+
26
+ ### How auto-download keys rates (no forever model catalog)
27
+
28
+ AWS offer JSON often uses marketing `servicename` strings, not Converse model ids. We do **not** maintain a hardcoded list of every Bedrock model. Resolution order:
29
+
30
+ 1. SDK-shaped id already present (`attributes.modelId` / `attributes.model` matching `provider.model…`, or the same shape embedded in `usagetype`) — **structural match, no vendor allowlist**. In real offer files this is mostly the `usagetype`-embedded form; `attributes.model` is usually a display name.
31
+ 2. Generative inference from a few stable **display-name shapes** (Claude tier+version, Titan embed, Nova tier) — not a growing catalog table. First-party rows put the name in `attributes.titanModel` / `attributes.model` (`servicename` is the literal `"Amazon Bedrock"`), so those are read too.
32
+ 3. Otherwise **skip** → that model has **no cost** until you add it to the pricing file
33
+
34
+ Reality check against `us-east-1` today: this keys ~66 families, including Claude (regional marketplace rows), Nova, Titan embeddings, and anything with an id in `usagetype`. It does **not** key Llama, Mistral, Mixtral, or newer Nova generations whose display names it cannot parse. Put the models you actually call in the file.
35
+
36
+ **Production rule:** put the models you call in `AI_BEDROCK_PRICING_FILE`. Do not depend on auto-download completeness.
37
+
38
+ ## Where rates come from (AWS Price List — public, no IAM)
39
+
40
+ Host is always **`pricing.us-east-1.amazonaws.com`** (AWS Price List endpoint). The **`{region}` path segment** is the Bedrock region whose on-demand rates you want (e.g. `us-east-1`, `eu-west-1`).
41
+
42
+ ### URL pattern
43
+
44
+ ```text
45
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/{ServiceName}/current/{region}/index.json
46
+ ```
47
+
48
+ `{ServiceName}` — this package auto-downloads **both**:
49
+
50
+ | ServiceName | Typical contents | Approx size (us-east-1) |
51
+ |-------------|------------------|-------------------------|
52
+ | `AmazonBedrock` | Older / first-party Bedrock SKUs (`model`, `feature: On-demand Inference`, units often **per 1K tokens**) | ~1.4MB |
53
+ | `AmazonBedrockFoundationModels` | Marketplace / “Bedrock Edition” Claude SKUs (`servicename`, units often **per 1M tokens**) | ~0.4MB |
54
+ | `AmazonBedrockService` | Smaller ancillary index (optional; not required for chat/embed v1) | ~34KB |
55
+
56
+ No auth headers. Outbound HTTPS only.
57
+
58
+ ### Concrete URL examples
59
+
60
+ **us-east-1 (N. Virginia):**
61
+
62
+ ```text
63
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/us-east-1/index.json
64
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-east-1/index.json
65
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockService/current/us-east-1/index.json
66
+ ```
67
+
68
+ **eu-west-1 (Ireland):**
69
+
70
+ ```text
71
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/eu-west-1/index.json
72
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/eu-west-1/index.json
73
+ ```
74
+
75
+ **us-west-2:**
76
+
77
+ ```text
78
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/us-west-2/index.json
79
+ https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/us-west-2/index.json
80
+ ```
81
+
82
+ Replace the final path region with your adapter `region` / `AI_BEDROCK_REGION`. Do **not** change the `pricing.us-east-1.amazonaws.com` host unless AWS documents a different Price List endpoint for your partition (GovCloud differs).
83
+
84
+ ### Pull with curl (CI / laptop)
85
+
86
+ ```bash
87
+ REGION=us-east-1
88
+ mkdir -p /tmp/bedrock-offers
89
+ curl -fsSL \
90
+ "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/${REGION}/index.json" \
91
+ -o "/tmp/bedrock-offers/AmazonBedrock-${REGION}.json"
92
+ curl -fsSL \
93
+ "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/${REGION}/index.json" \
94
+ -o "/tmp/bedrock-offers/AmazonBedrockFoundationModels-${REGION}.json"
95
+ ```
96
+
97
+ ### One-shot: curl + normalize (CI)
98
+
99
+ Requires `@plumbus/ai-bedrock` resolvable from the working directory (app or monorepo). Export `REGION` / `OUT` so the Node process can read them:
100
+
101
+ ```bash
102
+ export REGION=us-east-1
103
+ export OUT=bedrock-pricing.json
104
+ mkdir -p /tmp/bedrock-offers
105
+ curl -fsSL \
106
+ "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrock/current/${REGION}/index.json" \
107
+ -o "/tmp/bedrock-offers/AmazonBedrock-${REGION}.json"
108
+ curl -fsSL \
109
+ "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonBedrockFoundationModels/current/${REGION}/index.json" \
110
+ -o "/tmp/bedrock-offers/AmazonBedrockFoundationModels-${REGION}.json"
111
+
112
+ node --input-type=module <<'EOF'
113
+ import { readFileSync, writeFileSync } from 'node:fs';
114
+ import { parseAwsOfferRates } from '@plumbus/ai-bedrock';
115
+
116
+ const region = process.env.REGION ?? 'us-east-1';
117
+ const out = process.env.OUT ?? 'bedrock-pricing.json';
118
+ const offers = [
119
+ JSON.parse(readFileSync(`/tmp/bedrock-offers/AmazonBedrock-${region}.json`, 'utf8')),
120
+ JSON.parse(
121
+ readFileSync(`/tmp/bedrock-offers/AmazonBedrockFoundationModels-${region}.json`, 'utf8'),
122
+ ),
123
+ ];
124
+ const rates = parseAwsOfferRates(offers);
125
+ const models = Object.fromEntries(
126
+ [...rates].map(([id, r]) => [
127
+ id,
128
+ { inputPerMTok: r.inputPerMTok, outputPerMTok: r.outputPerMTok, kind: r.kind },
129
+ ]),
130
+ );
131
+ writeFileSync(
132
+ out,
133
+ JSON.stringify({ version: 1, region, generatedAt: new Date().toISOString(), models }, null, 2),
134
+ );
135
+ console.log('wrote', out, Object.keys(models).length, 'models');
136
+ EOF
137
+ ```
138
+
139
+ ## What the package keeps vs skips
140
+
141
+ Auto-download and `parseAwsOfferRates()` keep **regional on-demand standard input/output** only (same spirit as core’s OpenAI/Anthropic “standard tier” policy).
142
+
143
+ **Kept (examples):**
144
+
145
+ - Rows whose attrs/`usagetype` already contain an SDK-shaped id (`amazon.…`, `anthropic.…`, …)
146
+ - FoundationModels marketplace rows that match generative Claude / Nova display shapes
147
+ - First-party Titan embedding rows, whose model name lives in `attributes.titanModel` (`TitanEmbeddingsV2-Text-input`, `Titan Embeddings G1 Text`)
148
+ - Units converted to **USD per 1M tokens** (first-party rows are per **1K tokens**, marketplace rows per **1M**)
149
+
150
+ **Skipped:**
151
+
152
+ - Batch, latency-optimized, reserved / provisioned, priority, flex
153
+ - Rows whose unit is not a token unit (`hour`, `image`, `Model/month`, `1M TPM Hour`, `Search Units`, …) — a non-token unit is never guessed at
154
+ - The 1-hour cache-write tier (billed above the 5-minute write this package models)
155
+
156
+ **Kept separately (not merged into the regional rate):**
157
+
158
+ - Global / cross-region SKUs (`…_Global-…`) → `globalInputPerMTok` / `globalOutputPerMTok`, used only for `global.`-prefixed model ids
159
+ - Regional cache read/write SKUs → `cacheReadPerMTok` / `cacheWritePerMTok`
160
+
161
+ When several standard-tier SKUs map to the same family, the **lowest** rate wins, so the result does not depend on JSON key order.
162
+ - Cache tokens are billed **on top of** `inputTokens` (AWS excludes them from that field) using the published cache SKU when the parser found one, else ~0.1× read / ~1.25× write of the input rate
163
+ - Marketing names that do not match extraction or generative shapes → **no rate** (use the pricing file)
164
+
165
+ **Lookup at request time:** `normalizeBedrockModelId` strips geo prefixes (`us.`, `eu.`, …) and dated `-YYYYMMDD-vN:M` suffixes so versioned Converse ids hit family keys:
166
+
167
+ | Runtime model id | Family key used for rates |
168
+ |------------------|---------------------------|
169
+ | `us.anthropic.claude-haiku-4-5-20251001-v1:0` | `anthropic.claude-haiku-4-5` |
170
+ | `anthropic.claude-sonnet-4-5-20250929-v1:0` | `anthropic.claude-sonnet-4-5` |
171
+ | `amazon.titan-embed-text-v2:0` | `amazon.titan-embed-text-v2` |
172
+
173
+ Version-less keys are matched too: `amazon.nova-lite-v1:0` normalizes to `amazon.nova-lite-v1` and falls back to `amazon.nova-lite`, so both key shapes in a pricing file resolve.
174
+
175
+ Unknown / unkeyed models → **no `cost` field** on the response (inference still works). Add an explicit row to the pricing file.
176
+
177
+ ## Normalized pricing file (v1)
178
+
179
+ This is what you mount at `pricingFilePath` / `AI_BEDROCK_PRICING_FILE`. Prefer this over raw offer JSON — small enough for a ConfigMap.
180
+
181
+ ```json
182
+ {
183
+ "version": 1,
184
+ "region": "us-east-1",
185
+ "generatedAt": "2026-08-12T12:00:00.000Z",
186
+ "models": {
187
+ "anthropic.claude-haiku-4-5": {
188
+ "inputPerMTok": 1.1,
189
+ "outputPerMTok": 5.5,
190
+ "kind": "text"
191
+ },
192
+ "anthropic.claude-sonnet-4-5": {
193
+ "inputPerMTok": 3,
194
+ "outputPerMTok": 15,
195
+ "kind": "text"
196
+ },
197
+ "amazon.titan-embed-text-v2": {
198
+ "inputPerMTok": 0.02,
199
+ "outputPerMTok": 0,
200
+ "kind": "embedding"
201
+ }
202
+ }
203
+ }
204
+ ```
205
+
206
+ | Field | Required | Meaning |
207
+ |-------|----------|---------|
208
+ | `version` | yes | Must be `1` |
209
+ | `models` | yes | Map of model id or family key → rates |
210
+ | `models.*.inputPerMTok` | yes | USD per 1M input tokens |
211
+ | `models.*.outputPerMTok` | yes | USD per 1M output tokens (`0` for embeddings) |
212
+ | `models.*.kind` | no | `text` \| `embedding` \| … (used by `listModels` filters) |
213
+ | `models.*.cacheReadPerMTok` | no | USD per 1M cache-read tokens (else ~0.1× input) |
214
+ | `models.*.cacheWritePerMTok` | no | USD per 1M cache-write tokens, 5-minute TTL (else ~1.25× input) |
215
+ | `models.*.globalInputPerMTok` | no | USD per 1M input tokens via a `global.` inference profile (else regional) |
216
+ | `models.*.globalOutputPerMTok` | no | USD per 1M output tokens via a `global.` inference profile |
217
+ | `region` / `generatedAt` | no | Informational / audit |
218
+
219
+ All the optional rate fields are additive — a file that only has `inputPerMTok` / `outputPerMTok` keeps working exactly as before, and `version` stays `1`.
220
+
221
+ ### Generate the file with the package parser (recommended)
222
+
223
+ In a small Node script (CI job or InitContainer), after downloading the two offer JSON files:
224
+
225
+ ```typescript
226
+ import { readFileSync, writeFileSync } from 'node:fs';
227
+ import { parseAwsOfferRates } from '@plumbus/ai-bedrock';
228
+
229
+ const region = process.env.REGION ?? 'us-east-1';
230
+ const offers = [
231
+ JSON.parse(readFileSync(`/tmp/bedrock-offers/AmazonBedrock-${region}.json`, 'utf8')),
232
+ JSON.parse(
233
+ readFileSync(`/tmp/bedrock-offers/AmazonBedrockFoundationModels-${region}.json`, 'utf8'),
234
+ ),
235
+ ];
236
+
237
+ const rates = parseAwsOfferRates(offers);
238
+ const models: Record<string, { inputPerMTok: number; outputPerMTok: number; kind?: string }> = {};
239
+ for (const [id, rate] of rates) {
240
+ models[id] = {
241
+ inputPerMTok: rate.inputPerMTok,
242
+ outputPerMTok: rate.outputPerMTok,
243
+ kind: rate.kind,
244
+ };
245
+ }
246
+
247
+ writeFileSync(
248
+ 'bedrock-pricing.json',
249
+ JSON.stringify(
250
+ {
251
+ version: 1,
252
+ region,
253
+ generatedAt: new Date().toISOString(),
254
+ models,
255
+ },
256
+ null,
257
+ 2,
258
+ ),
259
+ );
260
+ ```
261
+
262
+ Or hand-edit the JSON from the AWS Bedrock pricing page / offer attributes when you only need a few models.
263
+
264
+ **Always review the generated file before mounting it.** The parser keys what it can recognize; models it cannot key are simply absent, and a missing row is silent (no cost recorded for that model). Check that every model id your app actually calls — chat *and* embedding — has a row, and add the rest by hand.
265
+
266
+ ### Sanity-check a rate (Haiku 4.5)
267
+
268
+ For `us-east-1`, regional on-demand Claude Haiku 4.5 input is typically **`$1.10` / MTok** in FoundationModels (`InputTokenCount-Units`) — **not** `$1.00` (that’s closer to Anthropic API / Global SKUs). If your file has `1.0` for Bedrock Haiku 4.5 regional, fix it.
269
+
270
+ ## Kubernetes / container recipe
271
+
272
+ 1. **CI (preferred) or CronJob** — `curl` the two offer URLs for your region → run the Node script above → publish `bedrock-pricing.json` as a build artifact or ConfigMap.
273
+ 2. **Mount** the file read-only, e.g. `/config/bedrock-pricing.json`.
274
+ 3. **Env:**
275
+
276
+ ```bash
277
+ AI_DEFAULT_PROVIDER=bedrock
278
+ AI_BEDROCK_REGION=us-east-1
279
+ AI_BEDROCK_PRICING_FILE=/config/bedrock-pricing.json
280
+ AI_BEDROCK_MODEL=anthropic.claude-sonnet-4-5-20250929-v1:0
281
+ ```
282
+
283
+ 4. **NetworkPolicy:** allow Bedrock Runtime (inference IAM/IRSA). With a pricing file you do **not** need egress to `pricing.us-east-1.amazonaws.com`.
284
+ 5. **Do not** use a shared Redis/PVC for pricing in v1 — one mounted file per pod (or the same ConfigMap volume) is enough.
285
+
286
+ Example ConfigMap sketch:
287
+
288
+ ```yaml
289
+ apiVersion: v1
290
+ kind: ConfigMap
291
+ metadata:
292
+ name: bedrock-pricing
293
+ data:
294
+ bedrock-pricing.json: |
295
+ { "version": 1, "region": "us-east-1", "models": { ... } }
296
+ ---
297
+ # in the Deployment pod spec:
298
+ env:
299
+ - name: AI_BEDROCK_PRICING_FILE
300
+ value: /config/bedrock-pricing.json
301
+ volumeMounts:
302
+ - name: bedrock-pricing
303
+ mountPath: /config
304
+ readOnly: true
305
+ volumes:
306
+ - name: bedrock-pricing
307
+ configMap:
308
+ name: bedrock-pricing
309
+ ```
310
+
311
+ ## Auto-download mode (no file)
312
+
313
+ Omit `pricingFilePath`. On warm (first AI call by default), the adapter fetches:
314
+
315
+ ```text
316
+ …/AmazonBedrock/current/{region}/index.json
317
+ …/AmazonBedrockFoundationModels/current/{region}/index.json
318
+ ```
319
+
320
+ Caches in memory (TTL default **24h**, override with `pricingCacheTtlMs` / `AI_BEDROCK_PRICING_TTL_MS`).
321
+
322
+ **Requirements:** egress HTTPS to `pricing.us-east-1.amazonaws.com`. Failure → `console.warn`; `cost` stays unknown (field omitted) until a successful refresh. Inference is **not** blocked.
323
+
324
+ Failed downloads back off for **5 minutes** before retrying, so a blocked pricing CDN costs one fetch timeout per cooldown window rather than one per inference request. Once rates are loaded, TTL expiry refreshes them **in the background** — requests keep serving the previous rates instead of blocking on the fetch.
325
+
326
+ ## Troubleshooting
327
+
328
+ | Symptom | Check |
329
+ |---------|--------|
330
+ | `cost` always `0` | Model id not in file / not mapped from offer display name; or auto-download failed (see logs); or using Anthropic-style id without Bedrock rates |
331
+ | File load error at runtime | Path missing in container; invalid JSON; `version` ≠ `1`; missing `inputPerMTok` / `outputPerMTok` |
332
+ | Rates differ from AWS bill | File stale; Global vs regional SKU; batch / provisioned tiers not modelled |
333
+ | Haiku cost too low vs bill | Likely aliased to Anthropic `$1` — use Bedrock regional `$1.10` (us-east-1) from FoundationModels |
334
+ | Auto-download works locally, fails in k8s | NetworkPolicy blocking Price List CDN — switch to `AI_BEDROCK_PRICING_FILE` |
335
+
336
+ ## Related
337
+
338
+ - Package boundary / wiring: [framework.md](./framework.md)
339
+ - Concept guide (monorepo): `docs/ai/bedrock.md`
340
+ - Broader AI docs: `docs/ai/ai-integration.md`
341
+ - Types: `BedrockPricingFileV1`, helpers `parseAwsOfferRates`, `parsePricingFile`, `normalizeBedrockModelId`, `extractModelIdFromOfferAttrs`, `inferFamilyFromDisplayName`
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@plumbus/ai-bedrock",
3
+ "version": "0.1.0",
4
+ "description": "Amazon Bedrock AI provider adapter for @plumbus/core (Converse chat + embeddings).",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "instructions",
17
+ "README.md",
18
+ "CHANGELOG.md"
19
+ ],
20
+ "keywords": [
21
+ "plumbus",
22
+ "bedrock",
23
+ "aws",
24
+ "ai"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/plumbus-framework/plumbus.git",
30
+ "directory": "packages/ai-bedrock"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "engines": {
36
+ "node": ">=20.6.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@plumbus/core": "0.6.x"
40
+ },
41
+ "dependencies": {
42
+ "@aws-sdk/client-bedrock-runtime": "^3.848.0",
43
+ "@aws-sdk/types": "^3.840.0"
44
+ },
45
+ "devDependencies": {
46
+ "@biomejs/biome": "^2.4.6",
47
+ "@types/node": "^25.4.0",
48
+ "typescript": "^5.7.0",
49
+ "vitest": "^3.2.4",
50
+ "@plumbus/core": "0.6.16"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc -b",
54
+ "dev": "tsc -b --watch",
55
+ "test": "vitest run",
56
+ "lint": "biome lint ./src",
57
+ "format": "biome format --write ./src",
58
+ "format:check": "biome format ./src",
59
+ "typecheck": "tsc --noEmit"
60
+ }
61
+ }