@bivelio/savings-layer 0.4.22 → 0.4.25

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.
package/README.md CHANGED
@@ -19,16 +19,24 @@
19
19
  Every request your app sends to an AI is paid for by the text going in and out.
20
20
  **Savings** sits on that path and lets through only what's needed — skipping calls
21
21
  that don't have to happen, and trimming what does — so you pay for less. It is not a
22
- promise you take on faith: **every saving is measured with the provider's official
23
- meter, the same one on your bill**, and handed back to you request by request.
24
-
25
- **We only earn when you save.** Your first month carries no commission at all: we
26
- measure everything and show you the statement of what it would have cost. After
27
- that, the commission comes out of the **measured** saving only — never out of your
28
- pocket, and never from estimates: a tiered rate from 10 % down to 4 % of what you
29
- saved, so **you always keep at least 90 % of every euro saved**, and a month with no
30
- saving is zero commission. And your prompts **never leave your infrastructure**:
31
- BiVelio only ever sees counts and cost, never your content.
22
+ promise you take on faith: **every saving we charge on is measured with your provider's
23
+ own usage counter, the one your bill is built from** (for a repeat served without
24
+ calling the provider, the counter of the identical call that ran), and every saving —
25
+ measured or estimated, and labelled as such — is handed back to you request by request.
26
+
27
+ **A small per‑seat fee, and a commission only on savings measured against your
28
+ provider's counter.** The seat fee is paid whether or not you save. Your
29
+ first month carries no commission at all — it is the first calendar month (UTC) in
30
+ which your organization reports usage, so starting late in a month makes it a short one —
31
+ and at its close you see the statement of what the commission would have been. After
32
+ that, the commission is charged on the **measured** saving only — never from estimates:
33
+ a tiered rate from 10 % down to 4 % of what you saved, so **you keep
34
+ at least 90 % of every euro saved** (before the invoice is rounded to the cent; the
35
+ seat fee is separate). A month with no measured saving is zero commission. And your
36
+ prompts and completions **never reach BiVelio**: what BiVelio receives is per‑request
37
+ usage — token counts, cost, the model id and opaque ids — never your content
38
+ ([LICENSE](./LICENSE) §5 lists every field; [savings.bivelio.com/privacy](https://savings.bivelio.com/privacy)
39
+ explains each one and how long it is kept).
32
40
 
33
41
  ### For engineers, in one line
34
42
 
@@ -42,9 +50,11 @@ goes straight from your app to your provider; the SDK is never a middleman serve
42
50
  > BV‑SALA is fail‑closed and executes only with an active BiVelio license key.
43
51
  > The license signature is verified **offline**; with `licenseServerUrl` set, the SDK
44
52
  > also calls `savings.bivelio.com` for the revocation list, license renewal and usage
45
- > metering. **Your prompts never leave your infrastructure** — what travels is token
46
- > counts, cost, the model id and opaque seat/session ids, never content. Use is governed by the [LICENSE](./LICENSE)
47
- > and the BiVelio Terms. Not a wrapper around any third‑party library.
53
+ > metering. **Your prompts, completions and provider keys never reach BiVelio** — what
54
+ > travels is per‑request usage: token counts, cost, the model id, a request id and
55
+ > opaque seat and session ids (the exact list is in [LICENSE](./LICENSE) §5). Use is
56
+ > governed by the LICENSE, which also sets out the seat fee and the commission, and the
57
+ > BiVelio Terms. Not a wrapper around any third‑party library.
48
58
 
49
59
  ## Where it installs
50
60
 
@@ -66,14 +76,69 @@ story, in plain words:
66
76
 
67
77
  **Claude Code, Codex CLI, Goose or Qwen Code with an API key?** The package also
68
78
  ships a local measuring gateway: `bvsala claude` (or `bvsala codex`, `bvsala
69
- goose`, `bvsala qwen`) launches the terminal through it and your real spend
70
- shows up in your dashboard, token by token — nothing to configure, nothing
71
- persisted. For opencode, Crush, Droid, Cline or Kilo Code, `bvsala doctor`
72
- prints the exact config block for each one you have installed. It measures every
73
- request. With a PRO license it also applies one saving by default: it shortens the
74
- cache-write TTL while your session stays hot, which changes the price, never the
75
- content the model reads. The other levers (sibling model, per-turn effort) stay off
76
- until you switch them on.
79
+ goose`, `bvsala qwen`) launches the terminal through it and your real spend shows
80
+ up in your dashboard, token by token — nothing to configure, nothing persisted.
81
+ For opencode, Crush, Droid, Cline or Kilo Code, `bvsala doctor` prints the exact
82
+ config block for each one you have installed. Like the SDK, the gateway is
83
+ fail‑closed: it starts only with your service key (`BIVELIO_SERVICE_KEY`) and an
84
+ active BiVelio license, which it collects with that key and verifies against the
85
+ key embedded in the package. Once running it never interrupts a request in flight:
86
+ if the license lapses, only new requests are refused (HTTP 402) until it is
87
+ renewed. It measures every request. With a PRO license it also applies one saving
88
+ by default: it shortens the cache-write TTL while your session stays hot, which
89
+ changes the price, never the content the model reads. The other levers (sibling
90
+ model, per-turn effort) stay off until you switch them on. On a Claude or ChatGPT
91
+ subscription there is no per-token invoice, so that traffic is metered at
92
+ list-price equivalent and never carries commission; the per-seat license applies
93
+ as for any other use.
94
+
95
+ **The gateway forwards what your terminal sends.** With no lever armed, your
96
+ provider receives the request body byte for byte and every header your client
97
+ sends (see the limits): `cache_control` and its 1-hour TTL, tool search and
98
+ `tool_reference` blocks, the advisor, every `anthropic-beta` value. An armed lever
99
+ changes only what it declares, and a request it cannot rewrite exactly travels
100
+ untouched. A test suite checks this against a fake upstream on every change
101
+ (`test/gateway-no-empeora.test.ts`; the repository is private, so the linked
102
+ section lists exactly what it checks). The gateway also logs, once per session,
103
+ requests that come out worse than your client asked for: cache markers the
104
+ provider ignored, a 1-hour TTL written at 5 minutes upstream, and tool search off
105
+ (Claude Code turns it off by default behind any proxy, this one included;
106
+ `bvsala claude` turns it back on). What it checks, and its limits:
107
+ [docs/terminales/README.md](./docs/terminales/README.md#what-the-gateway-guarantees-it-forwards-what-your-client-sends).
108
+
109
+ ### Python and any other language: the local gateway
110
+
111
+ The same gateway sits in front of any SDK that lets you set a base URL, whatever
112
+ the language:
113
+
114
+ ```bash
115
+ bvsala gateway --upstream openai --port 8403 # one gateway per upstream
116
+ bvsala gateway # Anthropic, the default, on 8402
117
+ ```
118
+
119
+ ```python
120
+ from openai import OpenAI
121
+ client = OpenAI(base_url="http://127.0.0.1:8403/v1") # WITH /v1
122
+ stream = client.chat.completions.create(
123
+ model="gpt-4o-mini", messages=[{"role": "user", "content": "Hi"}], stream=True,
124
+ stream_options={"include_usage": True}, # without it a stream carries no usage and is not metered
125
+ )
126
+ for chunk in stream:
127
+ if chunk.choices: # the last chunk carries only the usage
128
+ print(chunk.choices[0].delta.content or "", end="")
129
+
130
+ from anthropic import Anthropic
131
+ claude = Anthropic(base_url="http://127.0.0.1:8402") # NO /v1
132
+ ```
133
+
134
+ With `BIVELIO_SERVICE_KEY` set, the gateway measures every request whose usage the
135
+ provider reports — Anthropic Messages, OpenAI Chat Completions and Responses, streamed
136
+ or not — for models with a list price in its catalog, priced at that list price. A
137
+ request it cannot meter (a model without a list price, a body it cannot read, a stream
138
+ that never asked for usage) is still forwarded, and its log line says so and why. That
139
+ is measurement, not savings: the saving levers of the SDK are TypeScript, and the
140
+ gateway's own levers apply to Anthropic `/v1/messages` traffic with a PRO license.
141
+ `bvsala doctor` prints the recipe for each SDK.
77
142
 
78
143
  ## Check it before you install
79
144
 
@@ -89,16 +154,22 @@ lives in your dashboard once you sign in.
89
154
  **1 · The package**
90
155
 
91
156
  ```bash
92
- pnpm add @bivelio/savings-layer
93
- # or: npm i @bivelio/savings-layer · yarn add @bivelio/savings-layer
157
+ pnpm add @bivelio/savings-layer gpt-tokenizer
158
+ # or: npm i @bivelio/savings-layer gpt-tokenizer · yarn add @bivelio/savings-layer gpt-tokenizer
94
159
  ```
95
160
 
161
+ `gpt-tokenizer` is optional, but install it if you call OpenAI models: it is the
162
+ provider's own tokenizer, and the SDK loads it by itself. Without it, counts fall back
163
+ to a built‑in estimate and the reconstructed savings are less accurate. Either way, a
164
+ saving where the layer removed something is reported as `estimated`, and estimated
165
+ savings are never billed — the SDK warns once when the tokenizer is missing.
166
+
96
167
  **2 · The license** — from your [dashboard](https://savings.bivelio.com/dashboard/install)
97
168
  (**€4 / seat / month** or **€39 / seat / year**, EUR)
98
169
 
99
170
  ```bash
100
171
  export BIVELIO_LICENSE_KEY=… # your license key
101
- export BIVELIO_SERVICE_KEY=… # service-account key: auto-renewal + seat metering
172
+ export BIVELIO_SERVICE_KEY=… # service-account key: auto-renewal + seat metering (required by the gateway)
102
173
  ```
103
174
 
104
175
  **3 · Wrap the call** — this is the entire code change. The package is ESM only:
@@ -137,11 +208,177 @@ console.log(report.cost.basis); // → "estimated": nothing ran, so there
137
208
 
138
209
  That's it. From the first request, `report` carries the saving and says how it
139
210
  was obtained: `actual` is what the provider metered; `basis` is `"measured"` when
140
- the request went through untouched, `"estimated"` when the layer removed something
141
- (the baseline is then reconstructed with the model's tokenizer, or with a
142
- calibrated estimate where no exact tokenizer exists, as for Anthropic models),
143
- and `"unmeasured"` when the provider returned no usage. Only measured figures
144
- reach your invoice.
211
+ the request went through untouched, `"reference"` when a repeat of a byte‑identical
212
+ request was served without calling the provider — its baseline is the provider's own
213
+ counter for the identical request that already ran and that you already paid
214
+ (`report.cache.reference` points to it; with an output schema, only when your schema
215
+ traveled exactly as sent, in strict mode, in the provider's native response‑format
216
+ parameter, with nothing added to the prompt), valued at the provider's cached‑input
217
+ rate, the lowest it bills for repeating a prompt; the output counts only under deterministic
218
+ sampling (`temperature: 0`), on a model with a list price in the catalog that does not
219
+ reason and honors that setting, and when the call that ran carried no hidden reasoning (the
220
+ provider reported zero reasoning tokens or, where it does not break them out, its answer
221
+ had no reasoning block), and then at 95 % of its counter, a margin for the small drift
222
+ `temperature: 0` still has —
223
+ `"estimated"` when the layer removed something (the baseline is then reconstructed
224
+ with the model's tokenizer, or with a calibrated estimate where no exact tokenizer
225
+ exists, as for Anthropic models), and `"unmeasured"` when the provider returned no
226
+ usage. Only `measured` and `reference` figures reach your invoice; estimated ones
227
+ never do.
228
+
229
+ **Streaming.** `generateStream()` takes the same request and hands you the answer as
230
+ the provider writes it, then the same `SavingsReport`:
231
+
232
+ ```ts
233
+ const stream = layer.generateStream({
234
+ model: "openai/gpt-4o-mini",
235
+ messages: [{ role: "user", content: "Summarize the release notes in one line." }],
236
+ });
237
+ for await (const event of stream) {
238
+ if (event.type === "text") process.stdout.write(event.delta);
239
+ }
240
+ const { report: streamed } = await stream.result;
241
+ console.log(streamed.cost.basis); // same rules as generate(), read from the provider's final usage
242
+ console.log(streamed.streaming?.delivery); // "live", or "whole" when the answer arrived in one piece
243
+ ```
244
+
245
+ The SDK asks the provider for its final usage at the end of every stream (on
246
+ OpenAI‑compatible endpoints, `stream_options.include_usage`); a stream that ends
247
+ without it is reported as `unmeasured` and never billed. With an output contract, or
248
+ a request declared `deferrable`, the answer is produced exactly as `generate()` would
249
+ and arrives as a single `text` event. Breaking out of the loop while the provider is
250
+ still writing cancels its request, and nothing is cached or recorded; once its answer
251
+ is complete, or when it arrives in one piece, nothing is cancelled.
252
+ `await stream.result` tells you which happened.
253
+
254
+ ## Inside your framework
255
+
256
+ Already on an agent framework? The layer plugs in at that framework's own extension
257
+ point, from a subpath of this same package. The four adapters behave alike:
258
+
259
+ - **They answer without calling the model when they can** — a computed answer (the
260
+ arithmetic above), an exact repeat of a request that already ran, an identical
261
+ request already in flight — and otherwise let your framework make exactly the call
262
+ it was going to make. They never rewrite what your framework sends.
263
+ - **Tools are treated as having side effects** unless you list them in
264
+ `readOnlyTools`: a request that offers any other tool is never answered from a
265
+ cache, because the action has to run.
266
+ - **Fail‑closed, like the SDK**: without an active license the call fails with
267
+ `LicenseError` (a framework may wrap it, keeping its name and message) and the
268
+ model is not called. **Put the adapter last**, so nothing sits between it and the
269
+ model.
270
+
271
+ Every model call gets the same `SavingsReport` as with the SDK — its `basis` says
272
+ whether a figure comes from your provider's own counter — and `onReport` receives it
273
+ too.
274
+
275
+ ### Vercel AI SDK
276
+
277
+ `pnpm add @bivelio/savings-layer ai @ai-sdk/openai` — a `LanguageModelMiddleware`
278
+ for `ai` 6 or 7:
279
+
280
+ ```ts
281
+ import { generateText, wrapLanguageModel } from "ai";
282
+ import { openai } from "@ai-sdk/openai";
283
+ import { savingsMiddleware, getSavingsReport } from "@bivelio/savings-layer/ai-sdk";
284
+
285
+ const savings = savingsMiddleware({
286
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
287
+ licenseServerUrl: "https://savings.bivelio.com",
288
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
289
+ readOnlyTools: ["get_weather"],
290
+ });
291
+ const model = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware: savings }); // last in the list
292
+
293
+ const result = await generateText({
294
+ model,
295
+ prompt: "What is 2 + 2 * 3?",
296
+ providerOptions: { bivelio: { context: { sessionId: "user-42" } } },
297
+ });
298
+ console.log(getSavingsReport(result)?.cost.basis); // "estimated": answered without calling the model
299
+ ```
300
+
301
+ For `streamText`, read it with `getSavingsReport({ providerMetadata: await result.providerMetadata })`.
302
+
303
+ ### Mastra
304
+
305
+ `pnpm add @bivelio/savings-layer @mastra/core` — an input processor for
306
+ `@mastra/core` 1.x:
307
+
308
+ ```ts
309
+ import { Agent } from "@mastra/core/agent";
310
+ import { savingsProcessor } from "@bivelio/savings-layer/mastra";
311
+
312
+ const bivelio = savingsProcessor({
313
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
314
+ licenseServerUrl: "https://savings.bivelio.com",
315
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
316
+ onReport: (r) => console.log(r.cost.basis),
317
+ });
318
+ const agent = new Agent({
319
+ id: "support",
320
+ name: "Support",
321
+ instructions: "Answer briefly.",
322
+ model: "openai/gpt-4o-mini",
323
+ inputProcessors: [bivelio], // last in the list
324
+ });
325
+ console.log((await agent.generate("What is 2 + 2 * 3?")).text); // "8"
326
+ ```
327
+
328
+ It covers `generate()` and `stream()`, not `generateLegacy()`/`streamLegacy()` or a
329
+ durable agent that resumes in another process; identical requests in flight are not
330
+ shared here. `emitReportPart: true` also streams each report as a
331
+ `data-bivelio-savings-report` part.
332
+
333
+ ### LangChain.js
334
+
335
+ `pnpm add @bivelio/savings-layer langchain @langchain/core` — an agent middleware for
336
+ `createAgent` in `langchain` 1.x:
337
+
338
+ ```ts
339
+ import { createAgent } from "langchain";
340
+ import { savingsAgentMiddleware, getSavingsReportFromMessage } from "@bivelio/savings-layer/langchain";
341
+
342
+ const agentSavings = savingsAgentMiddleware({
343
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
344
+ licenseServerUrl: "https://savings.bivelio.com",
345
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
346
+ });
347
+ const lcAgent = createAgent({ model: "openai:gpt-4o-mini", tools: [], middleware: [agentSavings] }); // last
348
+ const state = await lcAgent.invoke({ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }] });
349
+ console.log(getSavingsReportFromMessage(state.messages.at(-1))?.cost.basis);
350
+ ```
351
+
352
+ The model call is measured when it finishes; tokens you stream with
353
+ `streamMode: "messages"` keep coming from the model as usual. A chat model with its
354
+ own `cache` set is reported as `unmeasured`: its counter may be a cached copy.
355
+
356
+ ### Anthropic TypeScript SDK
357
+
358
+ `pnpm add @bivelio/savings-layer @anthropic-ai/sdk` — a client middleware for
359
+ `@anthropic-ai/sdk` 0.103 or later:
360
+
361
+ ```ts
362
+ import Anthropic from "@anthropic-ai/sdk";
363
+ import { anthropicSavings } from "@bivelio/savings-layer/anthropic-sdk";
364
+
365
+ const claudeSavings = anthropicSavings({
366
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
367
+ licenseServerUrl: "https://savings.bivelio.com",
368
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
369
+ });
370
+ const client = new Anthropic({ middleware: [claudeSavings.middleware] }); // last in the list
371
+ const message = await client.messages.create({
372
+ model: "claude-opus-5",
373
+ max_tokens: 256,
374
+ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }],
375
+ });
376
+ console.log(claudeSavings.reportFor(message)?.cost.basis);
377
+ ```
378
+
379
+ It runs once per HTTP attempt, inside the SDK's retries: an attempt the provider
380
+ rejects reaches the SDK unchanged and is not recorded. Only message creation goes
381
+ through the layer; `countTokens`, batches, models and files pass untouched.
145
382
 
146
383
  ## What you get on every request
147
384
 
@@ -155,11 +392,130 @@ ledgers** that are never summed into one flattering figure:
155
392
  | `compressed_tokens` | information kept, but encoded with fewer tokens |
156
393
  | `provider_cached_tokens` | tokens still in the prompt, billed/processed more cheaply |
157
394
 
395
+ **Quality, kept apart from money.** `report.qualityLedger` records how the answer you
396
+ received earned its place: whether it came from your provider, a replay, or the answer
397
+ to a reworded question (with its similarity score); whether it was checked against your
398
+ output contract, repaired or cut off by the output limit; and what the model cascade
399
+ decided. It holds no money, is never added to the four ledgers, and never leaves your
400
+ process. Reusing answers to reworded questions is opt-in, and by default it covers
401
+ single-shot requests only: requests that declare tools, carry tool calls or results, or
402
+ hold more than one user turn are left out unless you set
403
+ `savings.semanticCache: "include-agentic"`.
404
+
158
405
  And a promise you can hold it to: **never worse than your JSON.** Payload encodings
159
406
  are chosen by counting real tokens with the destination model's own tokenizer, and
160
407
  BV‑SALA only moves away from plain JSON when the saving clearly clears the bar —
161
408
  `report.serialization` shows what was chosen and what each candidate would have cost.
162
409
 
410
+ <details>
411
+ <summary><b>Your own A/B — verification sampling (opt-in, off by default)</b></summary>
412
+
413
+ The per-request figures above are what the layer can show without running your
414
+ request twice. To see what it does on your own traffic, with both sides metered by
415
+ your provider, turn on verification sampling:
416
+
417
+ ```ts
418
+ const checkedLayer = createSavingsLayer({
419
+ provider,
420
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
421
+ licenseServerUrl: "https://savings.bivelio.com",
422
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
423
+ verification: { sampleRate: 0.02 }, // about 2 in every 100 eligible requests
424
+ });
425
+ ```
426
+
427
+ For that share of eligible requests, after your answer has been returned, the SDK
428
+ sends the same request once more to your provider exactly as it would go without the
429
+ layer — the model you asked for, all your tools, your data inline, no cache marks —
430
+ and discards the second answer. Each pair (what the unoptimized copy cost and what the
431
+ optimized request cost, both from your provider's usage counters) appears under
432
+ **Proof › Your bench** in your dashboard, with the mean difference, its 95 % interval
433
+ and every pair as a CSV download.
434
+
435
+ - **It costs you money.** You pay your provider for every copy: roughly the sampling
436
+ rate times what that traffic would cost without the layer. The SDK says so once
437
+ per process when it starts (`silenceWarnings: true` mutes that notice too).
438
+ - **It is never billed.** Pairs travel on their own channel into their own table.
439
+ They never enter measured savings, your statement or any commission.
440
+ - **Only requests served intact are checked.** Requests that took the fail-open path,
441
+ were cut off, missed their output contract or came back without a provider counter
442
+ are counted as excluded and never sent twice. `savings: { verification: false }`
443
+ excludes a single request.
444
+ - **Both sides follow the same rule.** A pair is compared only when the copy also
445
+ finished normally and met your contract; a copy cut off by the output limit is
446
+ counted and exported, never scored as a saving.
447
+ - **The headline is the floor, and it waits.** It is the lower bound of the 95 %
448
+ interval, shown once there are enough pairs overall and groups with too few pairs
449
+ of their own are a small share of your traffic, so a group where the layer costs
450
+ you money cannot be left out of it.
451
+ - **Only counts leave your infrastructure.** The copy goes to your own provider, like
452
+ the original; BiVelio receives token counts, costs, model ids and outcomes — never
453
+ prompts or answers.
454
+ - **`close()` settles every copy still running:** it is recorded as abandoned, never
455
+ as a pair. On serverless platforms, pass your runtime's `waitUntil` as
456
+ `verification.waitUntil` so checks can finish.
457
+
458
+ </details>
459
+
460
+ <details>
461
+ <summary><b>Provider price levers (opt-in, off by default)</b></summary>
462
+
463
+ Two request flags ask your provider for a lower price on the same tokens. Neither
464
+ changes the answer, and what they are worth is reported next to the four ledgers:
465
+ never folded into `cost.*`, never part of any commission.
466
+
467
+ **`savings.deferrable`** asks for the provider's Flex tier: OpenAI, and Gemini through
468
+ its OpenAI‑compatible endpoint. Anthropic has no cheaper synchronous tier (its
469
+ discount lives only in Message Batches, which the layer does not support yet).
470
+ Bedrock and DeepSeek off‑peak pricing are not supported.
471
+
472
+ - **Latency.** OpenAI publishes no latency commitment for Flex; Google publishes a
473
+ target of minutes. Keep deferrable requests off user‑facing paths.
474
+ `report.deferred.latency` quotes what the provider documents.
475
+ - **Risk.** Without capacity the provider refuses the request (429, or 503 on Gemini)
476
+ instead of waiting; OpenAI documents that the refusal is not charged. With
477
+ `priceLevers: { onCapacityError: "standard" }` the layer repeats it once at the
478
+ standard tier and price, and says so in `report.deferred.capacityFallback`.
479
+ - **What it is worth.** Your provider's usage counter for that call, priced at the two
480
+ published rate cards (standard, and the tier that actually served it), component
481
+ by component, with the date the prices were checked. It is `measured` only when
482
+ the served tier was read back from the provider's own API, the prices are list
483
+ prices and the counter says how much of the input was read from (or written to)
484
+ the prompt cache; otherwise `estimated`, and `basisReason` says why. It never
485
+ exceeds the difference between the two published rate cards, even with your own
486
+ `costModels`. A tier the provider did not confirm is never credited.
487
+
488
+ **`savings.standalone`** (GPT‑5.6 and later, on OpenAI's own API) declares that nothing
489
+ after the stable prefix of this request will be sent again. The provider then writes
490
+ its prompt cache only at the end of that prefix, or nowhere, instead of at the end of
491
+ your message, which on these models costs more than plain input. The trade‑off:
492
+ repeats and continuations of this exact prompt lose the cache reads the provider
493
+ default would give them, so leave it off conversation turns you will continue. Its
494
+ figure, `report.cacheWrites.avoidedWritePremium`, is always `estimated`: the provider
495
+ default was not run.
496
+
497
+ ```ts
498
+ const leveredLayer = createSavingsLayer({
499
+ provider,
500
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
501
+ priceLevers: { deferrable: true, onCapacityError: "standard" }, // layer-wide defaults
502
+ });
503
+
504
+ const { report } = await leveredLayer.generate({
505
+ model: "openai/gpt-5.6-luna",
506
+ messages: [
507
+ { role: "system", content: "…your long, fixed instructions…" },
508
+ { role: "user", content: "Classify ticket 42." },
509
+ ],
510
+ savings: { standalone: true }, // a request's own value always wins
511
+ });
512
+
513
+ report.deferred?.tierSaving; // { amount, basis, standardCost, appliedCost, pricesCheckedOn, basisReason }
514
+ report.cacheWrites; // { mode, breakpoints, avoidedWritePremium?, reason }
515
+ ```
516
+
517
+ </details>
518
+
163
519
  <details>
164
520
  <summary><b>Structured output — validated <code>data</code>, not just text</b></summary>
165
521
 
@@ -233,6 +589,144 @@ an action that has not run yet cannot be a saving.
233
589
 
234
590
  </details>
235
591
 
592
+ <details>
593
+ <summary><b>Your ledgers in your tracing — OpenTelemetry, Langfuse, Datadog</b></summary>
594
+
595
+ Wrap the layer once and every request becomes a span on the OpenTelemetry pipeline
596
+ you already run (`@opentelemetry/api` is an optional peer; the SDK never imports it):
597
+
598
+ ```ts
599
+ import { trace } from "@opentelemetry/api";
600
+ import { instrumentSavingsLayer } from "@bivelio/savings-layer";
601
+
602
+ // One span per request, on the OpenTelemetry pipeline you already run.
603
+ const traced = instrumentSavingsLayer(layer, { tracer: trace });
604
+ const { report: tracedReport } = await traced.generate({
605
+ model: "openai/gpt-4o-mini",
606
+ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }],
607
+ });
608
+ console.log(tracedReport.cost.basis); // the span carries the same figures
609
+ ```
610
+
611
+ - **Standard spans, plus your ledgers.** Spans follow the
612
+ [GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions/blob/v1.41.1/model/gen-ai/registry.yaml)
613
+ as last published (OpenTelemetry semantic conventions v1.41.1): `chat {model}`,
614
+ kind CLIENT, provider, model and usage from your provider's counter (0 on an avoided
615
+ call: no token was billed). On top, `com.bivelio.*` carries the four ledgers, the
616
+ `basis`, the cost and the ids; `com.bivelio.request_id` is the same id as your usage
617
+ record. Same results, same errors: `traced` hands back the very objects `layer` does.
618
+ - **Money is kept apart by basis.** The money saved is
619
+ `com.bivelio.cost.saved.measured`, `.reference` or `.estimated` — one key per span,
620
+ the one of its basis — so a sum over one key never mixes measured and estimated
621
+ money; `unmeasured` spans carry counts and no money. `report.cost.netSavings` is a
622
+ ratio and goes out as `com.bivelio.cost.net_savings_ratio`; the money is
623
+ `baselineEstimate − actual`, and that is what `saved.*` holds.
624
+ - **None of this talks to BiVelio.** Spans go to your exporter; files stay where you
625
+ write them. Your prompts, completions and provider keys never reach BiVelio, and no
626
+ span carries them either: ids, counts, money and enums only, and a failed request
627
+ records its `error.type`, never its message.
628
+ - **Langfuse.** Add the `LangfuseSpanProcessor` from `@langfuse/otel` to your tracer
629
+ provider. These spans carry `gen_ai.*`, so its default filter exports them
630
+ ([Langfuse OpenTelemetry docs](https://langfuse.com/integrations/native/opentelemetry)).
631
+ `instrumentSavingsLayer(layer, { tracer: trace, langfuse: { metadata: true } })`
632
+ makes the figures filterable metadata, and `langfuse: { cost: true }` hands Langfuse
633
+ the report's own cost (in USD) instead of its estimate. Tracing with `@langfuse/tracing` by
634
+ hand? Pass `toLangfuseGeneration(report)` to `generation.update()`.
635
+ - **Datadog LLM Observability** reads these spans over OTLP
636
+ ([Datadog OpenTelemetry docs](https://docs.datadoghq.com/llm_observability/instrumentation/otel_instrumentation/)):
637
+
638
+ ```bash
639
+ OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf
640
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=<your Datadog site's OTLP traces endpoint>
641
+ OTEL_EXPORTER_OTLP_TRACES_HEADERS=dd-api-key=<your Datadog API key>,dd-otlp-source=llmobs
642
+ ```
643
+
644
+ The `com.bivelio.*` attributes arrive as tags; with `datadog: { metadata: true }`
645
+ they also arrive as span metadata. Datadog drops spans that carry no `gen_ai.*`
646
+ attribute, so use `instrumentSavingsLayer` there rather than `recordSavingsOnSpan`
647
+ (which writes `com.bivelio.*` on a span you already have, and no `gen_ai.*` unless
648
+ you ask for it with `genAi: true`).
649
+
650
+ | Attribute | What it holds |
651
+ | --- | --- |
652
+ | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.response.model` | `chat`, the provider and the model (without a known provider prefix) |
653
+ | `gen_ai.usage.input_tokens`, `.output_tokens`, `.cache_read.input_tokens` | your provider's counter; absent when it reported no usage |
654
+ | `com.bivelio.cost.basis`, `.currency`, `.baseline`, `.actual` | where the money comes from, its currency (USD by default), and both sides |
655
+ | `com.bivelio.cost.saved.measured` \| `.reference` \| `.estimated` | the money saved, in the key of its basis only |
656
+ | `com.bivelio.cost.net_savings_ratio` | `cost.netSavings`, a 0..1 ratio — not money |
657
+ | `com.bivelio.calls.*`, `com.bivelio.tokens.*` | the four ledgers: avoided calls, eliminated, compressed and provider‑cached tokens |
658
+ | `com.bivelio.request_id`, `.model.id`, `.cache.result`, `.quality.status` | ids and outcomes |
659
+
660
+ </details>
661
+
662
+ <details>
663
+ <summary><b>Your ledgers in your FinOps tool — FOCUS rows and a daily savings series</b></summary>
664
+
665
+ Keep each report with the time it came back, and export whenever you like:
666
+
667
+ ```ts
668
+ import { writeFileSync } from "node:fs";
669
+ import { toFocusRows, toSavingsSeries, toCsv, type SavingsRecord } from "@bivelio/savings-layer";
670
+
671
+ const records: SavingsRecord[] = [];
672
+ const answer = await layer.generate({
673
+ model: "openai/gpt-4o-mini",
674
+ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }],
675
+ });
676
+ records.push({ at: Date.now(), report: answer.report }); // keep reports as they come back
677
+
678
+ // FOCUS 1.4 rows, one per day, model and basis. Every cost column is 0 by design:
679
+ // your provider's bill already has the spend, so these rows add savings, never cost.
680
+ const rows = toFocusRows(records, { billingAccountId: "org-XXXXXXXX" }); // your provider billing account id
681
+ writeFileSync("bv-sala-focus.csv", toCsv(rows));
682
+
683
+ // A daily series for tools that take savings as a metric: one file per basis.
684
+ const measured = toSavingsSeries(records).filter((r) => r.measuredSavings !== null);
685
+ writeFileSync(
686
+ "vantage-measured-savings.csv",
687
+ toCsv(measured.map((r) => ({ date: r.date, amount: r.measuredSavings, model: r.model }))),
688
+ );
689
+ ```
690
+
691
+ The rows follow [FOCUS 1.4](https://github.com/FinOps-Open-Cost-and-Usage-Spec/FOCUS_Spec/tree/v1.4),
692
+ the FinOps Open Cost and Usage Specification: `ChargeCategory` is `Adjustment`, the
693
+ service provider is the one in the model id (`OpenAI`, `Anthropic`…; pass
694
+ `serviceProviderName`, `invoiceIssuerName` or `hostProviderName` when a cloud or a
695
+ gateway bills you), and no SKU or unit price is invented. Nothing is sent anywhere:
696
+ `toFocusRows`, `toSavingsSeries` and `toCsv` are plain functions over the reports you
697
+ kept. Where each file goes, as each tool documents it in September 2026:
698
+
699
+ - **A FOCUS data lake or warehouse** reads every column, the `x_BiVelio*` ones included.
700
+ - **[Finout](https://docs.finout.io/get-started-with-finout/cost-and-usage-types/focus-in-finout)**
701
+ loads a FOCUS CSV as a custom cost center; you send the file to Finout support.
702
+ - **[Vantage](https://docs.vantage.sh/connecting_custom_providers)** Custom Providers
703
+ accept FOCUS 1.1–1.3 files but use only a few columns for cost reporting, and say
704
+ nothing about `x_` columns, so the savings would not show. Upload the series as a
705
+ [Business Metric](https://docs.vantage.sh/per_unit_costs) instead
706
+ (`date,amount,<labels>`), one file per basis, so a metric never mixes bases.
707
+ Vantage rejects two rows with the same date and labels: with `dimensions`, add
708
+ them as label columns too.
709
+ - **[CloudZero](https://docs.cloudzero.com/docs/anycost-common-bill-format-cbf)** AnyCost
710
+ takes its own Common Bill Format; the series can go in as a
711
+ [Telemetry Stream](https://docs.cloudzero.com/docs/telemetry-streams).
712
+
713
+ The custom columns, in the currency you pass (`currency`, USD by default):
714
+
715
+ | Column | What it holds |
716
+ | --- | --- |
717
+ | `x_BiVelioBasis`, `x_BiVelioModelId` | the basis of every figure in the row, and the model id |
718
+ | `x_BiVelioRequestCount`, `x_BiVelioProviderCallCount`, `x_BiVelioAvoidedCallCount` | requests, calls that reached the provider, calls avoided |
719
+ | `x_BiVelioBaselineCost`, `x_BiVelioActualCost` | both sides of the saving; empty when the basis is `unmeasured` |
720
+ | `x_BiVelioMeasuredSavings`, `x_BiVelioReferenceSavings`, `x_BiVelioEstimatedSavings` | `baseline − actual`, in the column of the row's basis only; negative when the layer cost you money |
721
+ | `x_BiVelioEliminatedInputFloorCost` | the separate floor lane for context your provider cleared |
722
+ | `x_BiVelio…InputTokens`, `x_BiVelio…OutputTokens` | the token ledgers: baseline, actual, eliminated, compressed, provider‑cached |
723
+
724
+ `Tags` carries `bivelio/basis` and `bivelio/model`, plus any tags you return from the
725
+ `tags` option. Sum `measured` and `reference` for what you can check against your
726
+ provider invoice; `estimated` has its own column on purpose.
727
+
728
+ </details>
729
+
236
730
  <details>
237
731
  <summary><b>Running a fleet? Seats and renewals</b></summary>
238
732
 
@@ -255,8 +749,14 @@ BVSALA_SEAT_ID=worker-03 # stable per replica; [A-Za-z0-9._:/-], max 128 chars
255
749
 
256
750
  - **Node ≥ 20.** The package is **ESM‑only** (`"type": "module"`); from CommonJS,
257
751
  `require()` works on Node ≥ 22.
258
- - Optional peer `gpt-tokenizer` for exact OpenAI token counts — without it a
259
- built‑in estimator is used, so it is not required to get started.
752
+ - Framework adapters: `ai` 6 or 7, `@mastra/core` ≥ 1.68 < 2, `langchain` ≥ 1.1 < 2
753
+ with `@langchain/core` ≥ 1.1 < 2, and `@anthropic-ai/sdk` ≥ 0.103 < 1 — optional
754
+ peers, needed only for the adapter you import. `ai` 7 and `@mastra/core` declare
755
+ Node ≥ 22 in their own `engines`.
756
+ - Optional peer `gpt-tokenizer` for exact OpenAI token counts, loaded automatically
757
+ when installed — without it a built‑in estimator is used. It improves the accuracy
758
+ of estimated figures; what reaches your invoice is `measured` and `reference`, which
759
+ come from the provider's own counter either way.
260
760
  - Every component (cache, vector store, tokenizer, compressor, constraint backend)
261
761
  ships with an in‑memory default and is independently swappable behind the same
262
762
  interfaces.
@@ -265,14 +765,20 @@ BVSALA_SEAT_ID=worker-03 # stable per replica; [A-Za-z0-9._:/-], max 128 chars
265
765
 
266
766
  ## Licensing & privacy
267
767
 
268
- BV‑SALA is commercial, per‑seat software (**€4 / seat / month** or **€39 / seat /
269
- year**, EUR; VAT not included). No free tier and no trial: what you can check before
270
- paying is the published measurement. The license is a signed token your SDK verifies **offline**; the
271
- managed control plane and the revocation kill‑switch are served from BiVelio. Your
272
- LLM traffic goes **straight to your provider** — BiVelio sees counts and cost,
273
- never prompts or completions.
274
-
275
- Get a key and read the terms at **[savings.bivelio.com](https://savings.bivelio.com)**.
768
+ BV‑SALA is commercial software: a per‑seat subscription (**€4 / seat / month** or
769
+ **€39 / seat / year**, EUR; VAT not included), paid whether or not you save, plus a
770
+ tiered commission on measured savings only, as described at the top of this page, in
771
+ [LICENSE](./LICENSE) §6 and in the Published Pricing it refers to,
772
+ **[savings.bivelio.com/pricing](https://savings.bivelio.com/pricing)**. No free tier and no trial: what you can check before
773
+ paying is the published measurement. The license is a signed token your SDK and the
774
+ gateway verify **offline**; the managed control plane and the revocation kill‑switch
775
+ are served from BiVelio. Your LLM traffic goes **straight to your provider** —
776
+ BiVelio receives per‑request usage (counts, cost, the model id and opaque ids), never
777
+ prompts, completions or provider keys; [LICENSE](./LICENSE) §5 lists every field, and
778
+ **[savings.bivelio.com/privacy](https://savings.bivelio.com/privacy)** says how long
779
+ each one is kept.
780
+
781
+ Get a key at **[savings.bivelio.com](https://savings.bivelio.com)**.
276
782
  Onboarding & general questions: **support@bivelio.com** · licensing & compliance:
277
783
  **privacy@bivelio.com** · technical: **team@bivelio.com**.
278
784