@bivelio/savings-layer 0.4.0 → 0.4.2

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
@@ -1,31 +1,61 @@
1
- # @bivelio/savings-layer — BV‑SALA
1
+ # BV‑SALA — the savings layer for your LLM bill
2
2
 
3
- **A provider‑neutral LLM execution optimizer.** BV‑SALA sits in front of any LLM
4
- provider and, for every request, picks the **cheapest execution that still meets
5
- your quality bar** — then hands you a full, auditable `SavingsReport`.
3
+ <p align="center">
4
+ <img src="https://savings.bivelio.com/en/how-it-works/opengraph-image" alt="Your application → BV-SALA → provider API. The bill drops from $330 to $160 a month, measured." width="720">
5
+ </p>
6
6
 
7
- It is **not** a prompt compressor. Compression is the *fourth* thing it tries, not
8
- the first.
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/@bivelio/savings-layer"><img src="https://img.shields.io/npm/v/%40bivelio%2Fsavings-layer?color=2F63D8&label=npm" alt="npm version"></a>
9
+ <img src="https://img.shields.io/badge/node-%E2%89%A520-14A06F" alt="Node ≥ 20">
10
+ <img src="https://img.shields.io/badge/license-commercial-0F1218" alt="Commercial license">
11
+ </p>
12
+
13
+ **One layer inside your code, right where your app calls the OpenAI or Anthropic API.**
14
+ For every request it picks the cheapest execution that still meets your quality bar —
15
+ avoiding calls that don't need to happen and trimming what must be sent — and hands
16
+ you an auditable `SavingsReport`. Your bill drops, and you can verify why.
9
17
 
10
18
  > **Commercial software — © 2026 BiVelio Inc.** Free to install, **paid to run**:
