@bivelio/savings-layer 0.4.24 → 0.4.26

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,17 +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 we charge on is measured with the provider's
23
- official meter, the same one on your bill**, and every saving — measured or estimated,
24
- and labelled as such — is handed back to you request by request.
25
-
26
- **We only earn when you save.** Your first month carries no commission at all: we
27
- measure everything and show you the statement of what it would have cost. After
28
- that, the commission comes out of the **measured** saving only — never out of your
29
- pocket, and never from estimates: a tiered rate from 10 % down to 4 % of what you
30
- saved, so **you always keep at least 90 % of every euro saved**, and a month with no
31
- saving is zero commission. And your prompts **never leave your infrastructure**:
32
- 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).
33
40
 
34
41
  ### For engineers, in one line
35
42
 
@@ -43,9 +50,11 @@ goes straight from your app to your provider; the SDK is never a middleman serve
43
50
  > BV‑SALA is fail‑closed and executes only with an active BiVelio license key.
44
51
  > The license signature is verified **offline**; with `licenseServerUrl` set, the SDK
45
52
  > also calls `savings.bivelio.com` for the revocation list, license renewal and usage
46
- > metering. **Your prompts never leave your infrastructure** — what travels is token
47
- > counts, cost, the model id and opaque seat/session ids, never content. Use is governed by the [LICENSE](./LICENSE)
48
- > 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.
49
58
 
50
59
  ## Where it installs
51
60
 
@@ -65,12 +74,14 @@ products charge a flat subscription, so there is no per‑token bill to cut. The
65
74
  story, in plain words:
