@bivelio/savings-layer 0.4.30 → 0.4.31

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
@@ -282,6 +282,21 @@ nothing either. The same rule applies in the gateway. Any other refusal is price
282
282
  its `usage` as usual. That covers `bio`, `frontier_llm` and `reasoning_extraction`, a
283
283
  refusal after partial output, and a category the table does not list.
284
284
 
285
+ Most refusals carry no signal from the API: the model writes "I can't help with that."
286
+ as ordinary text and ends with `end_turn` or `stop`. The layer also looks for these,
287
+ with a conservative text heuristic. It checks only the opening of the answer, or the
288
+ whole answer when it is short, and it needs a real refusal clause, such as "I'm sorry,
289
+ but I can't help with that", "I cannot assist" or "As an AI, I cannot provide". A
290
+ single word is not enough. It covers English, Spanish, Catalan, French, German,
291
+ Italian and Portuguese. A match is measured and served as usual. It is not written to
292
+ the exact or semantic cache, and it is not replayed by reference. A concurrent
293
+ identical request served from it is `estimated`. The trace says why
294
+ (`skipReason: "text_refusal"`). A refusal already stored by an earlier version is not
295
+ served either. In the gateway, the coalesced twin of such a response is `estimated`
296
+ (`primary-text-refusal`). A false positive costs one cache hit. A normal answer that
297
+ starts with "Sorry for the delay, here is…", or has "I can't" later on, is cached as
298
+ before.
299
+
285
300
  **Streaming.** `generateStream()` takes the same request and hands you the answer as
286
301
  the provider writes it, then the same `SavingsReport`:
287
302
 
@@ -711,7 +726,11 @@ const placedLayer = createSavingsLayer({
711
726
  Calibrated reuse and `cacheBreakpoints: "auto"` learn from your traffic, and by default
712
727
  what they learn lives in the process. On serverless platforms (Vercel Functions, AWS
713
728
  Lambda) an instance may serve one request or live a few minutes, so neither would ever
714
- get past its starting point. Give each one somewhere to keep it:
729
+ get past its starting point. Give each one somewhere to keep it. Giving the layer a
730
+ `semanticCache` only says where reworded questions are kept; `semanticCacheDefault: true`
731
+ is what switches reuse on for every request (a request's own `savings.semanticCache`
732
+ still wins), and without it, or `savings.semanticCache` on each request, the cache
733
+ stays empty and learns nothing:
715
734
 
716
735
  ```ts
717
736
  import { InMemorySemanticCache } from "@bivelio/savings-layer";
@@ -731,6 +750,8 @@ async function serverlessLayer(kv: SharedKeyValue) {
731
750
  provider,
732
751
  licenseKey: process.env.BIVELIO_LICENSE_KEY,
733
752
  semanticCache,
753
+ // Passing the cache does not switch it on; this asks for it on every request.
754
+ semanticCacheDefault: true,
734
755
  cacheBreakpoints: "auto",
735
756
  cacheBreakpointStore: {
736
757
  get: (key) => kv.get(key),
@@ -802,6 +823,35 @@ console.log(data.answer); // 8
802
823
 
803
824
  </details>
804
825
 
826
+ <details>
827
+ <summary><b>Output cap — with or without a contract</b></summary>
828
+
829
+ `maxOutputTokens` on the request caps the answer of any request, free text
830
+ included. With `response.maxOutputTokens` as well, the smaller of the two is sent.
831
+
832
+ ```ts
833
+ const { text, finishReason } = await layer.generate({
834
+ model: "openai/gpt-5-mini",
835
+ messages: [{ role: "user", content: "Summarize this in two sentences: …" }],
836
+ maxOutputTokens: 300,
837
+ });
838
+ ```
839
+
840
+ On the wire it becomes Anthropic's `max_tokens`; on Chat Completions,
841
+ `max_completion_tokens` when `baseUrl` is OpenAI's own API (`max_tokens` is
842
+ deprecated there and its reasoning models, o-series and gpt-5.x, reject it) and
843
+ `max_tokens` on any other OpenAI-compatible endpoint, which is the field they all
844
+ understand. If an endpoint answers 400 naming the field it got as unsupported, the
845
+ request is repeated once with the other one (nothing was generated, so nothing is
846
+ billed twice) and the model is remembered. To choose the field yourself:
847
+ `new OpenAICompatibleProvider({ baseUrl, maxTokensParam: "max_completion_tokens" })`.
848
+
849
+ The cap is yours, not a saving: it is never credited as output avoided. An answer
850
+ it cut (`finishReason: "length"`) is not cached, and
851
+ `report.qualityLedger.truncation` says the limit was yours.
852
+
853
+ </details>
854
+
805
855
  <details>
806
856
  <summary><b>Tool calls</b></summary>
807
857
 
@@ -862,13 +912,21 @@ each result answers and its `isError`. Two conversations that differ only there
862
912
  share a cached answer; the fresh call ids of a new run of the same loop are not a
863
913
  difference.
864
914
 
915
+ **Which tools reach the model.** The layer never cuts a catalogue it cannot rank. If
916
+ your tools declare no `intents` and no `risk` (the usual shape of MCP and OpenAI tool
917
+ lists), or you pass `savings: { router: false }`, every tool goes to the provider
918
+ exactly as you sent it, and the layer credits nothing for the tool block. Only when
919
+ your tools declare `intents`/`risk` does the selector keep the relevant ones, up to
920
+ `new ToolSelector({ maxTools })` (8 by default), and hold back write tools from a
921
+ request that is not an action. Whatever it leaves out, the model cannot call in that
922
+ request: the report's `tool-selector` trace lists it, the tokens count as eliminated
923
+ input, and the SDK warns once per process (`silenceWarnings` turns the warning off).
924
+
865
925
  **Large catalogues: let the provider search them (opt‑in).** With hundreds of tools
866
926
  (several MCP servers, say), `savings: { toolSearch: "auto" }` sends every tool deferred
867
- behind the provider's own tool search instead of letting the layer's selector keep a
868
- few. A catalogue that declares no `intents` or `risk` gives the selector nothing to go
869
- on, so it keeps the first ones and the model cannot call the rest; with the provider's
870
- search the model looks up what it needs, no tool you declared is out of reach, and the
871
- cached prompt prefix stays the same from one question to the next.
927
+ behind the provider's own tool search instead of putting the whole catalogue in every
928
+ request's prompt. The model looks up what it needs, no tool you declared is out of
929
+ reach, and the cached prompt prefix stays the same from one question to the next.
872
930
 
873
931
  - **Where.** The Anthropic adapter, on models in Anthropic's tool search compatibility
874
932
  table. OpenAI documents its tool search for the Responses and Agents APIs, not for