11
- > BV‑SALA is **fail‑closed** and executes only with a valid, active BiVelio license
12
- > key. Verification is **offline** (no phone‑home to answer a request) and **your
13
- > prompts never leave your infrastructure** — only counts and cost are ever reported.
14
- > Use is governed by the [LICENSE](./LICENSE) and the BiVelio Terms. Not affiliated
15
- > with, and not a wrapper around, any third‑party library.
19
+ > BV‑SALA is fail‑closed and executes only with an active BiVelio license key.
20
+ > License checks are **offline** and **your prompts never leave your infrastructure** —
21
+ > only counts and cost are ever reported. Use is governed by the [LICENSE](./LICENSE)
22
+ > and the BiVelio Terms. Not a wrapper around any third‑party library.
23
+
24
+ ## Where it installs
25
+
26
+ Your application → **BV‑SALA** → provider API. Traffic always goes straight from
27
+ your app to your provider — BV‑SALA is a library inside your code, never a
28
+ middleman server, and it works with any OpenAI‑compatible endpoint (OpenAI, Azure,
29
+ vLLM, SGLang, a gateway) as well as Anthropic.
30
+
31
+ **Only use ChatGPT or Claude?** Then there is nothing to install: those products
32
+ charge a flat subscription, so there is no per‑token bill to cut. BV‑SALA is for
33
+ applications that call the API with their own key. The whole story, in plain words:
34
+ **[Where does the saving install?](https://savings.bivelio.com/en/how-it-works)**
35
+
36
+ ## Try it before you install
37
+
38
+ Run the real optimizer **free in your browser** — pick an example or paste your own
39
+ data and watch the meter — at **[savings.bivelio.com](https://savings.bivelio.com)**.
16
40
 
17
- ## Install
41
+ ## Install in three steps
42
+
43
+ **1 · The package**
18
44
 
19
45
  ```bash
20
46
  pnpm add @bivelio/savings-layer
21
47
  # or: npm i @bivelio/savings-layer · yarn add @bivelio/savings-layer
22
48
  ```
23
49
 
24
- You also need a **license key**. Try the optimizer **free in your browser** — paste
25
- your own data and watch the meter — at **[savings.bivelio.com](https://savings.bivelio.com)**,
26
- then subscribe (**€4 / seat / month** or **€39 / seat / year**, EUR) to get your key.
50
+ **2 · The license** — from your [dashboard](https://savings.bivelio.com/dashboard/install)
51
+ (**€4 / seat / month** or **€39 / seat / year**, EUR)
52
+
53
+ ```bash
54
+ export BIVELIO_LICENSE_KEY=… # your license key
55
+ export BIVELIO_SERVICE_KEY=… # service-account key: auto-renewal + seat metering
56
+ ```
27
57
 
28
- ## Quick start
58
+ **3 · Wrap the call** — this is the entire code change:
29
59
 
30
60
  ```ts
31
61
  import { createSavingsLayer, OpenAICompatibleProvider } from "@bivelio/savings-layer";
@@ -54,8 +84,30 @@ console.log(report.calls.avoidedLlm); // → whole calls you never paid for
54
84
  console.log(report.cost.netSavings); // → verified savings, per request
55
85
  ```
56
86
 
57
- Ask for a **structured contract** and you also get `data`, validated against your
58
- schema — the JSON-repair and output contracts levers switch on here:
87
+ That's it. From the first request, `report` carries the saving — measured, not
88
+ estimated.
89
+
90
+ ## What you get on every request
91
+
92
+ A `SavingsReport` with the numbers behind every claim, kept in **four separate
93
+ ledgers** that are never summed into one flattering figure:
94
+
95
+ | Ledger | Meaning |
96
+ | --- | --- |
97
+ | `avoided_calls` | LLM / retrieval / tool calls that never executed |
98
+ | `eliminated_tokens` | information dropped because it was not needed |
99
+ | `compressed_tokens` | information kept, but encoded with fewer tokens |
100
+ | `provider_cached_tokens` | tokens still in the prompt, billed/processed more cheaply |
101
+
102
+ And a promise you can hold it to: **never worse than your JSON.** Payload encodings
103
+ are chosen by counting real tokens with the destination model's own tokenizer, and
104
+ BV‑SALA only moves away from plain JSON when the saving clearly clears the bar —
105
+ `report.serialization` shows what was chosen and what each candidate would have cost.
106
+
107
+ <details>
108
+ <summary><b>Structured output — validated <code>data</code>, not just text</b></summary>
109
+
110
+ Ask for a response contract and you also get `data`, validated against your schema:
59
111
 
60
112
  ```ts
61
113
  const { data, text } = await layer.generate<{ answer: number }>({
@@ -76,42 +128,10 @@ const { data, text } = await layer.generate<{ answer: number }>({
76
128
  console.log(data.answer); // 8
77
129
  ```
78
130
 
79
- The hard requirements are a `provider` and a valid `licenseKey`: BV‑SALA is a paid
80
- product and **refuses to run unlicensed, expired, or tampered** (it throws
81
- `LicenseError` at the `generate()` entry, carrying a `status` of `none`,
82
- `invalid`, `expired`, `grace`, `revoked` or `stale`). Your key — and this snippet
83
- with the key already filled in — is at
84
- [savings.bivelio.com/dashboard/install](https://savings.bivelio.com/dashboard/install).
85
-
86
- > **Set `serviceAccountKey` on every long‑running deployment.** Your licence token
87
- > expires at the end of the billing period; each renewal mints a **new** token. With
88
- > a service‑account key (`bvk_…`, same dashboard) the SDK picks the renewed token up
89
- > by itself and swaps it in without a restart. **Without it there is no machine
90
- > pickup**, so somebody has to paste the new token by hand every cycle — and if
91
- > nobody does, the deployment stops with `LicenseError` once the grace period ends,
92
- > while the subscription is paid and current. The key also enables seat metering
93
- > (counts only — never prompts or completions).
94
-
95
- ### Seats (how you are billed)
96
-
97
- A **seat is a machine**, never a person. The SDK derives a stable, opaque id per
98
- machine + service-account key and reports it with the counts; the control plane
99
- bills a seat only once it has been seen on **3 separate days**.
100
-
101
- **Set `BVSALA_SEAT_ID` (one value per replica or service) whenever the machine
102
- identity can't be deduced** — Kubernetes, or any fleet where the hostname is
103
- assigned dynamically. Without it, every pod of a deployment shares one fingerprint
104
- and the whole deployment counts as a **single** seat. It is also the way to keep
105
- seat ids stable across an SDK upgrade: the fingerprint changed in v0.1.x, so a
106
- fleet that never set it may be counted twice in the month it upgrades.
107
-
108
- ```bash
109
- BVSALA_SEAT_ID=worker-03 # stable per replica; [A-Za-z0-9._:/-], max 128 chars
110
- ```
111
-
112
- CI runners are auto-detected and excluded from billable seats.
131
+ </details>
113
132
 
114
- ### Tool calls
133
+ <details>
134
+ <summary><b>Tool calls</b></summary>
115
135
 
116
136
  When you pass `tools` and the model asks to invoke one, the reply is not the final
117
137
  answer: `text` is empty by design and the request is in `toolCalls`. Execute it,
@@ -155,92 +175,45 @@ if (res.toolCalls?.length) {
155
175
  A tool‑call turn is never served from cache and never counted as an avoided call:
156
176
  an action that has not run yet cannot be a saving.
157
177
 
158
- > The `devUnlicensed` flag and `BVSALA_DEV_OPEN=1` are development‑only, honored
159
- > **only when building from source** (this repo's tests and benchmarks). They are
160
- > **disabled in this published package** — a valid `licenseKey` is the only way to
161
- > run it. They are not a way to use the product for free.
162
-
163
- Every component ships with an in‑memory default and is independently swappable:
164
- plug in your own cache, vector store, tokenizer, compressor or constraint backend
165
- behind the same interfaces.
166
-
167
- ## The optimization order (cheapest first)
178
+ </details>
168
179
 
169
- 1. **Avoid the call entirely** when the answer can be produced or reused safely.
170
- 2. **Avoid auxiliary calls** — the retrievals, tool hops and retries around it.
171
- 3. **Eliminate unnecessary context** before trying to shrink what remains.
172
- 4. **Shrink** the tokens that genuinely must be sent.
173
- 5. **Constrain generation** to emit only the structure and length required.
180
+ <details>
181
+ <summary><b>Running a fleet? Seats and renewals</b></summary>
174
182
 
175
- The order is the point: work removed at step 1 costs nothing to compress at step 4.
183
+ - **A seat is a machine, never a person.** A seat is billed only once it has been
184
+ seen on 3 separate days, and CI runners are auto‑detected and excluded.
185
+ - **Set `serviceAccountKey` on every long‑running deployment.** Renewals mint a new
186
+ license token each billing period; with the service‑account key (`bvk_…`, same
187
+ dashboard) the SDK picks the renewed token up by itself, without a restart.
188
+ - **Set `BVSALA_SEAT_ID` (one stable value per replica) on Kubernetes or any fleet
189
+ with dynamic hostnames**, so each replica counts as what it is:
176
190
 
177
- Provider prompt/KV caching is treated as a cost/latency saving — **never** as a
178
- reduction of the logical prompt size.
179
-
180
- ## Four separate ledgers (never summed)
181
-
182
- Mixing these produces misleading metrics, so BV‑SALA keeps them apart:
183
-
184
- | Ledger | Meaning |
185
- | --- | --- |
186
- | `avoided_calls` | LLM / retrieval / tool calls that never executed |
187
- | `eliminated_tokens` | information dropped because it was not needed |
188
- | `compressed_tokens` | information kept, but encoded with fewer tokens |
189
- | `provider_cached_tokens` | tokens still in the prompt, billed/processed more cheaply |
190
-
191
- A cache hit removes a whole call. A re‑encode makes a payload smaller. Compression
192
- drops redundant words. A prefix cache makes tokens cheaper without removing them.
193
- Four different things — summing them would lie to you, so the report never does.
194
-
195
- ## The decision cascade
196
-
197
- ```
198
- Request
199
- → Canonicalize + Policy (risk & freshness)
200
- → Deterministic resolvers ─────────────► answer (0 calls)
201
- → Exact cache ─────────────────────────► answer (0 calls)
202
- → Concurrent duplicates coalesced ─────► answer (0 calls)
203
- → Near-duplicate reuse ────────────► answer (0 calls)
204
- → Execution planning (how much work this really needs)
205
- → Context reduction (only when it pays for itself)
206
- → Payload encoding chosen by measurement, not by default
207
- → Output contract enforced
208
- → ONE primary LLM call
209
- → Structural + semantic validation (+ ≤1 repair)
210
- → Local render / transcode / cache write
211
- → SavingsReport + traces
191
+ ```bash
192
+ BVSALA_SEAT_ID=worker-03 # stable per replica; [A-Za-z0-9._:/-], max 128 chars
212
193
  ```
213
194
 
214
- ## Why formats are chosen, not assumed
215
-
216
- A compact encoding can be *larger* than plain JSON on nested or non‑uniform data,
217
- so BV‑SALA never assumes one. It counts the candidates with the destination
218
- model's own tokenizer — including the cost of explaining the encoding to the
219
- model — and only moves away from JSON when the saving clears a margin worth the
220
- change.
221
-
222
- On a wide, uniform table that is a large reduction. On a small nested object it
223
- leaves JSON alone. Whichever it picks, `report.serialization` tells you what was
224
- chosen and what each candidate would have cost, so the decision is auditable
225
- rather than promised.
195
+ </details>
226
196
 
227
- ## What's inside
197
+ <details>
198
+ <summary><b>Requirements</b></summary>
228
199
 
229
- Safe cache keys and a risk/freshness policy · several independent ways to answer
230
- without spending a call · execution planning · context reduction that must pay for
231
- itself · measured payload encoding · output contracts with validation · a
232
- provider‑neutral adapter · and a trace collector that builds the `SavingsReport`.
200
+ - **Node ≥ 20.** The package is **ESM‑only** (`"type": "module"`); from CommonJS,
201
+ `require()` works on Node ≥ 22.
202
+ - Optional peer `gpt-tokenizer` for exact OpenAI token counts — without it a
203
+ built‑in estimator is used, so it is not required to get started.
204
+ - Every component (cache, vector store, tokenizer, compressor, constraint backend)
205
+ ships with an in‑memory default and is independently swappable behind the same
206
+ interfaces.
233
207
 
234
- Every piece is pluggable, and every saving it claims is reported per request with
235
- the token counts behind it.
208
+ </details>
236
209
 
237
210
  ## Licensing & privacy
238
211
 
239
212
  BV‑SALA is commercial, per‑seat software (**€4 / seat / month** or **€39 / seat /
240
213
  year**, EUR). The license is a signed token your SDK verifies **offline**; the
241
- managed control plane (semantic cache, learned thresholds, dashboards) and the
242
- revocation kill‑switch are served from BiVelio. Your LLM traffic goes **straight to
243
- your provider** — BiVelio sees counts and cost, never prompts or completions.
214
+ managed control plane and the revocation kill‑switch are served from BiVelio. Your
215
+ LLM traffic goes **straight to your provider** — BiVelio sees counts and cost,
216
+ never prompts or completions.
244
217
 
245
218
  Get a key and read the terms at **[savings.bivelio.com](https://savings.bivelio.com)**.
246
219
  Onboarding & general questions: **support@bivelio.com** · licensing & compliance:
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import {spawn}from'child_process';import {createServer}from'http';import {randomUUID,createHash}from'crypto';var te=new Set;function v(t,e){te.has(t)||(te.add(t),console.warn(e));}var w=class{options;now;flushIntervalMs;maxBatch;maxBuffer;buffer=[];seatId;ci;timer;inflight=null;fallosSeguidos=0;constructor(e){this.options=e,this.now=e.now??(()=>Date.now()),this.flushIntervalMs=e.flushIntervalMs??15e3,this.maxBatch=e.maxBatch??50,this.maxBuffer=e.maxBuffer??1e3,this.ci=e.ci??false;}setSeat(e,n){e&&(this.seatId=e),this.ci=n;}record(e){this.buffer.push({report:e,at:this.now()}),this.buffer.length>this.maxBuffer&&this.buffer.splice(0,this.buffer.length-this.maxBuffer),this.buffer.length>=this.maxBatch&&this.flush();}start(){this.timer||(this.timer=setInterval(()=>{this.flush();},this.flushIntervalMs),this.timer.unref?.());}stop(){this.timer&&(clearInterval(this.timer),this.timer=void 0),this.flush();}async stopAndDrain(e){this.timer&&(clearInterval(this.timer),this.timer=void 0);let n=e??Math.ceil(this.maxBuffer/this.maxBatch)+4,r=0;for(let o=0;o<n&&this.buffer.length>0;o++){let s=this.buffer.length;if(await this.flush(),r=this.buffer.length>=s?r+1:0,r>=2)break}}async flush(){if(this.inflight)return this.inflight;if(this.buffer.length!==0){this.inflight=this.hacerFlush();try{await this.inflight;}finally{this.inflight=null;}}}async hacerFlush(){let e=this.options.fetchImpl??globalThis.fetch;if(!e)return;let n=this.buffer.splice(0,this.maxBatch);try{let r=n.map(s=>this.toWire(s)),o=await e(this.options.url,{method:"POST",headers:{"content-type":"application/json",authorization:`Bearer ${this.options.serviceAccountKey}`},body:JSON.stringify(r)});o.ok?this.fallosSeguidos=0:o.status>=500?(this.fallosSeguidos++,this.requeue(n),this.avisarDeFallo(`el servidor respondi\xF3 ${o.status}`)):o.status===401||o.status===403?(v(`metering-auth-${o.status}`,`[bv-sala] el env\xEDo de uso est\xE1 siendo rechazado con HTTP ${o.status} en ${this.options.url}. Revisa 'serviceAccountKey' (\xBFrotada? \xBFrevocada? \xBFde otra organizaci\xF3n?). Mientras no funcione NO se reporta uso: el panel dir\xE1 que no ve tr\xE1fico y los asientos facturables saldr\xE1n a cero.`),this.options.onAuthError?.(o.status)):(this.fallosSeguidos++,this.avisarDeFallo(o.status===404||o.status===405?`HTTP ${o.status}: la URL de env\xEDo parece equivocada`:`el servidor rechaz\xF3 el lote con HTTP ${o.status}`));}catch(r){this.fallosSeguidos++,this.requeue(n),this.avisarDeFallo(r instanceof Error?r.message:"error de red");}}avisarDeFallo(e){if(this.options.silenceWarnings)return;let n=this.fallosSeguidos;n<3||(n-3)%12!==0||console.warn(`[bv-sala] el env\xEDo de uso lleva ${n} intentos seguidos fallando contra ${this.options.url} (${e}). Las peticiones SIGUEN funcionando, pero el uso no est\xE1 llegando: el panel dir\xE1 que no ve tr\xE1fico y los asientos facturables saldr\xE1n a cero. Comprueba 'meteringUrl' \u2014 por defecto es \`\${licenseServerUrl}/api/metering/usage\`.`);}requeue(e){this.buffer=[...e,...this.buffer],this.buffer.length>this.maxBuffer&&(this.buffer.length=this.maxBuffer);}toWire(e){let n=e.report;return {orgId:this.options.orgId??"self",...this.seatId?{seatId:this.seatId}:{},...this.ci?{ci:true}:{},requestId:n.requestId,model:n.model,at:e.at,avoidedLlm:n.calls.avoidedLlm,actualLlm:n.calls.actualLlm,baselineInputTokens:n.tokens.baselineInput,actualInputTokens:n.tokens.actualInput,eliminatedInputTokens:n.tokens.eliminatedInput,compressedInputTokens:n.tokens.compressedInput,baselineOutputTokens:n.tokens.baselineOutput,actualOutputTokens:n.tokens.actualOutput,providerCachedInputTokens:n.tokens.providerCachedInput??0,cacheResult:n.cache.result,baselineCost:n.cost.baselineEstimate,actualCost:n.cost.actual,netSavings:n.cost.netSavings,...n.cost.basis?{basis:n.cost.basis}:{}}}};function O(){return globalThis.process}function be(t){if(/^[0-9a-f]{12}$/.test(t)||/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||/^ip-\d{1,3}(-\d{1,3}){3}$/i.test(t))return true;let e="[bcdfghjklmnpqrstvwxz2456789]";return new RegExp(`-${e}{8,10}-${e}{5}$`).test(t)}function ve(t){return !!(t.AWS_LAMBDA_FUNCTION_NAME??t.LAMBDA_TASK_ROOT??t.VERCEL??t.VERCEL_ENV??t.K_SERVICE??t.FUNCTION_TARGET??t.FUNCTIONS_WORKER_RUNTIME??t.NETLIFY)}async function ye(){if(O()?.execPath)try{let e=await import(["node","os"].join(":")),n=typeof e.hostname=="function"?e.hostname():"";return n.length>0?n:void 0}catch{return}}async function ke(t,e=ye){if(t.KUBERNETES_SERVICE_HOST||ve(t))return;let n=t.HOSTNAME??t.COMPUTERNAME??await e();if(!(!n||be(n)))return n}async function xe(){let t=O();if(!t||!t.env)return;let e=t.env,n=await ke(e),r=[t.platform,t.arch,t.execPath,n,e.USER??e.USERNAME??e.LOGNAME,e.HOME??e.USERPROFILE].filter(o=>typeof o=="string"&&o.length>0);return r.length?r.join("|"):void 0}var we=["CI","CONTINUOUS_INTEGRATION","GITHUB_ACTIONS","GITLAB_CI","CIRCLECI","TRAVIS","JENKINS_URL","BUILDKITE","TF_BUILD","TEAMCITY_VERSION","BITBUCKET_BUILD_NUMBER","CODEBUILD_BUILD_ID","HEROKU_TEST_RUN_ID","DRONE"];function A(){let t=O()?.env;if(!t)return false;for(let e of we){let n=t[e];if(n&&n!=="false"&&n!=="0")return true}return false}async function Se(t,e){let n=globalThis.crypto,r=new TextEncoder;if(n?.subtle)try{let i=await n.subtle.importKey("raw",r.encode(t),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),l=await n.subtle.sign("HMAC",i,r.encode(e));return [...new Uint8Array(l)].map(u=>u.toString(16).padStart(2,"0")).join("")}catch{}let o=2166136261,s=`${t}|${e}`;for(let i=0;i<s.length;i++)o^=s.charCodeAt(i),o=Math.imul(o,16777619);return (o>>>0).toString(16).padStart(8,"0")}async function _(t){let n=O()?.env?.BVSALA_SEAT_ID;if(n&&Te.test(n))return n;let r=await xe();if(!r)return;let o=await Se(t,r);if(o)return `m-${o.slice(0,32)}`}var Te=/^[A-Za-z0-9._:/-]{1,128}$/;var F={"gpt-4.1":{inputPer1k:.002,outputPer1k:.008,cachedInputPer1k:5e-4},"gpt-4.1-mini":{inputPer1k:4e-4,outputPer1k:.0016,cachedInputPer1k:1e-4},"gpt-4.1-nano":{inputPer1k:1e-4,outputPer1k:4e-4,cachedInputPer1k:25e-6},"gpt-4o":{inputPer1k:.0025,outputPer1k:.01,cachedInputPer1k:.00125},"gpt-4o-mini":{inputPer1k:15e-5,outputPer1k:6e-4,cachedInputPer1k:75e-6},"gpt-4-turbo":{inputPer1k:.01,outputPer1k:.03},"gpt-4":{inputPer1k:.03,outputPer1k:.06},"gpt-3.5-turbo":{inputPer1k:5e-4,outputPer1k:.0015},"gpt-5":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gpt-5-codex":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gpt-5-mini":{inputPer1k:25e-5,outputPer1k:.002,cachedInputPer1k:25e-6},"gpt-5-nano":{inputPer1k:5e-5,outputPer1k:4e-4,cachedInputPer1k:5e-6},"gpt-5.1-codex":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gpt-5.1-codex-max":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gpt-5.1-codex-mini":{inputPer1k:25e-5,outputPer1k:.002,cachedInputPer1k:25e-6},"gpt-5.1-thinking":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gpt-5.2":{inputPer1k:.00175,outputPer1k:.014,cachedInputPer1k:175e-6},"gpt-5.2-codex":{inputPer1k:.00175,outputPer1k:.014,cachedInputPer1k:175e-6},"gpt-5.3-codex":{inputPer1k:.00175,outputPer1k:.014,cachedInputPer1k:175e-6},"gpt-5.4":{inputPer1k:.0025,outputPer1k:.015,cachedInputPer1k:25e-5},"gpt-5.4-mini":{inputPer1k:75e-5,outputPer1k:.0045,cachedInputPer1k:75e-6},"gpt-5.4-nano":{inputPer1k:2e-4,outputPer1k:.00125,cachedInputPer1k:2e-5},"gpt-5.5":{inputPer1k:.005,outputPer1k:.03,cachedInputPer1k:5e-4},"gpt-5.6-luna":{inputPer1k:2e-4,outputPer1k:.0012,cachedInputPer1k:2e-5},"gpt-5.6-sol":{inputPer1k:.004,outputPer1k:.02,cachedInputPer1k:4e-4},"gpt-5.6-terra":{inputPer1k:.002,outputPer1k:.012,cachedInputPer1k:2e-4},"claude-opus-4-5":{inputPer1k:.005,outputPer1k:.025,cachedInputPer1k:5e-4},"claude-opus-4-6":{inputPer1k:.005,outputPer1k:.025,cachedInputPer1k:5e-4},"claude-opus-4-7":{inputPer1k:.005,outputPer1k:.025,cachedInputPer1k:5e-4},"claude-opus-4-8":{inputPer1k:.005,outputPer1k:.025,cachedInputPer1k:5e-4},"claude-opus-5":{inputPer1k:.005,outputPer1k:.025,cachedInputPer1k:5e-4},"claude-opus-4":{inputPer1k:.015,outputPer1k:.075,cachedInputPer1k:.0015},"claude-sonnet-4-5":{inputPer1k:.003,outputPer1k:.015,cachedInputPer1k:3e-4},"claude-sonnet-4-6":{inputPer1k:.003,outputPer1k:.015,cachedInputPer1k:3e-4},"claude-sonnet-5":{inputPer1k:.002,outputPer1k:.01,cachedInputPer1k:2e-4},"claude-fable-5":{inputPer1k:.01,outputPer1k:.05,cachedInputPer1k:.001},"claude-sonnet-4":{inputPer1k:.003,outputPer1k:.015,cachedInputPer1k:3e-4},"claude-haiku-4-5":{inputPer1k:.001,outputPer1k:.005,cachedInputPer1k:1e-4},"claude-3-5-sonnet":{inputPer1k:.003,outputPer1k:.015,cachedInputPer1k:3e-4},"claude-3-5-haiku":{inputPer1k:8e-4,outputPer1k:.004,cachedInputPer1k:8e-5},"claude-3-opus":{inputPer1k:.015,outputPer1k:.075,cachedInputPer1k:.0015},"claude-3-haiku":{inputPer1k:25e-5,outputPer1k:.00125,cachedInputPer1k:3e-5},"gemini-3.7-flash":{inputPer1k:75e-5,outputPer1k:.00375,cachedInputPer1k:75e-6},"gemini-3.5-flash":{inputPer1k:.0015,outputPer1k:.009,cachedInputPer1k:15e-5},"gemini-3.5-flash-lite":{inputPer1k:3e-4,outputPer1k:.0025,cachedInputPer1k:3e-5},"gemini-3.1-pro-preview":{inputPer1k:.002,outputPer1k:.012,cachedInputPer1k:2e-4},"gemini-2.5-pro":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gemini-2.5-flash":{inputPer1k:3e-4,outputPer1k:.0025,cachedInputPer1k:3e-5}};function ne(t,e,n){if(!n)return;let r=t[n];if(r)return r;let o=n.lastIndexOf("/"),s=o>=0?n.slice(o+1):n,i=t[s];if(i)return i;for(let l of e)if(s.startsWith(l)){let u=s.charAt(l.length);if(u===""||u==="-"||u==="."||u==="_")return t[l]}}function V(t){return Object.keys(t).sort((e,n)=>n.length-e.length)}var Ce=V(F);function E(t){return ne(F,Ce,t)}var I=class{resto="";uso=null;eventos={};observar(e){this.resto+=typeof e=="string"?e:e.toString("utf8");let n;for(;(n=this.resto.indexOf(`
3
+
4
+ `))>=0;){let r=this.resto.slice(0,n);this.resto=this.resto.slice(n+2);let o=/^event: (.+)$/m.exec(r)?.[1];if(o&&(this.eventos[o]=(this.eventos[o]??0)+1),o!=="message_start"&&o!=="message_delta")continue;let s=/^data: (.+)$/m.exec(r)?.[1];if(s)try{let i=JSON.parse(s),l=i.usage??i.message?.usage;l&&(this.uso={...this.uso,...l});}catch{}}}usage(){return this.uso}},Re=1.25;function oe(t,e){let n=E(t);if(!n)return null;let r=e.input_tokens??0,o=e.output_tokens??0,s=e.cache_read_input_tokens??0,i=e.cache_creation_input_tokens??0,l=n.cachedInputPer1k??n.inputPer1k;return {costeUsd:r/1e3*n.inputPer1k+s/1e3*l+i/1e3*n.inputPer1k*Re+o/1e3*n.outputPer1k,precio:n,tokens:{entrada:r,salida:o,cacheLeida:s,cacheEscrita:i}}}function re(t,e,n){return ie(t,e,n,false)}function se(t,e,n){return ie(t,e,n,true)}function ie(t,e,n,r){let o=n.tokens.entrada+n.tokens.cacheLeida+n.tokens.cacheEscrita;return {requestId:t,model:e,calls:r?{avoidedLlm:1,actualLlm:0}:{avoidedLlm:0,actualLlm:1},tokens:{baselineInput:o,actualInput:r?0:o,eliminatedInput:0,compressedInput:0,baselineOutput:n.tokens.salida,actualOutput:r?0:n.tokens.salida,providerCachedInput:r?0:n.tokens.cacheLeida},cache:{result:r?"singleflight":"miss"},cost:r?{baselineEstimate:n.costeUsd,actual:0,netSavings:1,basis:"estimated"}:{baselineEstimate:n.costeUsd,actual:n.costeUsd,netSavings:0,basis:"measured"}}}var Ie=5*1024*1024,Pe=["authorization","x-api-key","anthropic-version","anthropic-beta"];function ae(t,e,n,r=""){return createHash("sha256").update(t).update(`
5
+ `).update(e).update(`
6
+ `).update(r).update(`
7
+ `).update(n).digest("hex")}function ce(t){return Pe.map(e=>`${e}=${t(e)??""}`).join(`
8
+ `)}var D=class{enVuelo=new Map;primarias=0;gemelas=0;registrar(e){if(this.enVuelo.has(e))return null;this.primarias+=1;let n,r=new Promise(l=>n=l);this.enVuelo.set(e,{promesa:r,resolver:n});let o=[],s=0,i=false;return {capturar:l=>{if(!i){if(s+=l.length,s>Ie){i=true,o.length=0;return}o.push(l);}},terminar:(l,u)=>{let a=!i&&l>=200&&l<300;this.enVuelo.get(e)?.resolver(a?{status:l,headers:u,chunks:o}:null),this.enVuelo.delete(e);},abortar:()=>{this.enVuelo.get(e)?.resolver(null),this.enVuelo.delete(e);}}}async esperar(e){let n=this.enVuelo.get(e);if(!n)return null;let r=await n.promesa;return r&&(this.gemelas+=1),r}tieneEnVuelo(e){return this.enVuelo.has(e)}};var _e=["content-encoding","content-length","transfer-encoding"];async function le(t={}){let e=(t.upstream??"https://api.anthropic.com").replace(/\/+$/,""),n=t.fetchImpl??fetch,r=t.log??(a=>console.log(a)),o;if(t.serviceAccountKey){o=new w({url:t.meteringUrl??"https://savings.bivelio.com/api/metering/usage",serviceAccountKey:t.serviceAccountKey,...t.fetchImpl?{fetchImpl:t.fetchImpl}:{},onAuthError:c=>v("gateway-ingest-auth",`[bvsala gateway] la ingesta de uso rechaza la credencial (${c}). El tr\xE1fico sigue fluyendo (avisar, nunca romper), pero este uso no est\xE1 llegando al panel. Revisa BIVELIO_SERVICE_KEY y el estado de la suscripci\xF3n en savings.bivelio.com.`)});let a=A();t.seatId?o.setSeat(t.seatId,a):_(t.serviceAccountKey).then(c=>o?.setSeat(c,a)).catch(()=>{}),o.start();}else v("gateway-sin-clave","[bvsala gateway] sin BIVELIO_SERVICE_KEY: el gateway reenv\xEDa el tr\xE1fico pero NO mide. Consigue tu clave en savings.bivelio.com/dashboard/install para ver el gasto en tu panel.");let s=t.coalescer?new D:null,i=(a,c,m,f,g)=>{if(!o||!m)return;let d="";try{d=JSON.parse(c.toString("utf8")).model??"";}catch{}let p=d?oe(d,m):null;p?(o.record(f?se(a,d,p):re(a,d,p)),r(`[gateway] POST ${g} ${f?"\xB7 COALESCIDA (llamada evitada)":""} \xB7 ${d} \xB7 $${p.costeUsd.toFixed(6)} (in ${p.tokens.entrada}, cache\u2191${p.tokens.cacheEscrita} cache\u2193${p.tokens.cacheLeida}, out ${p.tokens.salida})`)):d&&v(`gateway-sin-precio:${d}`,`[bvsala gateway] el modelo "${d}" no est\xE1 en el cat\xE1logo de precios; su uso se reenv\xEDa pero no se mide.`);},l=createServer(async(a,c)=>{let m=randomUUID(),f=new AbortController;c.on("close",()=>{c.writableEnded||f.abort();});let g=null,d=new URL(a.url??"/",e);try{let p=[];for await(let h of a)p.push(h);let y=Buffer.concat(p);d=new URL(a.url??"/",e);let C=s!==null&&a.method==="POST"&&y.length>0,N=ce(h=>{let b=a.headers[h];return Array.isArray(b)?b.join(", "):b}),R=C?ae("POST",d.pathname+d.search,y,N):"";if(C)if(s.tieneEnVuelo(R)){let h=await s.esperar(R);if(h){c.writeHead(h.status,h.headers);let b=new I;for(let ee of h.chunks)c.write(ee),b.observar(ee);c.end(),i(m,y,b.usage(),!0,d.pathname);return}}else g=s.registrar(R);let X={};for(let[h,b]of Object.entries(a.headers))h==="host"||h==="content-length"||b===void 0||(X[h]=Array.isArray(b)?b.join(", "):b);let k;try{k=await n(d,{method:a.method,headers:X,body:["GET","HEAD"].includes(a.method??"")?void 0:y,redirect:"manual",signal:f.signal});}catch(h){g?.abortar(),c.headersSent||(c.writeHead(502,{"content-type":"application/json"}),c.end(JSON.stringify({error:"bvsala gateway: upstream unreachable",detail:String(h)}))),r(`[gateway] ${a.method} ${d.pathname} \u2192 upstream inalcanzable: ${String(h)}`);return}let z=Object.fromEntries(k.headers.entries());for(let h of _e)delete z[h];c.writeHead(k.status,z);let Z=(k.headers.get("content-type")??"").includes("text/event-stream"),B=Z?new I:null;if(k.body)for await(let h of k.body){let b=Buffer.from(h);c.write(b),B?.observar(b),g?.capturar(b);}c.end(),g?.terminar(k.status,z),B&&k.ok?i(m,y,B.usage(),!1,d.pathname):Z||r(`[gateway] ${a.method} ${d.pathname} \u2192 ${k.status}`);}catch(p){if(g?.abortar(),f.signal.aborted)r(`[gateway] ${a.method} ${d.pathname} \u2192 el cliente abort\xF3; upstream cancelado`);else if(r(`[gateway] ${a.method} ${d.pathname} \u2192 error sirviendo: ${String(p)}`),!c.headersSent){c.writeHead(502,{"content-type":"application/json"}),c.end(JSON.stringify({error:"bvsala gateway: stream interrupted",detail:String(p)}));return}c.writableEnded||c.end();}});return await new Promise((a,c)=>{l.once("error",c),l.listen(t.puerto??8402,"127.0.0.1",()=>a());}),{puerto:l.address().port,cerrar:async()=>{await o?.stopAndDrain(),await new Promise(a=>l.close(()=>a()));}}}var U=class{id;exact=false;constructor(e="heuristic"){this.id=e;}count(e){if(e.length===0)return 0;let n=e.match(/[A-Za-z]+|\d+|\s+|[^\sA-Za-z\d]/g)??[],r=0;for(let o of n){let s=o[0];if(/\s/.test(s)){let i=(o.match(/\n/g)??[]).length;r+=i+Math.floor(Math.max(0,o.length-1)/2);}else /[A-Za-z]/.test(s)?r+=Math.max(1,Math.ceil(o.length/4)):/\d/.test(s)?r+=Math.max(1,Math.ceil(o.length/2)):r+=1;}return Math.max(r,Math.ceil(e.length/4))}},q=new U;var K=class{estimators=new Map;modelToTokenizer=new Map;fallbackId;constructor(e=q){this.fallbackId=e.id,this.estimators.set(e.id,e);}register(e){return this.estimators.set(e.id,e),this}mapModel(e,n){return this.modelToTokenizer.set(e,n),this}resolve(e){let n=e.tokenizer?this.estimators.get(e.tokenizer):void 0;if(n)return n;if(e.model){let r=this.modelToTokenizer.get(e.model);if(r){let o=this.estimators.get(r);if(o)return o}}return this.estimators.get(this.fallbackId)}count(e,n){return this.resolve(n).count(e)}};new K;new TextEncoder;new TextDecoder;new TextDecoder;var We={"gpt-4o-mini":{deltaTokens:-16,salidaDelBrazoA:72,contrato:"triage (6 fields, long enums)",pares:8},"gpt-4o":{deltaTokens:3,salidaDelBrazoA:75,contrato:"triage (6 fields, long enums)",pares:8}};V(We);var G="0.4.2";var[,,x,...j]=process.argv;function Ye(){console.log(`bvsala ${G} \u2014 BiVelio Savings Layer
9
+
10
+ Comandos:
11
+ gateway [--puerto N] Proxy local medidor para Claude Code (ANTHROPIC_BASE_URL).
12
+ claude [args\u2026] Lanza Claude Code a trav\xE9s del gateway, sin configurar nada.
13
+
14
+ Entorno:
15
+ BIVELIO_SERVICE_KEY Clave de servicio (bvk_\u2026): habilita metering + asiento.
16
+ BVSALA_SEAT_ID Identidad de asiento estable por r\xE9plica (opcional).
17
+ BVSALA_GATEWAY_UPSTREAM Upstream alternativo (por defecto https://api.anthropic.com).
18
+ BVSALA_GATEWAY_COALESCE =1 enciende la coalescencia de duplicados (F2, experimental).
19
+
20
+ El gateway nunca corta el tr\xE1fico: sin clave o con la ingesta ca\xEDda, reenv\xEDa y avisa.
21
+ Tus prompts jam\xE1s salen de tu m\xE1quina \u2014 solo cuentas y coste. M\xE1s: savings.bivelio.com`);}x!=="gateway"&&x!=="claude"&&(Ye(),process.exit(x===void 0||x==="help"||x==="--help"?0:1));var Y=j.indexOf("--puerto"),$=x==="claude"?0:Y>=0?Number(j[Y+1]):8402;(!Number.isInteger($)||$<0||$>65535)&&(console.error(`[bvsala gateway] puerto inv\xE1lido: ${j[Y+1]}`),process.exit(1));var Q=process.env.BIVELIO_SERVICE_KEY,T=await le({puerto:$,...Q?{serviceAccountKey:Q}:{},...process.env.BVSALA_SEAT_ID?{seatId:process.env.BVSALA_SEAT_ID}:{},...process.env.BVSALA_GATEWAY_UPSTREAM?{upstream:process.env.BVSALA_GATEWAY_UPSTREAM}:{},...process.env.BVSALA_GATEWAY_COALESCE==="1"?{coalescer:true}:{},...process.env.BVSALA_METERING_URL?{meteringUrl:process.env.BVSALA_METERING_URL}:{}});console.log(`[bvsala gateway] v${G} escuchando en http://127.0.0.1:${T.puerto}`);console.log(`[bvsala gateway] medici\xF3n: ${Q?"ACTIVA (clave de servicio presente)":"APAGADA (falta BIVELIO_SERVICE_KEY)"}`);if(x==="claude"){let t=spawn("claude",j,{stdio:"inherit",env:{...process.env,ANTHROPIC_BASE_URL:`http://127.0.0.1:${T.puerto}`}});t.on("error",e=>{console.error(`[bvsala claude] no se pudo lanzar \`claude\`: ${String(e)}. \xBFEst\xE1 instalado y en el PATH?`),T.cerrar().then(()=>process.exit(127));}),t.on("exit",(e,n)=>{T.cerrar().then(()=>process.exit(e??(n?1:0)));});}else console.log(`[bvsala gateway] apunta tu herramienta: ANTHROPIC_BASE_URL=http://127.0.0.1:${T.puerto} claude`);var ge=async()=>{await T.cerrar(),process.exit(0);};process.on("SIGINT",()=>{ge();});process.on("SIGTERM",()=>{ge();});
package/dist/index.d.ts CHANGED
@@ -158,7 +158,7 @@ declare class SavingsLedger {
158
158
  * catalogue stays out of the way.
159
159
  */
160
160
  /** When these prices were last checked against the providers' public rates. */
161
- declare const PRECIOS_AL_DIA = "2026-08-13";
161
+ declare const PRECIOS_AL_DIA = "2026-08-24";
162
162
  /**
163
163
  * Key = the model id as the provider publishes it, without a vendor prefix.
164
164
  * Lookup tries the exact id, then the id without its provider prefix, then the
@@ -2759,7 +2759,7 @@ declare class UsageEmitter {
2759
2759
  private seatId;
2760
2760
  private ci;
2761
2761
  private timer;
2762
- private flushing;
2762
+ private inflight;
2763
2763
  /**
2764
2764
  * CONSECUTIVE failed flushes. Warn on the third and then every twelfth: one
2765
2765
  * lost batch is normal, a whole hour is not. Same cadence as the revocation
@@ -2774,14 +2774,22 @@ declare class UsageEmitter {
2774
2774
  /** Record one request's usage. Never blocks; O(1). */
2775
2775
  record(report: SavingsReport): void;
2776
2776
  start(): void;
2777
- /** Stop the timer and attempt a final flush. Safe to call more than once. */
2777
+ /** Stop the timer and attempt a final flush. Safe to call more than once.
2778
+ * Fire-and-forget: for a CLEAN shutdown that must not lose the last batch,
2779
+ * use `stopAndDrain` and await it. */
2778
2780
  stop(): void;
2781
+ /** Clean shutdown: stop the timer and DRAIN the buffer, awaiting each POST.
2782
+ * Unlike `stop()` (fire-and-forget), this resolves only once the final batch
2783
+ * has actually been sent, so a graceful exit doesn't lose the last session's
2784
+ * usage. Bounded, so an unreachable control plane can't block shutdown. */
2785
+ stopAndDrain(maxIntentos?: number): Promise<void>;
2779
2786
  /**
2780
2787
  * Flush one batch. Never throws. A transient server error (5xx) or network
2781
2788
  * failure re-queues the batch (capped); a 4xx (bad request / bad key) drops it,
2782
2789
  * since retrying a rejected payload would loop forever.
2783
2790
  */
2784
2791
  flush(): Promise<void>;
2792
+ private hacerFlush;
2785
2793
  /**
2786
2794
  * THE INGEST HAS BEEN FAILING FOR A WHILE AND NOBODY NOTICED.
2787
2795
  *
@@ -3193,6 +3201,6 @@ declare function createSavingsLayer(config: Omit<SavingsLayerConfig, "tokenizer"
3193
3201
  * constant to package.json so the two can never drift.
3194
3202
  */
3195
3203
 
3196
- declare const BV_SALA_VERSION = "0.4.0";
3204
+ declare const BV_SALA_VERSION = "0.4.2";
3197
3205
 
3198
3206
  export { type AnthropicOptions, AnthropicProvider, BV_SALA_VERSION, type BivelioHandoff, CATALOGO, COMPACT_ROWS_INSTRUCTION, type CacheOutcome, type CacheTier, type CachedAnswer, type CanonicalRequest, type ChatMessage, type CompactRowsDecodeOptions, type CompactRowsOptions, type CompactVerification, type CompressionEconomics, type CompressionPolicyInput, type CompressionRequest, type CompressionResult, type Compressor, type ConstraintBackend, type ConstraintBackendId, type ConstraintPlan, type ConversationState, type CostBasis, type CostModel, DEFAULT_COST_MODEL, type DeferredExecutionReport, type DeterministicResolver, EMBEDDED_PUBLIC_KEY, type EmbeddingFn, type EvidenceDoc, type ExactCache, ExactTokenizer, type ExecutionClass, ExecutionRouter, type FormatChoice, FormatSelector, type FormatSelectorOptions, type HandoffFact, HeuristicEstimator, InMemoryExactCache, InMemorySemanticCache, type JsonSchema, LicenseError, type LicenseGate, type LicenseKeyset, type LicenseStatus, type LicenseTokenPayload, type LocalClassifier, MODELOS_POR_CODIFICACION, MODELS_BY_ENCODING, type MemoryDelta, type MessageRole, MockProvider, type MockProviderOptions, type ModelCascadeMode, type ModelCascadeReport, type ModelCascadeSettings, type OpenAICompatibleOptions, OpenAICompatibleProvider, type OutputContract, PRECIOS_AL_DIA, type Policy, type ProtectedSpan, type Provider, type ProviderRequest, type ProviderResponse, type ProviderUsage, QUALITY_STATUSES, type QualityStatus, type RequestContext, type Reranker, type ResolvedDeps, type ResolvedGate, type ResolvedLicense, ResolverRegistry, type ResolverResult, type RetrievalOptions, type RetrievalOutcome, type RetrievalPlan, type Retriever, type RevocationListBody, type RevocationListVerification, type RevocationPollEvent, RevocationPoller, type RevocationPollerOptions, RevocationSnapshot, type RouteDecision, type SavingsLayer, type SavingsLayerConfig, SavingsLedger, type SavingsReport, type SavingsRequest, type SavingsResult, type SavingsToggles, SchemaRegistry, type SemanticCacheOptions, type SemanticHit, type SemanticRecord, type SemanticVerifier, type SerializationFormat, type Serializer, Singleflight, type TokenEstimator, TokenizerRegistry, type ToolCallRequest, type ToolDescriptor, type ToolRisk, type ToolSelection, ToolSelector, type ToolSelectorOptions, TraceCollector, type TraceEvent, UMBRAL_LOCAL, UsageEmitter, type UsageEmitterOptions, type ValidationError, type ValidationResult, adaptiveK, arithmeticResolver, buildExactKey, buildInflightKey, canonicalize, compactRowsSerializer, computeNextDelay, cosine, createCompactRowsSerializer, createDefaultResolvers, createHandoff, createMockProvider, createSavingsLayer, csvSerializer, decodeCompactRows, dedupe, defaultTokenizerRegistry, deriveSeatId, deriveStatus, detectCi, embebedorLocal, emptyState, encodeCompactRows, evalArithmetic, evaluatePolicy, executeRetrievalPlan, gateFromKey, heuristicEstimator, isKeyset, isStrictCompatible, jsonCompactSerializer, keyTokensMatch, liveGateFromKey, mergeDelta, modelFamilyOf, noopCompressor, normalizeText, precioDeLista, projectContext, providerNativeBackend, randomId, registerExactTokenizer, registrarTokenizadorExacto, renderState, resolveConstraintBackend, resolveDependencies, resolveLicense, sha256, shouldCompress, stableStringify, uniformColumns, validateAgainstSchema, vectorLocal, verifyCompactSignature, verifyLicenseToken, verifyRevocationList, withProtectedSpans };