@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 +64 -6
- package/dist/gateway/bin.js +740 -687
- package/dist/index.d.ts +93 -17
- package/dist/index.js +1277 -1208
- package/package.json +1 -1
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
|
|
868
|
-
|
|
869
|
-
|
|
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
|