66
75
  **[Where does the saving install?](https://savings.bivelio.com/en/how-it-works)**
67
76
 
68
- **Claude Code, Codex CLI, Goose or Qwen Code with an API key?** The package also
69
- ships a local measuring gateway: `bvsala claude` (or `bvsala codex`, `bvsala
70
- goose`, `bvsala qwen`) launches the terminal through it and your real spend shows
71
- up in your dashboard, token by token — nothing to configure, nothing persisted.
72
- For opencode, Crush, Droid, Cline or Kilo Code, `bvsala doctor` prints the exact
73
- config block for each one you have installed. Like the SDK, the gateway is
77
+ **A coding agent with an API key?** The package also ships a local measuring
78
+ gateway: `bvsala claude` (or `bvsala codex`, `goose`, `qwen`, `opencode`, `kilo`,
79
+ `copilot` with your own Anthropic key, `aider`) launches the agent through it and your real
80
+ spend shows up in your dashboard, token by token — nothing to configure, nothing
81
+ persisted. On exit it tells you how many requests it metered and which ones it
82
+ could not, and why. For Crush, Droid, Cline, Continue or Copilot in VS Code,
83
+ `bvsala doctor` prints the exact config block. Gemini CLI and Cursor cannot be
84
+ metered today, and the doctor says so. Like the SDK, the gateway is
74
85
  fail‑closed: it starts only with your service key (`BIVELIO_SERVICE_KEY`) and an
75
86
  active BiVelio license, which it collects with that key and verifies against the
76
87
  key embedded in the package. Once running it never interrupts a request in flight:
@@ -83,6 +94,21 @@ subscription there is no per-token invoice, so that traffic is metered at
83
94
  list-price equivalent and never carries commission; the per-seat license applies
84
95
  as for any other use.
85
96
 
97
+ **The gateway forwards what your terminal sends.** With no lever armed, your
98
+ provider receives the request body byte for byte and every header your client
99
+ sends except `x-bvsala-report`, which asks the gateway itself for a report (see
100
+ the limits): `cache_control` and its 1-hour TTL, tool search and
101
+ `tool_reference` blocks, the advisor, every `anthropic-beta` value. An armed lever
102
+ changes only what it declares, and a request it cannot rewrite exactly travels
103
+ untouched. A test suite checks this against a fake upstream on every change
104
+ (`test/gateway-no-empeora.test.ts`; the repository is private, so the linked
105
+ section lists exactly what it checks). The gateway also logs, once per session,
106
+ requests that come out worse than your client asked for: cache markers the
107
+ provider ignored, a 1-hour TTL written at 5 minutes upstream, and tool search off
108
+ (Claude Code turns it off by default behind any proxy, this one included;
109
+ `bvsala claude` turns it back on). What it checks, and its limits:
110
+ [docs/terminales/README.md](./docs/terminales/README.md#what-the-gateway-guarantees-it-forwards-what-your-client-sends).
111
+
86
112
  ### Python and any other language: the local gateway
87
113
 
88
114
  The same gateway sits in front of any SDK that lets you set a base URL, whatever
@@ -117,6 +143,17 @@ is measurement, not savings: the saving levers of the SDK are TypeScript, and th
117
143
  gateway's own levers apply to Anthropic `/v1/messages` traffic with a PRO license.
118
144
  `bvsala doctor` prints the recipe for each SDK.
119
145
 
146
+ Each request can also bring its report back to your code. Send the header
147
+ `x-bvsala-report: 1` (in Python, `default_headers={"x-bvsala-report": "1"}` on
148
+ the client) and the gateway answers with `x-bvsala-request-id`. On the same
149
+ gateway, `GET /_bvsala/v1/reports/<that id>` returns what it recorded for your
150
+ dashboard: `metered` with the `SavingsReport` (the provider's token counts, the
151
+ cost at list price and `cost.basis`), or `not_metered` with the reason from the
152
+ log line. `?response_id=<the provider's response id>` finds it too, and
153
+ `wait_ms` waits for a request still in flight. The header never reaches your
154
+ provider, and a request without it is not changed at all:
155
+ [the details](./docs/terminales/README.md#what-the-gateway-guarantees-it-forwards-what-your-client-sends).
156
+
120
157
  ## Check it before you install
121
158
 
122
159
  What you can check before paying is what we have measured: the published benchmark,
@@ -126,6 +163,22 @@ and the commission calculator on the front page, which runs on the same price
126
163
  catalogue that bills you. The verifier that replays the optimizer on your own data
127
164
  lives in your dashboard once you sign in.
128
165
 
166
+ ## Set it up in one command
167
+
168
+ ```bash
169
+ npx @bivelio/savings-layer@latest init
170
+ ```
171
+
172
+ It reads your `package.json` (Next.js, Express, the Vercel AI SDK, Mastra,
173
+ LangChain.js, the Anthropic SDK or the OpenAI SDK) and prints the three steps below,
174
+ written for your stack — the install line for your package manager, which keys are
175
+ missing, the exact code for your adapter — plus a free check: a question the layer
176
+ answers on your machine, so the model is not called. It reads `package.json` files only
177
+ (yours, and those in `node_modules` for the installed versions), never a `.env` file or a
178
+ key's value; it uses no network of its own and writes nothing.
179
+ `--write` creates the new files after asking you, and never overwrites one; `--json`
180
+ prints the same plan for a script or a coding agent.
181
+
129
182
  ## Install in three steps
130
183
 
131
184
  **1 · The package**
@@ -228,6 +281,137 @@ still writing cancels its request, and nothing is cached or recorded; once its a
228
281
  is complete, or when it arrives in one piece, nothing is cancelled.
229
282
  `await stream.result` tells you which happened.
230
283
 
284
+ ## Inside your framework
285
+
286
+ Already on an agent framework? The layer plugs in at that framework's own extension
287
+ point, from a subpath of this same package. The four adapters behave alike:
288
+
289
+ - **They answer without calling the model when they can** — a computed answer (the
290
+ arithmetic above), an exact repeat of a request that already ran, an identical
291
+ request already in flight — and otherwise let your framework make exactly the call
292
+ it was going to make. They never rewrite what your framework sends.
293
+ - **Tools are treated as having side effects** unless you list them in
294
+ `readOnlyTools`: a request that offers any other tool is never answered from a
295
+ cache, because the action has to run.
296
+ - **Fail‑closed, like the SDK**: without an active license the call fails with
297
+ `LicenseError` (a framework may wrap it, keeping its name and message) and the
298
+ model is not called. **Put the adapter last**, so nothing sits between it and the
299
+ model.
300
+
301
+ Every model call gets the same `SavingsReport` as with the SDK — its `basis` says
302
+ whether a figure comes from your provider's own counter — and `onReport` receives it
303
+ too.
304
+
305
+ ### Vercel AI SDK
306
+
307
+ `pnpm add @bivelio/savings-layer ai @ai-sdk/openai` — a `LanguageModelMiddleware`
308
+ for `ai` 6 or 7:
309
+
310
+ ```ts
311
+ import { generateText, wrapLanguageModel } from "ai";
312
+ import { openai } from "@ai-sdk/openai";
313
+ import { savingsMiddleware, getSavingsReport } from "@bivelio/savings-layer/ai-sdk";
314
+
315
+ const savings = savingsMiddleware({
316
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
317
+ licenseServerUrl: "https://savings.bivelio.com",
318
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
319
+ readOnlyTools: ["get_weather"],
320
+ });
321
+ const model = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware: savings }); // last in the list
322
+
323
+ const result = await generateText({
324
+ model,
325
+ prompt: "What is 2 + 2 * 3?",
326
+ providerOptions: { bivelio: { context: { sessionId: "user-42" } } },
327
+ });
328
+ console.log(getSavingsReport(result)?.cost.basis); // "estimated": answered without calling the model
329
+ ```
330
+
331
+ For `streamText`, read it with `getSavingsReport({ providerMetadata: await result.providerMetadata })`.
332
+
333
+ ### Mastra
334
+
335
+ `pnpm add @bivelio/savings-layer @mastra/core` — an input processor for
336
+ `@mastra/core` 1.x:
337
+
338
+ ```ts
339
+ import { Agent } from "@mastra/core/agent";
340
+ import { savingsProcessor } from "@bivelio/savings-layer/mastra";
341
+
342
+ const bivelio = savingsProcessor({
343
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
344
+ licenseServerUrl: "https://savings.bivelio.com",
345
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
346
+ onReport: (r) => console.log(r.cost.basis),
347
+ });
348
+ const agent = new Agent({
349
+ id: "support",
350
+ name: "Support",
351
+ instructions: "Answer briefly.",
352
+ model: "openai/gpt-4o-mini",
353
+ inputProcessors: [bivelio], // last in the list
354
+ });
355
+ console.log((await agent.generate("What is 2 + 2 * 3?")).text); // "8"
356
+ ```
357
+
358
+ It covers `generate()` and `stream()`, not `generateLegacy()`/`streamLegacy()` or a
359
+ durable agent that resumes in another process; identical requests in flight are not
360
+ shared here. `emitReportPart: true` also streams each report as a
361
+ `data-bivelio-savings-report` part.
362
+
363
+ ### LangChain.js
364
+
365
+ `pnpm add @bivelio/savings-layer langchain @langchain/core @langchain/openai` — an agent
366
+ middleware for `createAgent` in `langchain` 1.x. The model string (`"openai:…"`) is loaded
367
+ through its integration package, so `@langchain/openai` (or `@langchain/anthropic`) has
368
+ to be installed:
369
+
370
+ ```ts
371
+ import { createAgent } from "langchain";
372
+ import { savingsAgentMiddleware, getSavingsReportFromMessage } from "@bivelio/savings-layer/langchain";
373
+
374
+ const agentSavings = savingsAgentMiddleware({
375
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
376
+ licenseServerUrl: "https://savings.bivelio.com",
377
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
378
+ });
379
+ const lcAgent = createAgent({ model: "openai:gpt-4o-mini", tools: [], middleware: [agentSavings] }); // last
380
+ const state = await lcAgent.invoke({ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }] });
381
+ console.log(getSavingsReportFromMessage(state.messages.at(-1))?.cost.basis);
382
+ ```
383
+
384
+ The model call is measured when it finishes; tokens you stream with
385
+ `streamMode: "messages"` keep coming from the model as usual. A chat model with its
386
+ own `cache` set is reported as `unmeasured`: its counter may be a cached copy.
387
+
388
+ ### Anthropic TypeScript SDK
389
+
390
+ `pnpm add @bivelio/savings-layer @anthropic-ai/sdk` — a client middleware for
391
+ `@anthropic-ai/sdk` 0.103 or later:
392
+
393
+ ```ts
394
+ import Anthropic from "@anthropic-ai/sdk";
395
+ import { anthropicSavings } from "@bivelio/savings-layer/anthropic-sdk";
396
+
397
+ const claudeSavings = anthropicSavings({
398
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
399
+ licenseServerUrl: "https://savings.bivelio.com",
400
+ serviceAccountKey: process.env.BIVELIO_SERVICE_KEY,
401
+ });
402
+ const client = new Anthropic({ middleware: [claudeSavings.middleware] }); // last in the list
403
+ const message = await client.messages.create({
404
+ model: "claude-opus-5",
405
+ max_tokens: 256,
406
+ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }],
407
+ });
408
+ console.log(claudeSavings.reportFor(message)?.cost.basis);
409
+ ```
410
+
411
+ It runs once per HTTP attempt, inside the SDK's retries: an attempt the provider
412
+ rejects reaches the SDK unchanged and is not recorded. Only message creation goes
413
+ through the layer; `countTokens`, batches, models and files pass untouched.
414
+
231
415
  ## What you get on every request
232
416
 
233
417
  A `SavingsReport` with the numbers behind every claim, kept in **four separate
@@ -240,6 +424,29 @@ ledgers** that are never summed into one flattering figure:
240
424
  | `compressed_tokens` | information kept, but encoded with fewer tokens |
241
425
  | `provider_cached_tokens` | tokens still in the prompt, billed/processed more cheaply |
242
426
 
427
+ **Quality, kept apart from money.** `report.qualityLedger` records how the answer you
428
+ received earned its place: whether it came from your provider, a replay, or the answer
429
+ to a reworded question (with its similarity score); whether it was checked against your
430
+ output contract, repaired or cut off by the output limit; and what the model cascade
431
+ decided. It holds no money, is never added to the four ledgers, and never leaves your
432
+ process. Reusing answers to reworded questions is opt-in, and by default it covers
433
+ single-shot requests only: requests that declare tools, carry tool calls or results, or
434
+ hold more than one user turn are left out unless you set
435
+ `savings.semanticCache: "include-agentic"`.
436
+
437
+ **Calibrated reuse (opt-in).** A fixed similarity floor means something different for
438
+ every embedder and every workload, so you can make the cache earn its floor on your own
439
+ traffic instead: `new InMemorySemanticCache({ embed, calibration: { maxErrorRate: 0.02 } })`.
440
+ It then serves no reworded answer until your labels show, at 95 % confidence, that at
441
+ most 2 % of the answers it would reuse from some score up are wrong, and it re-tests that
442
+ floor as labels accumulate. Until then every candidate goes to your provider, exactly as
443
+ it would without the cache, and the fresh answer is compared with the stored one by your
444
+ `judge` (by default only identical answers count, which suits classification and
445
+ structured output; pass your own for free text). Each calibrated reuse carries its bound
446
+ and the labels behind it in `report.qualityLedger.cacheMatch.calibration`. Calibration
447
+ costs reuses, never an extra call, and it does not change how anything is billed. If you
448
+ already have labeled pairs, `calibrateSemanticFloor()` runs the same test offline.
449
+
243
450
  And a promise you can hold it to: **never worse than your JSON.** Payload encodings
244
451
  are chosen by counting real tokens with the destination model's own tokenizer, and
245
452
  BV‑SALA only moves away from plain JSON when the saving clearly clears the bar —
@@ -295,6 +502,183 @@ and every pair as a CSV download.
295
502
 
296
503
  </details>
297
504
 
505
+ <details>
506
+ <summary><b>Provider price levers (opt-in, off by default)</b></summary>
507
+
508
+ Two request flags ask your provider for a lower price on the same tokens. Neither
509
+ changes the answer, and what they are worth is reported next to the four ledgers:
510
+ never folded into `cost.*`, never part of any commission.
511
+
512
+ **`savings.deferrable`** asks for the provider's Flex tier: OpenAI, and Gemini through
513
+ its OpenAI‑compatible endpoint. Anthropic has no cheaper synchronous tier: its
514
+ discount lives only in Message Batches, which `layer.batches` (below) sends to.
515
+ Bedrock and DeepSeek off‑peak pricing are not supported.
516
+
517
+ - **Latency.** OpenAI publishes no latency commitment for Flex; Google publishes a
518
+ target of minutes. Keep deferrable requests off user‑facing paths.
519
+ `report.deferred.latency` quotes what the provider documents.
520
+ - **Risk.** Without capacity the provider refuses the request (429, or 503 on Gemini)
521
+ instead of waiting; OpenAI documents that the refusal is not charged. With
522
+ `priceLevers: { onCapacityError: "standard" }` the layer repeats it once at the
523
+ standard tier and price, and says so in `report.deferred.capacityFallback`.
524
+ - **What it is worth.** Your provider's usage counter for that call, priced at the two
525
+ published rate cards (standard, and the tier that actually served it), component
526
+ by component, with the date the prices were checked. It is `measured` only when
527
+ the served tier was read back from the provider's own API, the prices are list
528
+ prices and the counter says how much of the input was read from (or written to)
529
+ the prompt cache; otherwise `estimated`, and `basisReason` says why. It never
530
+ exceeds the difference between the two published rate cards, even with your own
531
+ `costModels`. A tier the provider did not confirm is never credited.
532
+
533
+ **`savings.standalone`** (GPT‑5.6 and later, on OpenAI's own API) declares that nothing
534
+ after the stable prefix of this request will be sent again. The provider then writes
535
+ its prompt cache only at the end of that prefix, or nowhere, instead of at the end of
536
+ your message, which on these models costs more than plain input. The trade‑off:
537
+ repeats and continuations of this exact prompt lose the cache reads the provider
538
+ default would give them, so leave it off conversation turns you will continue. Its
539
+ figure, `report.cacheWrites.avoidedWritePremium`, is always `estimated`: the provider
540
+ default was not run.
541
+
542
+ ```ts
543
+ const leveredLayer = createSavingsLayer({
544
+ provider,
545
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
546
+ priceLevers: { deferrable: true, onCapacityError: "standard" }, // layer-wide defaults
547
+ });
548
+
549
+ const { report } = await leveredLayer.generate({
550
+ model: "openai/gpt-5.6-luna",
551
+ messages: [
552
+ { role: "system", content: "…your long, fixed instructions…" },
553
+ { role: "user", content: "Classify ticket 42." },
554
+ ],
555
+ savings: { standalone: true }, // a request's own value always wins
556
+ });
557
+
558
+ report.deferred?.tierSaving; // { amount, basis, standardCost, appliedCost, pricesCheckedOn, basisReason }
559
+ report.cacheWrites; // { mode, breakpoints, avoidedWritePremium?, reason }
560
+ ```
561
+
562
+ **`layer.batches`** sends requests that can wait hours to the provider's own batch API,
563
+ billed at its published batch rate card: Anthropic's Message Batches through
564
+ `AnthropicProvider`, and OpenAI's Batch API through `OpenAICompatibleProvider` on
565
+ `api.openai.com`. Gemini's batch mode is not supported yet.
566
+
567
+ - **What runs.** Each request is translated by the same provider adapter as
568
+ `generate()` and sent as you wrote it: the layer's own per‑request savings (caching,
569
+ compression, model cascade, retrieval) do not run on batches. The layer splits the
570
+ job at the provider's limits (and, on OpenAI, one model per file).
571
+ - **Latency.** The provider's: results arrive within its batch window, not on a
572
+ request/response path. `submit()` returns a plain‑JSON job you can store and read
573
+ from another process.
574
+ - **What it is worth.** Each result's usage counter at the standard and at the batch
575
+ published rate cards, component by component, with the same `measured` /
576
+ `estimated` rule as above. The tier is read from the provider: Anthropic reports it
577
+ on every result; on OpenAI it is the batch the result came from. Nothing is
578
+ metered and the batch saving is never billed.
579
+
580
+ ```ts
581
+ const tickets = [{ id: "t-1", text: "My invoice is wrong." }]; // your deferrable work
582
+
583
+ const job = await layer.batches.submit(
584
+ tickets.map((t) => ({
585
+ customId: t.id,
586
+ request: { model: "openai/gpt-4o-mini", messages: [{ role: "user", content: t.text }] },
587
+ })),
588
+ );
589
+ // …later, even from another process (store `job` as JSON):
590
+ const results = await layer.batches.results(job);
591
+ results.ended; // false while the provider is still working
592
+ results.items; // one per request: { customId, outcome, text, usage, tierSaving?, reason }
593
+ results.summary; // { succeeded, errored, expired, credited, tierSaving, basis, pricesCheckedOn, reason, … }
594
+ ```
595
+
596
+ </details>
597
+
598
+ <details>
599
+ <summary><b>Tool outputs, compacted where they are born (opt-in, off by default)</b></summary>
600
+
601
+ In an agent loop your history is re-sent on every turn and your provider reads it
602
+ from its prompt cache at a fraction of the input price. Rewriting anything it has
603
+ already cached turns everything after it back into full-price cache writes, which is
604
+ how "fewer tokens" ends up as a bigger bill. So this lever only ever changes a tool
605
+ output on the turn it arrives, and re-sends exactly the same bytes afterwards.
606
+
607
+ It removes terminal colour codes, superseded progress-bar states, whitespace between
608
+ JSON tokens and runs of identical lines (folded into one line plus a count), and
609
+ nothing else: no summaries, no dropped words. Each change either reverses exactly or
610
+ leaves what a terminal would display. Outputs of calls that name a file are left
611
+ verbatim, so an agent that edits by exact match still sees the file as it is.
612
+
613
+ ```ts
614
+ const agentLayer = createSavingsLayer({
615
+ provider,
616
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
617
+ toolOutputCompaction: true, // or { exceptTools: ["read_logs"], minSavedChars: 512 }
618
+ });
619
+ // or per request: savings: { toolOutputCompaction: true }
620
+ ```
621
+
622
+ - **Switch it on for whole conversations.** The decision depends only on each
623
+ output's own text, so every process and every turn produces the same bytes. A
624
+ conversation that started without it is rewritten once, on its first compacted
625
+ request.
626
+ - **Counted, not billed.** What it removes shows in `report.tokens.compressedInput`
627
+ and in `report.traces`; it never enters `cost.*` or any commission. Your provider's
628
+ invoice shows the effect.
629
+ - **Inside a framework,** call `compactToolOutput(text)` in your tool before you
630
+ return its result: your history then holds the compacted text itself.
631
+ - **Measure it by conversation, not by request.** A verification copy re-sends one
632
+ request with the original history, which your provider has not cached, so on
633
+ turns that re-send compacted outputs the copy costs more than going without the
634
+ layer would have. Compare whole conversations with and without it.
635
+
636
+ </details>
637
+
638
+ <details>
639
+ <summary><b>Prompt-cache breakpoints placed from your traffic (opt-in)</b></summary>
640
+
641
+ By default the layer writes the provider's prompt cache at two fixed places: the end
642
+ of the system header and the end of the history before your last user message, and
643
+ stops marking a prefix whose measured hit rate does not pay the write premium.
644
+
645
+ `cacheBreakpoints: "auto"` places them from what your traffic actually does. The layer
646
+ compares each request's prefix with the ones it sent before, per model, tool set and
647
+ system header, and learns how often each part is sent again and after how long. That
648
+ costs nothing: it compares locally and marks nothing to find out. Once the evidence
649
+ is conclusive:
650
+
651
+ - **Tail.** When requests continue each other (agent tool loops, conversations), it
652
+ also marks the last block, so the next turn reads the whole prompt from cache.
653
+ Tool results arrive after your last user message, so the fixed placement never
654
+ caches them.
655
+ - **History.** When the history before the question is not sent again (one-off
656
+ requests with their own document), it moves the breakpoint back to the system
657
+ header, so no write premium is paid on a prefix nobody reads.
658
+ - **One-hour lifetime** (Anthropic). When turns come back after more than five
659
+ minutes but within the hour, and the size of the request makes the 2x write pay.
660
+ - **Explicit mode** (GPT‑5.6 and later, on OpenAI's own API). When continuations are
661
+ rare, your question is not written to the cache. `savings.standalone` still wins
662
+ when you set it, and `report.cacheWrites` says when the layer chose it.
663
+
664
+ Until then it behaves exactly like the default. Every decision and its reason is in
665
+ `report.traces` (`prefix-cache-placement`), and the effect is read from the provider's
666
+ own counters: if they do not confirm the cache reads the layer predicted, that
667
+ traffic goes back to the default placement. The write premium it causes, one-hour
668
+ writes at 2x included, is charged to `cost.actual`; the cache discount it earns is
669
+ counted in ledger 4 like any provider cache read, never credited as the layer's
670
+ saving and never part of any commission.
671
+
672
+ ```ts
673
+ const placedLayer = createSavingsLayer({
674
+ provider, // Anthropic, or OpenAI GPT-5.6+ on OpenAI's own API
675
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
676
+ cacheBreakpoints: "auto",
677
+ });
678
+ ```
679
+
680
+ </details>
681
+
298
682
  <details>
299
683
  <summary><b>Structured output — validated <code>data</code>, not just text</b></summary>
300
684
 
@@ -366,6 +750,185 @@ if (res.toolCalls?.length) {
366
750
  A tool‑call turn is never served from cache and never counted as an avoided call:
367
751
  an action that has not run yet cannot be a saving.
368
752
 
753
+ **Large catalogues: let the provider search them (opt‑in).** With hundreds of tools
754
+ (several MCP servers, say), `savings: { toolSearch: "auto" }` sends every tool deferred
755
+ behind the provider's own tool search instead of letting the layer's selector keep a
756
+ few. A catalogue that declares no `intents` or `risk` gives the selector nothing to go
757
+ on, so it keeps the first ones and the model cannot call the rest; with the provider's
758
+ search the model looks up what it needs, no tool you declared is out of reach, and the
759
+ cached prompt prefix stays the same from one question to the next.
760
+
761
+ - **Where.** The Anthropic adapter, on models in Anthropic's tool search compatibility
762
+ table. OpenAI documents its tool search for the Responses and Agents APIs, not for
763
+ Chat Completions, which is what this SDK's OpenAI adapter speaks, so OpenAI requests
764
+ keep the selector.
765
+ - **When.** `"auto"` applies it when the adapter talks to Anthropic's own API, the
766
+ catalogue is over the provider's documented size, and deferring it pays for one
767
+ search on every request even with a warm prompt cache. `"always"` skips those checks
768
+ (for example behind a proxy that forwards the search blocks). Requests with an output
769
+ contract, or with the model cascade on, keep the selector.
770
+ - **Whose saving.** The provider's. Your usage counter shows it; the layer credits
771
+ nothing for it and charges no commission on it.
772
+
773
+ ```ts
774
+ import { AnthropicProvider } from "@bivelio/savings-layer";
775
+
776
+ const claude = createSavingsLayer({
777
+ provider: new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY }),
778
+ licenseKey: process.env.BIVELIO_LICENSE_KEY,
779
+ });
780
+
781
+ const mcpTools: ToolDescriptor[] = []; // every tool your MCP servers list
782
+
783
+ const { report: searched } = await claude.generate({
784
+ model: "anthropic/claude-opus-5-5",
785
+ messages: [{ role: "user", content: "Which orders from service 17 are still open?" }],
786
+ tools: mcpTools,
787
+ savings: { toolSearch: { mode: "auto", alwaysLoaded: ["read_file"] } },
788
+ });
789
+
790
+ // Which path ran and why, and how many searches the provider ran.
791
+ console.log(searched.traces.filter((t) => t.stage === "tool-search"));
792
+ ```
793
+
794
+ </details>
795
+
796
+ <details>
797
+ <summary><b>Your ledgers in your tracing — OpenTelemetry, Langfuse, Datadog</b></summary>
798
+
799
+ Wrap the layer once and every request becomes a span on the OpenTelemetry pipeline
800
+ you already run (`@opentelemetry/api` is an optional peer; the SDK never imports it):
801
+
802
+ ```ts
803
+ import { trace } from "@opentelemetry/api";
804
+ import { instrumentSavingsLayer } from "@bivelio/savings-layer";
805
+
806
+ // One span per request, on the OpenTelemetry pipeline you already run.
807
+ const traced = instrumentSavingsLayer(layer, { tracer: trace });
808
+ const { report: tracedReport } = await traced.generate({
809
+ model: "openai/gpt-4o-mini",
810
+ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }],
811
+ });
812
+ console.log(tracedReport.cost.basis); // the span carries the same figures
813
+ ```
814
+
815
+ - **Standard spans, plus your ledgers.** Spans follow the
816
+ [GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions/blob/v1.41.1/model/gen-ai/registry.yaml)
817
+ as last published (OpenTelemetry semantic conventions v1.41.1): `chat {model}`,
818
+ kind CLIENT, provider, model and usage from your provider's counter (0 on an avoided
819
+ call: no token was billed). On top, `com.bivelio.*` carries the four ledgers, the
820
+ `basis`, the cost and the ids; `com.bivelio.request_id` is the same id as your usage
821
+ record. Same results, same errors: `traced` hands back the very objects `layer` does.
822
+ - **Money is kept apart by basis.** The money saved is
823
+ `com.bivelio.cost.saved.measured`, `.reference` or `.estimated` — one key per span,
824
+ the one of its basis — so a sum over one key never mixes measured and estimated
825
+ money; `unmeasured` spans carry counts and no money. `report.cost.netSavings` is a
826
+ ratio and goes out as `com.bivelio.cost.net_savings_ratio`; the money is
827
+ `baselineEstimate − actual`, and that is what `saved.*` holds.
828
+ - **None of this talks to BiVelio.** Spans go to your exporter; files stay where you
829
+ write them. Your prompts, completions and provider keys never reach BiVelio, and no
830
+ span carries them either: ids, counts, money and enums only, and a failed request
831
+ records its `error.type`, never its message.
832
+ - **Langfuse.** Add the `LangfuseSpanProcessor` from `@langfuse/otel` to your tracer
833
+ provider. These spans carry `gen_ai.*`, so its default filter exports them
834
+ ([Langfuse OpenTelemetry docs](https://langfuse.com/integrations/native/opentelemetry)).
835
+ `instrumentSavingsLayer(layer, { tracer: trace, langfuse: { metadata: true } })`
836
+ makes the figures filterable metadata, and `langfuse: { cost: true }` hands Langfuse
837
+ the report's own cost (in USD) instead of its estimate. Tracing with `@langfuse/tracing` by
838
+ hand? Pass `toLangfuseGeneration(report)` to `generation.update()`.
839
+ - **Datadog LLM Observability** reads these spans over OTLP
840
+ ([Datadog OpenTelemetry docs](https://docs.datadoghq.com/llm_observability/instrumentation/otel_instrumentation/)):
841
+
842
+ ```bash
843
+ OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf
844
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=<your Datadog site's OTLP traces endpoint>
845
+ OTEL_EXPORTER_OTLP_TRACES_HEADERS=dd-api-key=<your Datadog API key>,dd-otlp-source=llmobs
846
+ ```
847
+
848
+ The `com.bivelio.*` attributes arrive as tags; with `datadog: { metadata: true }`
849
+ they also arrive as span metadata. Datadog drops spans that carry no `gen_ai.*`
850
+ attribute, so use `instrumentSavingsLayer` there rather than `recordSavingsOnSpan`
851
+ (which writes `com.bivelio.*` on a span you already have, and no `gen_ai.*` unless
852
+ you ask for it with `genAi: true`).
853
+
854
+ | Attribute | What it holds |
855
+ | --- | --- |
856
+ | `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) |
857
+ | `gen_ai.usage.input_tokens`, `.output_tokens`, `.cache_read.input_tokens` | your provider's counter; absent when it reported no usage |
858
+ | `com.bivelio.cost.basis`, `.currency`, `.baseline`, `.actual` | where the money comes from, its currency (USD by default), and both sides |
859
+ | `com.bivelio.cost.saved.measured` \| `.reference` \| `.estimated` | the money saved, in the key of its basis only |
860
+ | `com.bivelio.cost.net_savings_ratio` | `cost.netSavings`, a 0..1 ratio — not money |
861
+ | `com.bivelio.calls.*`, `com.bivelio.tokens.*` | the four ledgers: avoided calls, eliminated, compressed and provider‑cached tokens |
862
+ | `com.bivelio.request_id`, `.model.id`, `.cache.result`, `.quality.status` | ids and outcomes |
863
+
864
+ </details>
865
+
866
+ <details>
867
+ <summary><b>Your ledgers in your FinOps tool — FOCUS rows and a daily savings series</b></summary>
868
+
869
+ Keep each report with the time it came back, and export whenever you like:
870
+
871
+ ```ts
872
+ import { writeFileSync } from "node:fs";
873
+ import { toFocusRows, toSavingsSeries, toCsv, type SavingsRecord } from "@bivelio/savings-layer";
874
+
875
+ const records: SavingsRecord[] = [];
876
+ const answer = await layer.generate({
877
+ model: "openai/gpt-4o-mini",
878
+ messages: [{ role: "user", content: "What is 2 + 2 * 3?" }],
879
+ });
880
+ records.push({ at: Date.now(), report: answer.report }); // keep reports as they come back
881
+
882
+ // FOCUS 1.4 rows, one per day, model and basis. Every cost column is 0 by design:
883
+ // your provider's bill already has the spend, so these rows add savings, never cost.
884
+ const rows = toFocusRows(records, { billingAccountId: "org-XXXXXXXX" }); // your provider billing account id
885
+ writeFileSync("bv-sala-focus.csv", toCsv(rows));
886
+
887
+ // A daily series for tools that take savings as a metric: one file per basis.
888
+ const measured = toSavingsSeries(records).filter((r) => r.measuredSavings !== null);
889
+ writeFileSync(
890
+ "vantage-measured-savings.csv",
891
+ toCsv(measured.map((r) => ({ date: r.date, amount: r.measuredSavings, model: r.model }))),
892
+ );
893
+ ```
894
+
895
+ The rows follow [FOCUS 1.4](https://github.com/FinOps-Open-Cost-and-Usage-Spec/FOCUS_Spec/tree/v1.4),
896
+ the FinOps Open Cost and Usage Specification: `ChargeCategory` is `Adjustment`, the
897
+ service provider is the one in the model id (`OpenAI`, `Anthropic`…; pass
898
+ `serviceProviderName`, `invoiceIssuerName` or `hostProviderName` when a cloud or a
899
+ gateway bills you), and no SKU or unit price is invented. Nothing is sent anywhere:
900
+ `toFocusRows`, `toSavingsSeries` and `toCsv` are plain functions over the reports you
901
+ kept. Where each file goes, as each tool documents it in September 2026:
902
+
903
+ - **A FOCUS data lake or warehouse** reads every column, the `x_BiVelio*` ones included.
904
+ - **[Finout](https://docs.finout.io/get-started-with-finout/cost-and-usage-types/focus-in-finout)**
905
+ loads a FOCUS CSV as a custom cost center; you send the file to Finout support.
906
+ - **[Vantage](https://docs.vantage.sh/connecting_custom_providers)** Custom Providers
907
+ accept FOCUS 1.1–1.3 files but use only a few columns for cost reporting, and say
908
+ nothing about `x_` columns, so the savings would not show. Upload the series as a
909
+ [Business Metric](https://docs.vantage.sh/per_unit_costs) instead
910
+ (`date,amount,<labels>`), one file per basis, so a metric never mixes bases.
911
+ Vantage rejects two rows with the same date and labels: with `dimensions`, add
912
+ them as label columns too.
913
+ - **[CloudZero](https://docs.cloudzero.com/docs/anycost-common-bill-format-cbf)** AnyCost
914
+ takes its own Common Bill Format; the series can go in as a
915
+ [Telemetry Stream](https://docs.cloudzero.com/docs/telemetry-streams).
916
+
917
+ The custom columns, in the currency you pass (`currency`, USD by default):
918
+
919
+ | Column | What it holds |
920
+ | --- | --- |
921
+ | `x_BiVelioBasis`, `x_BiVelioModelId` | the basis of every figure in the row, and the model id |
922
+ | `x_BiVelioRequestCount`, `x_BiVelioProviderCallCount`, `x_BiVelioAvoidedCallCount` | requests, calls that reached the provider, calls avoided |
923
+ | `x_BiVelioBaselineCost`, `x_BiVelioActualCost` | both sides of the saving; empty when the basis is `unmeasured` |
924
+ | `x_BiVelioMeasuredSavings`, `x_BiVelioReferenceSavings`, `x_BiVelioEstimatedSavings` | `baseline − actual`, in the column of the row's basis only; negative when the layer cost you money |
925
+ | `x_BiVelioEliminatedInputFloorCost` | the separate floor lane for context your provider cleared |
926
+ | `x_BiVelio…InputTokens`, `x_BiVelio…OutputTokens` | the token ledgers: baseline, actual, eliminated, compressed, provider‑cached |
927
+
928
+ `Tags` carries `bivelio/basis` and `bivelio/model`, plus any tags you return from the
929
+ `tags` option. Sum `measured` and `reference` for what you can check against your
930
+ provider invoice; `estimated` has its own column on purpose.
931
+
369
932
  </details>
370
933
 
371
934
  <details>
@@ -390,6 +953,10 @@ BVSALA_SEAT_ID=worker-03 # stable per replica; [A-Za-z0-9._:/-], max 128 chars
390
953
 
391
954
  - **Node ≥ 20.** The package is **ESM‑only** (`"type": "module"`); from CommonJS,
392
955
  `require()` works on Node ≥ 22.
956
+ - Framework adapters: `ai` 6 or 7, `@mastra/core` ≥ 1.68 < 2, `langchain` ≥ 1.1 < 2
957
+ with `@langchain/core` ≥ 1.1 < 2, and `@anthropic-ai/sdk` ≥ 0.103 < 1 — optional
958
+ peers, needed only for the adapter you import. `ai` 7 and `@mastra/core` declare
959
+ Node ≥ 22 in their own `engines`.
393
960
  - Optional peer `gpt-tokenizer` for exact OpenAI token counts, loaded automatically
394
961
  when installed — without it a built‑in estimator is used. It improves the accuracy
395
962
  of estimated figures; what reaches your invoice is `measured` and `reference`, which
@@ -402,14 +969,20 @@ BVSALA_SEAT_ID=worker-03 # stable per replica; [A-Za-z0-9._:/-], max 128 chars
402
969
 
403
970
  ## Licensing & privacy
404
971
 
405
- BV‑SALA is commercial, per‑seat software (**€4 / seat / month** or **€39 / seat /
406
- year**, EUR; VAT not included). No free tier and no trial: what you can check before
407
- paying is the published measurement. The license is a signed token your SDK and the gateway verify **offline**; the
408
- managed control plane and the revocation kill‑switch are served from BiVelio. Your
409
- LLM traffic goes **straight to your provider** — BiVelio sees counts and cost,
410
- never prompts or completions.
411
-
412
- Get a key and read the terms at **[savings.bivelio.com](https://savings.bivelio.com)**.
972
+ BV‑SALA is commercial software: a per‑seat subscription (**€4 / seat / month** or
973
+ **€39 / seat / year**, EUR; VAT not included), paid whether or not you save, plus a
974
+ tiered commission on measured savings only, as described at the top of this page, in
975
+ [LICENSE](./LICENSE) §6 and in the Published Pricing it refers to,
976
+ **[savings.bivelio.com/pricing](https://savings.bivelio.com/pricing)**. No free tier and no trial: what you can check before
977
+ paying is the published measurement. The license is a signed token your SDK and the
978
+ gateway verify **offline**; the managed control plane and the revocation kill‑switch
979
+ are served from BiVelio. Your LLM traffic goes **straight to your provider** —
980
+ BiVelio receives per‑request usage (counts, cost, the model id and opaque ids), never
981
+ prompts, completions or provider keys; [LICENSE](./LICENSE) §5 lists every field, and
982
+ **[savings.bivelio.com/privacy](https://savings.bivelio.com/privacy)** says how long
983
+ each one is kept.
984
+
985
+ Get a key at **[savings.bivelio.com](https://savings.bivelio.com)**.
413
986
  Onboarding & general questions: **support@bivelio.com** · licensing & compliance:
414
987
  **privacy@bivelio.com** · technical: **team@bivelio.com**.
415
988