@bivelio/savings-layer 0.4.25 → 0.4.28
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 +328 -23
- package/dist/adapters/ai-sdk.js +73 -73
- package/dist/adapters/anthropic-sdk.js +69 -67
- package/dist/adapters/langchain.js +55 -52
- package/dist/adapters/mastra.js +65 -65
- package/dist/gateway/bin.js +906 -623
- package/dist/index.d.ts +928 -11
- package/dist/index.js +1398 -1004
- package/docs/terminales/README.md +59 -11
- package/docs/terminales/aider.md +74 -0
- package/docs/terminales/continue.md +47 -0
- package/docs/terminales/copilot.md +114 -0
- package/docs/terminales/kilo.md +54 -31
- package/docs/terminales/no-soportados.md +41 -0
- package/docs/terminales/opencode.md +33 -11
- package/package.json +24 -3
package/README.md
CHANGED
|
@@ -74,12 +74,14 @@ products charge a flat subscription, so there is no per‑token bill to cut. The
|
|
|
74
74
|
story, in plain words:
|
|
75
75
|
**[Where does the saving install?](https://savings.bivelio.com/en/how-it-works)**
|
|
76
76
|
|
|
77
|
-
**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
up in your dashboard, token by token — nothing to configure, nothing
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
83
85
|
fail‑closed: it starts only with your service key (`BIVELIO_SERVICE_KEY`) and an
|
|
84
86
|
active BiVelio license, which it collects with that key and verifies against the
|
|
85
87
|
key embedded in the package. Once running it never interrupts a request in flight:
|
|
@@ -94,7 +96,8 @@ as for any other use.
|
|
|
94
96
|
|
|
95
97
|
**The gateway forwards what your terminal sends.** With no lever armed, your
|
|
96
98
|
provider receives the request body byte for byte and every header your client
|
|
97
|
-
sends
|
|
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
|
|
98
101
|
`tool_reference` blocks, the advisor, every `anthropic-beta` value. An armed lever
|
|
99
102
|
changes only what it declares, and a request it cannot rewrite exactly travels
|
|
100
103
|
untouched. A test suite checks this against a fake upstream on every change
|
|
@@ -140,6 +143,17 @@ is measurement, not savings: the saving levers of the SDK are TypeScript, and th
|
|
|
140
143
|
gateway's own levers apply to Anthropic `/v1/messages` traffic with a PRO license.
|
|
141
144
|
`bvsala doctor` prints the recipe for each SDK.
|
|
142
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
|
+
|
|
143
157
|
## Check it before you install
|
|
144
158
|
|
|
145
159
|
What you can check before paying is what we have measured: the published benchmark,
|
|
@@ -149,6 +163,22 @@ and the commission calculator on the front page, which runs on the same price
|
|
|
149
163
|
catalogue that bills you. The verifier that replays the optimizer on your own data
|
|
150
164
|
lives in your dashboard once you sign in.
|
|
151
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
|
+
|
|
152
182
|
## Install in three steps
|
|
153
183
|
|
|
154
184
|
**1 · The package**
|
|
@@ -226,6 +256,19 @@ exists, as for Anthropic models), and `"unmeasured"` when the provider returned
|
|
|
226
256
|
usage. Only `measured` and `reference` figures reach your invoice; estimated ones
|
|
227
257
|
never do.
|
|
228
258
|
|
|
259
|
+
A repeat is valued by reference only when the call that ran reached the provider's
|
|
260
|
+
own API (`https://api.openai.com`, `https://api.anthropic.com` or
|
|
261
|
+
`https://generativelanguage.googleapis.com`), because the list price it is valued at
|
|
262
|
+
is the invoice only there. When `OpenAICompatibleProvider` or `AnthropicProvider`
|
|
263
|
+
points anywhere else — a proxy such as LiteLLM, a gateway, Azure, Bedrock, Vertex —
|
|
264
|
+
or when the Anthropic SDK or LangChain adapter reads that its client does, that call
|
|
265
|
+
leaves no reference: its repeats stay `estimated`, and the `reference` trace of the
|
|
266
|
+
call that ran says `unofficial-origin`. When the layer cannot tell where the call
|
|
267
|
+
went (a provider of your own, the AI SDK and Mastra adapters, a LangChain model that
|
|
268
|
+
does not expose its base URL), the rule applies as it always has. The layer reads the
|
|
269
|
+
configured base URL, not the wire: a custom `fetch` that sends the request elsewhere
|
|
270
|
+
is not seen.
|
|
271
|
+
|
|
229
272
|
**Streaming.** `generateStream()` takes the same request and hands you the answer as
|
|
230
273
|
the provider writes it, then the same `SavingsReport`:
|
|
231
274
|
|
|
@@ -332,8 +375,10 @@ shared here. `emitReportPart: true` also streams each report as a
|
|
|
332
375
|
|
|
333
376
|
### LangChain.js
|
|
334
377
|
|
|
335
|
-
`pnpm add @bivelio/savings-layer langchain @langchain/core` — an agent
|
|
336
|
-
`createAgent` in `langchain` 1.x
|
|
378
|
+
`pnpm add @bivelio/savings-layer langchain @langchain/core @langchain/openai` — an agent
|
|
379
|
+
middleware for `createAgent` in `langchain` 1.x. The model string (`"openai:…"`) is loaded
|
|
380
|
+
through its integration package, so `@langchain/openai` (or `@langchain/anthropic`) has
|
|
381
|
+
to be installed:
|
|
337
382
|
|
|
338
383
|
```ts
|
|
339
384
|
import { createAgent } from "langchain";
|
|
@@ -402,6 +447,19 @@ single-shot requests only: requests that declare tools, carry tool calls or resu
|
|
|
402
447
|
hold more than one user turn are left out unless you set
|
|
403
448
|
`savings.semanticCache: "include-agentic"`.
|
|
404
449
|
|
|
450
|
+
**Calibrated reuse (opt-in).** A fixed similarity floor means something different for
|
|
451
|
+
every embedder and every workload, so you can make the cache earn its floor on your own
|
|
452
|
+
traffic instead: `new InMemorySemanticCache({ embed, calibration: { maxErrorRate: 0.02 } })`.
|
|
453
|
+
It then serves no reworded answer until your labels show, at 95 % confidence, that at
|
|
454
|
+
most 2 % of the answers it would reuse from some score up are wrong, and it re-tests that
|
|
455
|
+
floor as labels accumulate. Until then every candidate goes to your provider, exactly as
|
|
456
|
+
it would without the cache, and the fresh answer is compared with the stored one by your
|
|
457
|
+
`judge` (by default only identical answers count, which suits classification and
|
|
458
|
+
structured output; pass your own for free text). Each calibrated reuse carries its bound
|
|
459
|
+
and the labels behind it in `report.qualityLedger.cacheMatch.calibration`. Calibration
|
|
460
|
+
costs reuses, never an extra call, and it does not change how anything is billed. If you
|
|
461
|
+
already have labeled pairs, `calibrateSemanticFloor()` runs the same test offline.
|
|
462
|
+
|
|
405
463
|
And a promise you can hold it to: **never worse than your JSON.** Payload encodings
|
|
406
464
|
are chosen by counting real tokens with the destination model's own tokenizer, and
|
|
407
465
|
BV‑SALA only moves away from plain JSON when the saving clearly clears the bar —
|
|
@@ -465,8 +523,8 @@ changes the answer, and what they are worth is reported next to the four ledgers
|
|
|
465
523
|
never folded into `cost.*`, never part of any commission.
|
|
466
524
|
|
|
467
525
|
**`savings.deferrable`** asks for the provider's Flex tier: OpenAI, and Gemini through
|
|
468
|
-
its OpenAI‑compatible endpoint. Anthropic has no cheaper synchronous tier
|
|
469
|
-
discount lives only in Message Batches, which
|
|
526
|
+
its OpenAI‑compatible endpoint. Anthropic has no cheaper synchronous tier: its
|
|
527
|
+
discount lives only in Message Batches, which `layer.batches` (below) sends to.
|
|
470
528
|
Bedrock and DeepSeek off‑peak pricing are not supported.
|
|
471
529
|
|
|
472
530
|
- **Latency.** OpenAI publishes no latency commitment for Flex; Google publishes a
|
|
@@ -514,6 +572,195 @@ report.deferred?.tierSaving; // { amount, basis, standardCost, appliedCost, pric
|
|
|
514
572
|
report.cacheWrites; // { mode, breakpoints, avoidedWritePremium?, reason }
|
|
515
573
|
```
|
|
516
574
|
|
|
575
|
+
**`layer.batches`** sends requests that can wait hours to the provider's own batch API,
|
|
576
|
+
billed at its published batch rate card: Anthropic's Message Batches through
|
|
577
|
+
`AnthropicProvider`, and OpenAI's Batch API through `OpenAICompatibleProvider` on
|
|
578
|
+
`api.openai.com`. Gemini's batch mode is not supported yet.
|
|
579
|
+
|
|
580
|
+
- **What runs.** Each request is translated by the same provider adapter as
|
|
581
|
+
`generate()` and sent as you wrote it: the layer's own per‑request savings (caching,
|
|
582
|
+
compression, model cascade, retrieval) do not run on batches. The layer splits the
|
|
583
|
+
job at the provider's limits (and, on OpenAI, one model per file).
|
|
584
|
+
- **Latency.** The provider's: results arrive within its batch window, not on a
|
|
585
|
+
request/response path. `submit()` returns a plain‑JSON job you can store and read
|
|
586
|
+
from another process.
|
|
587
|
+
- **What it is worth.** Each result's usage counter at the standard and at the batch
|
|
588
|
+
published rate cards, component by component, with the same `measured` /
|
|
589
|
+
`estimated` rule as above. The tier is read from the provider: Anthropic reports it
|
|
590
|
+
on every result; on OpenAI it is the batch the result came from. Nothing is
|
|
591
|
+
metered and the batch saving is never billed.
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
const tickets = [{ id: "t-1", text: "My invoice is wrong." }]; // your deferrable work
|
|
595
|
+
|
|
596
|
+
const job = await layer.batches.submit(
|
|
597
|
+
tickets.map((t) => ({
|
|
598
|
+
customId: t.id,
|
|
599
|
+
request: { model: "openai/gpt-4o-mini", messages: [{ role: "user", content: t.text }] },
|
|
600
|
+
})),
|
|
601
|
+
);
|
|
602
|
+
// …later, even from another process (store `job` as JSON):
|
|
603
|
+
const results = await layer.batches.results(job);
|
|
604
|
+
results.ended; // false while the provider is still working
|
|
605
|
+
results.items; // one per request: { customId, outcome, text, usage, tierSaving?, reason }
|
|
606
|
+
results.summary; // { succeeded, errored, expired, credited, tierSaving, basis, pricesCheckedOn, reason, … }
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
</details>
|
|
610
|
+
|
|
611
|
+
<details>
|
|
612
|
+
<summary><b>Tool outputs, compacted where they are born (opt-in, off by default)</b></summary>
|
|
613
|
+
|
|
614
|
+
In an agent loop your history is re-sent on every turn and your provider reads it
|
|
615
|
+
from its prompt cache at a fraction of the input price. Rewriting anything it has
|
|
616
|
+
already cached turns everything after it back into full-price cache writes, which is
|
|
617
|
+
how "fewer tokens" ends up as a bigger bill. So this lever only ever changes a tool
|
|
618
|
+
output on the turn it arrives, and re-sends exactly the same bytes afterwards.
|
|
619
|
+
|
|
620
|
+
It removes terminal colour codes, superseded progress-bar states, whitespace between
|
|
621
|
+
JSON tokens and runs of identical lines (folded into one line plus a count), and
|
|
622
|
+
nothing else: no summaries, no dropped words. Each change either reverses exactly or
|
|
623
|
+
leaves what a terminal would display. Outputs of calls that name a file are left
|
|
624
|
+
verbatim, so an agent that edits by exact match still sees the file as it is.
|
|
625
|
+
|
|
626
|
+
```ts
|
|
627
|
+
const agentLayer = createSavingsLayer({
|
|
628
|
+
provider,
|
|
629
|
+
licenseKey: process.env.BIVELIO_LICENSE_KEY,
|
|
630
|
+
toolOutputCompaction: true, // or { exceptTools: ["read_logs"], minSavedChars: 512 }
|
|
631
|
+
});
|
|
632
|
+
// or per request: savings: { toolOutputCompaction: true }
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
- **Switch it on for whole conversations.** The decision depends only on each
|
|
636
|
+
output's own text, so every process and every turn produces the same bytes. A
|
|
637
|
+
conversation that started without it is rewritten once, on its first compacted
|
|
638
|
+
request.
|
|
639
|
+
- **Counted, not billed.** What it removes shows in `report.tokens.compressedInput`
|
|
640
|
+
and in `report.traces`; it never enters `cost.*` or any commission. Your provider's
|
|
641
|
+
invoice shows the effect.
|
|
642
|
+
- **Inside a framework,** call `compactToolOutput(text)` in your tool before you
|
|
643
|
+
return its result: your history then holds the compacted text itself.
|
|
644
|
+
- **Measure it by conversation, not by request.** A verification copy re-sends one
|
|
645
|
+
request with the original history, which your provider has not cached, so on
|
|
646
|
+
turns that re-send compacted outputs the copy costs more than going without the
|
|
647
|
+
layer would have. Compare whole conversations with and without it.
|
|
648
|
+
|
|
649
|
+
</details>
|
|
650
|
+
|
|
651
|
+
<details>
|
|
652
|
+
<summary><b>Prompt-cache breakpoints placed from your traffic (opt-in)</b></summary>
|
|
653
|
+
|
|
654
|
+
By default the layer writes the provider's prompt cache at two fixed places: the end
|
|
655
|
+
of the system header and the end of the history before your last user message, and
|
|
656
|
+
stops marking a prefix whose measured hit rate does not pay the write premium.
|
|
657
|
+
|
|
658
|
+
`cacheBreakpoints: "auto"` places them from what your traffic actually does. The layer
|
|
659
|
+
compares each request's prefix with the ones it sent before, per model, tool set and
|
|
660
|
+
system header, and learns how often each part is sent again and after how long. That
|
|
661
|
+
costs nothing: it compares locally and marks nothing to find out. Once the evidence
|
|
662
|
+
is conclusive:
|
|
663
|
+
|
|
664
|
+
- **Tail.** When requests continue each other (agent tool loops, conversations), it
|
|
665
|
+
also marks the last block, so the next turn reads the whole prompt from cache.
|
|
666
|
+
Tool results arrive after your last user message, so the fixed placement never
|
|
667
|
+
caches them.
|
|
668
|
+
- **History.** When the history before the question is not sent again (one-off
|
|
669
|
+
requests with their own document), it moves the breakpoint back to the system
|
|
670
|
+
header, so no write premium is paid on a prefix nobody reads.
|
|
671
|
+
- **One-hour lifetime** (Anthropic). When turns come back after more than five
|
|
672
|
+
minutes but within the hour, and the size of the request makes the 2x write pay.
|
|
673
|
+
- **Explicit mode** (GPT‑5.6 and later, on OpenAI's own API). When continuations are
|
|
674
|
+
rare, your question is not written to the cache. `savings.standalone` still wins
|
|
675
|
+
when you set it, and `report.cacheWrites` says when the layer chose it.
|
|
676
|
+
|
|
677
|
+
Until then it behaves exactly like the default. Every decision and its reason is in
|
|
678
|
+
`report.traces` (`prefix-cache-placement`), and the effect is read from the provider's
|
|
679
|
+
own counters: if they do not confirm the cache reads the layer predicted, that
|
|
680
|
+
traffic goes back to the default placement. The write premium it causes, one-hour
|
|
681
|
+
writes at 2x included, is charged to `cost.actual`; the cache discount it earns is
|
|
682
|
+
counted in ledger 4 like any provider cache read, never credited as the layer's
|
|
683
|
+
saving and never part of any commission.
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
const placedLayer = createSavingsLayer({
|
|
687
|
+
provider, // Anthropic, or OpenAI GPT-5.6+ on OpenAI's own API
|
|
688
|
+
licenseKey: process.env.BIVELIO_LICENSE_KEY,
|
|
689
|
+
cacheBreakpoints: "auto",
|
|
690
|
+
});
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
</details>
|
|
694
|
+
|
|
695
|
+
<details>
|
|
696
|
+
<summary><b>Serverless: keep what the layer learns between instances</b></summary>
|
|
697
|
+
|
|
698
|
+
Calibrated reuse and `cacheBreakpoints: "auto"` learn from your traffic, and by default
|
|
699
|
+
what they learn lives in the process. On serverless platforms (Vercel Functions, AWS
|
|
700
|
+
Lambda) an instance may serve one request or live a few minutes, so neither would ever
|
|
701
|
+
get past its starting point. Give each one somewhere to keep it:
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
import { InMemorySemanticCache } from "@bivelio/savings-layer";
|
|
705
|
+
|
|
706
|
+
// Any key-value client your instances share, such as Redis.
|
|
707
|
+
type SharedKeyValue = {
|
|
708
|
+
get(key: string): Promise<unknown>;
|
|
709
|
+
set(key: string, value: string, options?: { px: number }): Promise<unknown>;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
async function serverlessLayer(kv: SharedKeyValue) {
|
|
713
|
+
const semanticCache = new InMemorySemanticCache({ calibration: { maxErrorRate: 0.02 } });
|
|
714
|
+
const saved = await kv.get("bvsala:semantic-calibration");
|
|
715
|
+
if (saved) semanticCache.importCalibration(saved); // the object or its JSON text
|
|
716
|
+
|
|
717
|
+
const layer = createSavingsLayer({
|
|
718
|
+
provider,
|
|
719
|
+
licenseKey: process.env.BIVELIO_LICENSE_KEY,
|
|
720
|
+
semanticCache,
|
|
721
|
+
cacheBreakpoints: "auto",
|
|
722
|
+
cacheBreakpointStore: {
|
|
723
|
+
get: (key) => kv.get(key),
|
|
724
|
+
set: (key, value, ttlMs) => kv.set(key, value, { px: ttlMs }),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// After handling requests with `layer`, save the calibration:
|
|
729
|
+
const save = async () => {
|
|
730
|
+
await semanticCache.settled();
|
|
731
|
+
await kv.set("bvsala:semantic-calibration", JSON.stringify(semanticCache.exportCalibration()));
|
|
732
|
+
};
|
|
733
|
+
return { layer, save };
|
|
734
|
+
}
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
- **Breakpoint placement.** The store holds prompt-prefix fingerprints, timestamps and
|
|
738
|
+
counts, one key per traffic class, never prompt text. The layer reads it before
|
|
739
|
+
placing breakpoints and reads and rewrites it after the provider answers, so every
|
|
740
|
+
instance extends the same measurement. If the store fails, that request is placed as
|
|
741
|
+
a fresh instance would place it and nothing is written over what others learned.
|
|
742
|
+
Without a store nothing changes.
|
|
743
|
+
- **Calibration.** The saved state is the whole experiment: both halves of the labels,
|
|
744
|
+
how many tests have already drawn on the confidence budget, and the floor in force.
|
|
745
|
+
Restoring it continues the same calibration, so the bound keeps holding across
|
|
746
|
+
restarts; it never starts the budget over. A state recorded under another embedder,
|
|
747
|
+
verifier, judge or configuration is refused with `CalibrationStateError`. If you pass
|
|
748
|
+
your own `embed`, `verify` or `judge`, name them (`embedderId`, `verifierId`,
|
|
749
|
+
`calibration.judgeId`) so a state can say what it was recorded with.
|
|
750
|
+
- **One line of history.** The bound holds along one line: restore the latest state and
|
|
751
|
+
save from where it went. Instances that continue the same state at the same time
|
|
752
|
+
each spend its remaining confidence again, and the last one to save overwrites the
|
|
753
|
+
others' labels. For one bound across many concurrent instances, calibrate in a single
|
|
754
|
+
long-lived process, or offline with `calibrateSemanticFloor()`, and give every
|
|
755
|
+
instance the certified floor as `acceptThreshold`. The cached answers themselves
|
|
756
|
+
still live in each instance's memory.
|
|
757
|
+
- **Why a predicted cache read did not happen** (Anthropic's own API). With
|
|
758
|
+
`cacheMissDiagnostics: true`, the layer asks Anthropic to compare each marked request
|
|
759
|
+
with the one whose cache entry it expected to read, and puts the provider's
|
|
760
|
+
`cache_miss_reason` in `report.traces` when that read does not arrive. Anthropic
|
|
761
|
+
keeps a short-lived fingerprint (hashes and token-count estimates) of each request
|
|
762
|
+
that opts in; check that against your data-retention terms first.
|
|
763
|
+
|
|
517
764
|
</details>
|
|
518
765
|
|
|
519
766
|
<details>
|
|
@@ -547,10 +794,11 @@ console.log(data.answer); // 8
|
|
|
547
794
|
|
|
548
795
|
When you pass `tools` and the model asks to invoke one, the reply is not the final
|
|
549
796
|
answer: `text` is empty by design and the request is in `toolCalls`. Execute it,
|
|
550
|
-
append the
|
|
797
|
+
append the assistant turn and one `role: "tool"` message per call to `messages`, and
|
|
798
|
+
call `generate()` again.
|
|
551
799
|
|
|
552
800
|
```ts
|
|
553
|
-
import type { ToolDescriptor } from "@bivelio/savings-layer";
|
|
801
|
+
import type { ChatMessage, ToolDescriptor } from "@bivelio/savings-layer";
|
|
554
802
|
|
|
555
803
|
const tools: ToolDescriptor[] = [
|
|
556
804
|
{
|
|
@@ -567,26 +815,83 @@ const tools: ToolDescriptor[] = [
|
|
|
567
815
|
},
|
|
568
816
|
];
|
|
569
817
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
messages: [{ role: "user", content: "What's the weather in Madrid?" }],
|
|
573
|
-
tools,
|
|
574
|
-
});
|
|
818
|
+
// ← your tool
|
|
819
|
+
const getWeather = async (city: string): Promise<string> => `${city}: 21 °C, clear`;
|
|
575
820
|
|
|
576
|
-
|
|
577
|
-
|
|
821
|
+
const messages: ChatMessage[] = [{ role: "user", content: "What's the weather in Madrid?" }];
|
|
822
|
+
let res = await layer.generate({ model: "openai/gpt-4o-mini", messages, tools });
|
|
823
|
+
|
|
824
|
+
while (res.toolCalls?.length) {
|
|
825
|
+
// res.finishReason === "tool_calls". Send the calls back as they came, ids included.
|
|
826
|
+
messages.push({ role: "assistant", content: res.text, toolCalls: res.toolCalls });
|
|
578
827
|
for (const call of res.toolCalls) {
|
|
579
828
|
const args = JSON.parse(call.arguments) as { city: string };
|
|
580
|
-
|
|
829
|
+
const output = await getWeather(args.city);
|
|
830
|
+
messages.push({ role: "tool", toolCallId: call.id, name: call.name, content: output });
|
|
581
831
|
}
|
|
582
|
-
|
|
583
|
-
console.log(res.text);
|
|
832
|
+
res = await layer.generate({ model: "openai/gpt-4o-mini", messages, tools });
|
|
584
833
|
}
|
|
834
|
+
console.log(res.text);
|
|
585
835
|
```
|
|
586
836
|
|
|
837
|
+
- **Ids.** Each result carries the `toolCallId` of the call it answers, and every call
|
|
838
|
+
of a turn gets its result before the next user or assistant message. On Anthropic
|
|
839
|
+
that is what lets the turn travel as `tool_use`/`tool_result` blocks (with the tool
|
|
840
|
+
still in that request's `tools`); otherwise it is sent as plain text, as before, and
|
|
841
|
+
the SDK warns once.
|
|
842
|
+
- **Errors.** When a tool fails, put the error in `content` and set `isError: true`.
|
|
843
|
+
Anthropic receives it as `is_error`; providers without such a flag get the text.
|
|
844
|
+
|
|
587
845
|
A tool‑call turn is never served from cache and never counted as an avoided call:
|
|
588
846
|
an action that has not run yet cannot be a saving.
|
|
589
847
|
|
|
848
|
+
**Large catalogues: let the provider search them (opt‑in).** With hundreds of tools
|
|
849
|
+
(several MCP servers, say), `savings: { toolSearch: "auto" }` sends every tool deferred
|
|
850
|
+
behind the provider's own tool search instead of letting the layer's selector keep a
|
|
851
|
+
few. A catalogue that declares no `intents` or `risk` gives the selector nothing to go
|
|
852
|
+
on, so it keeps the first ones and the model cannot call the rest; with the provider's
|
|
853
|
+
search the model looks up what it needs, no tool you declared is out of reach, and the
|
|
854
|
+
cached prompt prefix stays the same from one question to the next.
|
|
855
|
+
|
|
856
|
+
- **Where.** The Anthropic adapter, on models in Anthropic's tool search compatibility
|
|
857
|
+
table. OpenAI documents its tool search for the Responses and Agents APIs, not for
|
|
858
|
+
Chat Completions, which is what this SDK's OpenAI adapter speaks, so OpenAI requests
|
|
859
|
+
keep the selector.
|
|
860
|
+
- **When.** `"auto"` applies it when the adapter talks to Anthropic's own API, the
|
|
861
|
+
catalogue is over the provider's documented size, and deferring it pays for one
|
|
862
|
+
search on every request even with a warm prompt cache. `"always"` skips those checks
|
|
863
|
+
(for example behind a proxy that forwards the search blocks). Requests with an output
|
|
864
|
+
contract, or with the model cascade on, keep the selector.
|
|
865
|
+
- **Across turns.** Append the assistant turn with its `toolCalls` (ids as returned)
|
|
866
|
+
before the tool results, as in any tool conversation. The Anthropic adapter then sends
|
|
867
|
+
the earlier search results back in that turn, so the model calls the tools it already
|
|
868
|
+
found without searching again. It keeps them in memory, per adapter instance and
|
|
869
|
+
bounded; a turn it no longer remembers (another process, say) goes out as before and
|
|
870
|
+
the model searches again.
|
|
871
|
+
- **Whose saving.** The provider's. Your usage counter shows it; the layer credits
|
|
872
|
+
nothing for it and charges no commission on it.
|
|
873
|
+
|
|
874
|
+
```ts
|
|
875
|
+
import { AnthropicProvider } from "@bivelio/savings-layer";
|
|
876
|
+
|
|
877
|
+
const claude = createSavingsLayer({
|
|
878
|
+
provider: new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY }),
|
|
879
|
+
licenseKey: process.env.BIVELIO_LICENSE_KEY,
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const mcpTools: ToolDescriptor[] = []; // every tool your MCP servers list
|
|
883
|
+
|
|
884
|
+
const { report: searched } = await claude.generate({
|
|
885
|
+
model: "anthropic/claude-opus-5-5",
|
|
886
|
+
messages: [{ role: "user", content: "Which orders from service 17 are still open?" }],
|
|
887
|
+
tools: mcpTools,
|
|
888
|
+
savings: { toolSearch: { mode: "auto", alwaysLoaded: ["read_file"] } },
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Which path ran and why, and how many searches the provider ran.
|
|
892
|
+
console.log(searched.traces.filter((t) => t.stage === "tool-search"));
|
|
893
|
+
```
|
|
894
|
+
|
|
590
895
|
</details>
|
|
591
896
|
|
|
592
897
|
<details>
|