@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 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
- **Claude Code, Codex CLI, Goose or Qwen Code with an API key?** The package also
78
- ships a local measuring gateway: `bvsala claude` (or `bvsala codex`, `bvsala
79
- goose`, `bvsala qwen`) launches the terminal through it and your real spend shows
80
- up in your dashboard, token by token — nothing to configure, nothing persisted.
81
- For opencode, Crush, Droid, Cline or Kilo Code, `bvsala doctor` prints the exact
82
- config block for each one you have installed. Like the SDK, the gateway is
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 (see the limits): `cache_control` and its 1-hour TTL, tool search and
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 middleware for
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 (its
469
- discount lives only in Message Batches, which the layer does not support yet).
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 result to `messages`, and call `generate()` again.
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
- const res = await layer.generate({
571
- model: "openai/gpt-4o-mini",
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
- if (res.toolCalls?.length) {
577
- // res.finishReason === "tool_calls"
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
- console.log(call.name, args); // ← run your tool here, then append its result
829
+ const output = await getWeather(args.city);
830
+ messages.push({ role: "tool", toolCallId: call.id, name: call.name, content: output });
581
831
  }
582
- } else {
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>