llm-output-guard 1.12.0 → 1.12.1
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/docs/adapters.md +500 -0
- package/docs/agent-loops.md +293 -0
- package/docs/calibration.md +253 -0
- package/docs/detectors.md +224 -0
- package/docs/performance.md +123 -0
- package/docs/script-coverage.md +132 -0
- package/docs/streaming.md +160 -0
- package/package.json +3 -2
package/docs/adapters.md
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# Provider adapters
|
|
2
|
+
|
|
3
|
+
One wrap per client. Every adapter drives the same `createStreamGuard`;
|
|
4
|
+
none of them reimplements it.
|
|
5
|
+
|
|
6
|
+
### Vercel AI SDK
|
|
7
|
+
|
|
8
|
+
One wrap, and both `generateText` and `streamText` are guarded:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { wrapLanguageModel } from 'ai';
|
|
12
|
+
import { outputGuard } from 'llm-output-guard/ai-sdk';
|
|
13
|
+
import { presets } from 'llm-output-guard';
|
|
14
|
+
|
|
15
|
+
const model = wrapLanguageModel({
|
|
16
|
+
model: groq('llama-3.3-70b-versatile'),
|
|
17
|
+
middleware: outputGuard({ ...presets.chat, onDegenerate: 'throw' }),
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
On `streamText` this cancels the provider's stream mid-generation. Driven
|
|
22
|
+
through the real SDK over a **mock part stream**, the source was pulled for **17
|
|
23
|
+
of 137 parts** before the guard cut it off. That figure is parts never requested
|
|
24
|
+
from a stub, not tokens never billed by a provider: the SDK's cancellation path
|
|
25
|
+
is exercised for real, the thing on the other end of it is not. On
|
|
26
|
+
`generateText` the tokens are already bought, so it throws
|
|
27
|
+
`DegenerateOutputError` instead, which your fallback layer can act on.
|
|
28
|
+
|
|
29
|
+
`onDegenerate` takes `'throw'` (default, also cancels the stream), `'abort'`
|
|
30
|
+
(stop cleanly, keep what arrived), or `'ignore'`. Start with `'ignore'` plus
|
|
31
|
+
`onVerdict` to watch your own traffic before letting a threshold fail anything:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
outputGuard({
|
|
35
|
+
...presets.chat,
|
|
36
|
+
onDegenerate: 'ignore',
|
|
37
|
+
onVerdict: (verdict, { streaming }) => metrics.record(verdict.scores, { streaming }),
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`ai` is an **optional peer dependency** — importing the subpath does not pull it
|
|
42
|
+
in, and the main entry point has no peers at all. Supported: **`ai` v5, v6 and
|
|
43
|
+
v7**. CI installs the packed tarball against each of those and both typechecks
|
|
44
|
+
and runs the adapter, so the range is one that has been executed rather than
|
|
45
|
+
assumed.
|
|
46
|
+
|
|
47
|
+
**`ai` v4 is not supported, and forcing it will look like a bug in this
|
|
48
|
+
package.** v4's middleware hands back `text` where v5+ hands back a `content`
|
|
49
|
+
array, and streams `{ textDelta }` where v5+ streams `{ delta }`. This adapter
|
|
50
|
+
reads the v5+ shape, so on v4 it sees the empty string for every response —
|
|
51
|
+
which means **every healthy generation is flagged `EMPTY`, and under the default
|
|
52
|
+
`onDegenerate: 'throw'` every call throws `DegenerateOutputError`.** It is not
|
|
53
|
+
that the guard misses things on v4; it rejects everything. The peer range now
|
|
54
|
+
refuses the install so you find out at `npm install` rather than in production.
|
|
55
|
+
If you are pinned to v4, do not override it — stay on the core entry point and
|
|
56
|
+
call `checkOutput` on the result yourself.
|
|
57
|
+
|
|
58
|
+
### OpenAI SDK — and anything speaking its protocol
|
|
59
|
+
|
|
60
|
+
One wrap, and both APIs are guarded — `chat.completions.create` and
|
|
61
|
+
`responses.create`, streaming and not:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import OpenAI from 'openai';
|
|
65
|
+
import { withOutputGuard } from 'llm-output-guard/openai';
|
|
66
|
+
import { presets } from 'llm-output-guard';
|
|
67
|
+
|
|
68
|
+
const client = withOutputGuard(new OpenAI(), {
|
|
69
|
+
...presets.chat,
|
|
70
|
+
onDegenerate: 'throw',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await client.chat.completions.create({ model, messages }); // guarded
|
|
74
|
+
await client.responses.create({ model, input }); // guarded
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### `throw` stops a bad answer. `abort` saves tokens.
|
|
78
|
+
|
|
79
|
+
They are not two strengths of the same setting, and picking the wrong one is
|
|
80
|
+
the quiet failure this package exists to catch:
|
|
81
|
+
|
|
82
|
+
| | non-streaming call | stream |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `'throw'` (default) | **fails the call** with `DegenerateOutputError` | fails it *and* cancels the request |
|
|
85
|
+
| `'abort'` | reports to `onVerdict`, **returns the response** | ends the stream, keeps what arrived |
|
|
86
|
+
| `'ignore'` | reports only | reports only |
|
|
87
|
+
|
|
88
|
+
**`'abort'` on a non-streaming call does not stop anything.** There is no stream
|
|
89
|
+
to end, so the guard measures the response, reports it, and hands it back — a
|
|
90
|
+
guard you believe in and do not have. It is the right choice on a stream where a
|
|
91
|
+
partial answer beats no answer, and only there.
|
|
92
|
+
|
|
93
|
+
`'throw'` is the default, so the shortest correct wrap is the one with no
|
|
94
|
+
`onDegenerate` at all.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
The Responses API spells its stop reason `incomplete_details.reason` rather than
|
|
98
|
+
`finish_reason`, and its length stop `max_output_tokens` rather than `length`.
|
|
99
|
+
Both are mapped, so `TRUNCATED` fires the same way on either. `content_filter`
|
|
100
|
+
is deliberately *not* read as truncation — a filtered response is a different
|
|
101
|
+
failure, and reporting it as `TRUNCATED` would send a retry layer after the
|
|
102
|
+
wrong fix.
|
|
103
|
+
|
|
104
|
+
### `responses.stream()` needed a different mechanism
|
|
105
|
+
|
|
106
|
+
It is guarded, and it was the last thing here that wasn't. A `ResponseStream` is
|
|
107
|
+
an `EventStream`, and it can be read **six** ways:
|
|
108
|
+
|
|
109
|
+
| path | detected | cancelled | throws |
|
|
110
|
+
|---|---|---|---|
|
|
111
|
+
| `for await (… of stream)` | yes | yes | yes |
|
|
112
|
+
| `await stream.finalResponse()` | yes | yes | yes |
|
|
113
|
+
| `await stream.done()` | yes | yes | yes |
|
|
114
|
+
| `stream.on('…', cb)` | yes | yes | — |
|
|
115
|
+
| `stream.once()` / `.emitted()` | yes | yes | — |
|
|
116
|
+
| `stream.events('…')` | yes | yes | — |
|
|
117
|
+
|
|
118
|
+
Replacing the iterator would have covered the first row and left five reading an
|
|
119
|
+
unchecked stream, which is why this shipped unguarded until 1.12.0 — half a
|
|
120
|
+
guard on the SDK's default streaming surface looks like coverage and is not.
|
|
121
|
+
|
|
122
|
+
What makes it work is that all six are fed by one event pump, so **one listener
|
|
123
|
+
sees every delta however you read it**, and `abort()` reaches the transport for
|
|
124
|
+
all of them. Throwing is the part that cannot be universal: three paths have an
|
|
125
|
+
error channel and three do not. A caller who only attached `on()` callbacks gets
|
|
126
|
+
the `onVerdict` report and a stream that stops — which is the half that saves
|
|
127
|
+
tokens — but no exception, because there is nowhere to put one.
|
|
128
|
+
|
|
129
|
+
`onDegenerate: 'abort'` ends the iterator cleanly and keeps what arrived.
|
|
130
|
+
`finalResponse()` and `done()` still reject under it, because there is no
|
|
131
|
+
partial value either can honestly return.
|
|
132
|
+
|
|
133
|
+
This is also how you guard **Groq, Together, OpenRouter, Fireworks, DeepInfra,
|
|
134
|
+
vLLM and Ollama** — anything you reach through an OpenAI-compatible `baseURL`
|
|
135
|
+
works, because the adapter is typed against the wire shapes rather than against
|
|
136
|
+
OpenAI the company.
|
|
137
|
+
|
|
138
|
+
Non-streaming calls are already paid for by the time anything can run, so a
|
|
139
|
+
degenerate one throws `DegenerateOutputError` for your fallback layer to catch:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const completion = await client.chat.completions.create({ model, messages });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Streaming is where it pays. The guard watches deltas and **cancels the HTTP
|
|
146
|
+
request** the moment a loop is detectable:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const stream = await client.chat.completions.create({ model, messages, stream: true });
|
|
150
|
+
for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Driven through the real SDK against a looping model over a **mock transport**,
|
|
154
|
+
the response body was cancelled after **16 of 135 chunks** were generated — 88%
|
|
155
|
+
of the chunks were never produced.
|
|
156
|
+
|
|
157
|
+
**What that number is, precisely:** chunks a mock server was never asked to
|
|
158
|
+
produce after the client closed the connection, measured against an unguarded
|
|
159
|
+
baseline of the full 135. It is stronger than a "did abort fire" assertion —
|
|
160
|
+
the test observes cancellation at the response body, so a guard that stopped
|
|
161
|
+
iterating while the connection stayed open would fail it. It is **not** a
|
|
162
|
+
billing figure. A real provider sits behind buffering, its own chunking, and
|
|
163
|
+
server-side generation that may already have run ahead of what it has sent;
|
|
164
|
+
none of that exists in the mock. Treat 88% as evidence that cancellation
|
|
165
|
+
reaches the transport promptly, and measure your own provider before putting a
|
|
166
|
+
number in a budget.
|
|
167
|
+
|
|
168
|
+
`onDegenerate` and `onVerdict` are the same options as the Vercel adapter, from
|
|
169
|
+
the same type — `'throw'` (default, also cancels the stream), `'abort'` (stop
|
|
170
|
+
cleanly, keep what arrived), or `'ignore'`. Start with `'ignore'` plus
|
|
171
|
+
`onVerdict` to watch your own traffic first:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
withOutputGuard(new OpenAI(), {
|
|
175
|
+
...presets.chat,
|
|
176
|
+
onDegenerate: 'ignore',
|
|
177
|
+
onVerdict: (verdict, { streaming }) =>
|
|
178
|
+
metrics.record(verdict.scores, { streaming, modes: verdict.modes }),
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`openai` is an **optional peer dependency**, and the wrapper is a proxy: every
|
|
183
|
+
other method on the client, and `create()`'s own `.withResponse()`, pass through
|
|
184
|
+
untouched. `finish_reason: 'length'` is mapped into the final check, so
|
|
185
|
+
`TRUNCATED` fires on a response that hit `max_tokens`.
|
|
186
|
+
|
|
187
|
+
**What runs when.** Mid-stream only the redundancy detectors are meaningful:
|
|
188
|
+
partial output is genuinely short, genuinely cut off, and genuinely not valid
|
|
189
|
+
JSON, so `TOO_SHORT`, `TRUNCATED`, `INVALID_JSON` and `LANG_MISMATCH` would
|
|
190
|
+
fire on every healthy generation and teach you to ignore the guard. They are
|
|
191
|
+
deferred to `end()`. `LOW_ENTROPY` is deferred too, for cost — it is ~100x the
|
|
192
|
+
other detectors, and everything it would have caught early is caught by
|
|
193
|
+
`REPETITION`, or by `TAIL_LOOP`'s character mode on non-spaced scripts.
|
|
194
|
+
|
|
195
|
+
Every adapter shares this behaviour because they all drive the same
|
|
196
|
+
`createStreamGuard`. None of them reimplements it.
|
|
197
|
+
|
|
198
|
+
### Anthropic SDK
|
|
199
|
+
|
|
200
|
+
Same one wrap, same options:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
204
|
+
import { withOutputGuard } from 'llm-output-guard/anthropic';
|
|
205
|
+
import { presets } from 'llm-output-guard';
|
|
206
|
+
|
|
207
|
+
const client = withOutputGuard(new Anthropic(), {
|
|
208
|
+
...presets.chat,
|
|
209
|
+
onDegenerate: 'throw',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await client.messages.create({ model, max_tokens, messages }); // guarded
|
|
213
|
+
await client.messages.create({ model, max_tokens, messages, stream: true }); // guarded
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Two things are specific to this API:
|
|
217
|
+
|
|
218
|
+
**Extended thinking is not read as the answer.** `thinking` blocks are the
|
|
219
|
+
model's reasoning, they are often longer than the answer, and they repeat
|
|
220
|
+
themselves as a matter of course while working a problem. Folding them into the
|
|
221
|
+
measured text would raise every repetition score on every thinking response and
|
|
222
|
+
flag the ones that thought hardest — so only `text` blocks are measured, and a
|
|
223
|
+
`thinking` block is not mistaken for a tool call either.
|
|
224
|
+
|
|
225
|
+
**Both of Anthropic's length stops map to `TRUNCATED`.** `max_tokens` passes
|
|
226
|
+
straight through; `model_context_window_exceeded` is the same event under a
|
|
227
|
+
different name and is normalised in the adapter. `refusal` is deliberately *not*
|
|
228
|
+
truncation — a refusal is a complete response that says no, which is a content
|
|
229
|
+
judgement this package does not make.
|
|
230
|
+
|
|
231
|
+
> **`messages.stream()` is not guarded**, for the same reason `responses.stream()`
|
|
232
|
+
> isn't: it returns a `MessageStream` — an event emitter with `.on()` and
|
|
233
|
+
> `.finalMessage()` — and guarding only its iteration would leave
|
|
234
|
+
> `.finalMessage()` unchecked. Use `create({ stream: true })`, or run
|
|
235
|
+
> `checkOutput` on `await stream.finalMessage()` yourself. `messages.batches` is
|
|
236
|
+
> unguarded too, and less interestingly: a batch is retrieved later as a file of
|
|
237
|
+
> results, so there is no response at `create` time to inspect.
|
|
238
|
+
|
|
239
|
+
`@anthropic-ai/sdk` is an **optional peer dependency**, declared
|
|
240
|
+
`>=0.60.0 <1.0.0` and verified at 0.60.0, 0.90.0 and 0.117.1 — each installing
|
|
241
|
+
the packed tarball and running the adapter for real, not just typechecking.
|
|
242
|
+
|
|
243
|
+
### Google Gemini SDK
|
|
244
|
+
|
|
245
|
+
Same one wrap, same options — but Gemini splits streaming into its own method
|
|
246
|
+
rather than putting it behind a flag, so both are named:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { GoogleGenAI } from '@google/genai';
|
|
250
|
+
import { withOutputGuard } from 'llm-output-guard/google';
|
|
251
|
+
import { presets } from 'llm-output-guard';
|
|
252
|
+
|
|
253
|
+
const ai = withOutputGuard(new GoogleGenAI({ apiKey }), {
|
|
254
|
+
...presets.chat,
|
|
255
|
+
onDegenerate: 'throw',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await ai.models.generateContent({ model, contents }); // guarded
|
|
259
|
+
await ai.models.generateContentStream({ model, contents }); // guarded
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Three things are specific to this API:
|
|
263
|
+
|
|
264
|
+
**Cancelling a stream works differently here, and it matters.**
|
|
265
|
+
`generateContentStream` resolves to a bare `AsyncGenerator`. Unlike the OpenAI
|
|
266
|
+
and Anthropic streams it carries no `AbortController`, so there is nothing on it
|
|
267
|
+
to abort — and ending our own iteration would leave the model generating, and
|
|
268
|
+
billing, while we looked away. The adapter therefore puts a signal *into* the
|
|
269
|
+
outgoing request as `config.abortSignal`, which is what reaches `fetch`. Your
|
|
270
|
+
request object is cloned rather than mutated, and a signal you supplied yourself
|
|
271
|
+
keeps working: ours follows yours rather than replacing it.
|
|
272
|
+
|
|
273
|
+
**Thought summaries are not read as the answer.** Gemini carries thinking in the
|
|
274
|
+
same `text` field as the answer, flagged `thought: true`, so that flag is the
|
|
275
|
+
only thing separating the two. Measured as answer text, reasoning would raise
|
|
276
|
+
every repetition score on every thinking-enabled response — the same reason
|
|
277
|
+
`./anthropic` skips `thinking` blocks.
|
|
278
|
+
|
|
279
|
+
**Only the first candidate is judged.** `candidateCount` above 1 asks for
|
|
280
|
+
*alternatives* to the same question and only one of them gets used.
|
|
281
|
+
Concatenating them would measure a document nobody receives, and measure it
|
|
282
|
+
wrongly in one direction: two good answers to one question share their subject,
|
|
283
|
+
vocabulary and often their structure, so the more consistent the model was the
|
|
284
|
+
higher its repetition score would climb.
|
|
285
|
+
|
|
286
|
+
`MAX_TOKENS` maps to `TRUNCATED` — it lands on the length stop the detector
|
|
287
|
+
already knows. Gemini's content stops (`SAFETY`, `RECITATION`, `BLOCKLIST`,
|
|
288
|
+
`PROHIBITED_CONTENT`, `SPII`) deliberately do **not**: each is a decision about
|
|
289
|
+
what the model may say rather than the model losing the thread, and reading one
|
|
290
|
+
as truncation would discard the response and spend a retry earning the identical
|
|
291
|
+
stop again.
|
|
292
|
+
|
|
293
|
+
> **`chats` is not guarded.** `chats.create()` returns a `Chat` that holds its
|
|
294
|
+
> own history, so guarding it means wrapping a value returned from a method
|
|
295
|
+
> rather than a response — and deciding what a degenerate turn does to history
|
|
296
|
+
> that has already been appended. Left plainly unguarded rather than half-done.
|
|
297
|
+
> Use `models.generateContent`, or run `checkOutput` on the result yourself.
|
|
298
|
+
|
|
299
|
+
`@google/genai` is an **optional peer dependency**, declared `>=1.0.0 <3.0.0`
|
|
300
|
+
and verified at 1.0.0, 1.9.0, 2.0.0 and 2.19.0 — each installing the packed
|
|
301
|
+
tarball and running the adapter for real, including the cancellation, not just
|
|
302
|
+
typechecking.
|
|
303
|
+
|
|
304
|
+
### Tool calls and agents
|
|
305
|
+
|
|
306
|
+
A model that answers by calling a tool returns no assistant text — OpenAI sends
|
|
307
|
+
`content: null` beside `tool_calls`, and the AI SDK sends a `content` array with
|
|
308
|
+
no `text` part. Handed to `checkOutput`, that is an empty string, and an empty
|
|
309
|
+
string scores `EMPTY`.
|
|
310
|
+
|
|
311
|
+
So **the presence of tool calls means the text, if any, is a preamble rather
|
|
312
|
+
than the answer**, and every adapter judges it as one:
|
|
313
|
+
|
|
314
|
+
| | On a tool-calling turn |
|
|
315
|
+
|---|---|
|
|
316
|
+
| No text at all | Nothing is judged, and nothing is reported to `onVerdict` |
|
|
317
|
+
| Text beside the call | `REPETITION`, `TAIL_LOOP` and `LOW_ENTROPY` still run |
|
|
318
|
+
| `TOO_SHORT` | Off — "Let me look that up" is sixteen characters and correct |
|
|
319
|
+
| `TRUNCATED` | Off — a preamble ends without terminal punctuation as a matter of course |
|
|
320
|
+
| `INVALID_JSON` | Off — the JSON is in the call arguments, which your provider already validated against the schema |
|
|
321
|
+
|
|
322
|
+
The redundancy detectors stay on because a model looping in its preamble is
|
|
323
|
+
still a model that is looping. `EMPTY` is not disarmed either: a response with
|
|
324
|
+
neither text nor tool calls still fails, which is the case this package exists
|
|
325
|
+
for.
|
|
326
|
+
|
|
327
|
+
Nothing is reported to `onVerdict` for a text-free tool call on purpose. Those
|
|
328
|
+
samples are what a `calibrate` run is built from, and a spike of `EMPTY: 1` in
|
|
329
|
+
them would describe your agent's tool use rather than any degeneration.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
[← Back to the README](../README.md) · [Try the playground](https://edwinsatya.github.io/llm-output-guard/)
|
|
336
|
+
|
|
337
|
+
## Building an agent turn
|
|
338
|
+
|
|
339
|
+
Every subpath above also exports `toTurn`, which maps that provider's response
|
|
340
|
+
onto the shape `llm-output-guard/agent` reads:
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
import { toTurn } from 'llm-output-guard/openai';
|
|
344
|
+
guard.observe(toTurn(completion));
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
It carries the same provider knowledge the guard beside it does — thought parts
|
|
348
|
+
excluded on Gemini, thinking blocks on Anthropic, first choice or candidate
|
|
349
|
+
only. **[docs/agent-loops.md](agent-loops.md#building-a-turn-from-your-provider)**
|
|
350
|
+
|
|
351
|
+
### What is covered
|
|
352
|
+
|
|
353
|
+
| Provider | Maps | Via |
|
|
354
|
+
|---|---|---|
|
|
355
|
+
| OpenAI — `chat.completions` and `responses` | fully | `./openai` |
|
|
356
|
+
| Anthropic | fully | `./anthropic` |
|
|
357
|
+
| Google Gemini | fully | `./google` |
|
|
358
|
+
| Vercel AI SDK | fully | `./ai-sdk` |
|
|
359
|
+
| Groq, Together, OpenRouter, Fireworks, DeepInfra, vLLM | fully | `./openai` |
|
|
360
|
+
| Ollama — OpenAI-compatible endpoint | fully | `./openai` |
|
|
361
|
+
| Azure OpenAI | fully | `./openai` — it *is* the `openai` SDK |
|
|
362
|
+
| Mistral SDK | fully | `./openai` — see below |
|
|
363
|
+
| AWS Bedrock, Cohere, LangChain, Ollama's native `/api/chat` | **nothing** | not adapted |
|
|
364
|
+
|
|
365
|
+
**Full, or nothing.** An unsupported envelope maps to an empty turn, which a
|
|
366
|
+
trace drops. There is deliberately no partial outcome, and that is the invariant
|
|
367
|
+
worth understanding: a turn with its text read and its calls missed
|
|
368
|
+
fingerprints by its *preamble prose* instead of its arguments, so an agent that
|
|
369
|
+
reuses one preamble while working through twenty files reads as a total
|
|
370
|
+
collapse. A false positive on a healthy run is the failure this package treats
|
|
371
|
+
as worse than a miss. The table is asserted in `test/openai-alikes.test.ts`.
|
|
372
|
+
|
|
373
|
+
**Mistral is why that invariant is written down.** Its generated SDK renames
|
|
374
|
+
`tool_calls` to `toolCalls`, and the rename broke more than the mapper: a
|
|
375
|
+
tool-calling turn carries no prose, so an unrecognised call list left
|
|
376
|
+
`content: ''` — which scores `EMPTY: 1`. Every tool call from a Mistral client
|
|
377
|
+
aborted under `withOutputGuard`. Both spellings are now read everywhere the list
|
|
378
|
+
is, which is safe rather than merely convenient: no response in the OpenAI
|
|
379
|
+
family carries both fields, so there is nothing to disambiguate.
|
|
380
|
+
|
|
381
|
+
## Tool-call arguments
|
|
382
|
+
|
|
383
|
+
A tool-calling turn is judged by its preamble — the text beside the call — because
|
|
384
|
+
that text is not the answer. Which leaves the answer itself, the arguments,
|
|
385
|
+
unmeasured. Before 1.5 nothing here looked at them at all.
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
const client = withOutputGuard(new OpenAI(), {
|
|
389
|
+
...presets.chat,
|
|
390
|
+
checkToolArguments: true,
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Your provider already validates arguments against the schema you declared. That
|
|
395
|
+
covers **types**, not content — and a model that loops does not produce the wrong
|
|
396
|
+
type, it produces a valid string with nothing in it:
|
|
397
|
+
|
|
398
|
+
```json
|
|
399
|
+
{ "query": "site reliability engineering site reliability engineering …", "limit": 10 }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Schema-valid. Passes the provider. Reaches your tool as a garbage search, or,
|
|
403
|
+
if the tool writes, persists the loop.
|
|
404
|
+
|
|
405
|
+
**What is measured.** Redundancy only, per string value — `REPETITION` and
|
|
406
|
+
`TAIL_LOOP`, under the same thresholds your guard already uses. The reason codes
|
|
407
|
+
are unchanged so existing handling keeps working; the `message` says the loop was
|
|
408
|
+
found in an argument rather than in the prose.
|
|
409
|
+
|
|
410
|
+
**What is not, and why.** `LOW_ENTROPY` is off because JSON is legitimately
|
|
411
|
+
repetitive at the character level, the same reason `presets.strictJson` turns it
|
|
412
|
+
off. `TRUNCATED`, `TOO_SHORT` and `INVALID_JSON` are off because the provider
|
|
413
|
+
already guarantees the arguments parse and match your schema — they would be
|
|
414
|
+
answering a question that has been answered. `SCRIPT_MISMATCH` and
|
|
415
|
+
`LANG_MISMATCH` are off because an argument is not prose addressed to a user: a
|
|
416
|
+
search query in another language is ordinary, not degenerate.
|
|
417
|
+
|
|
418
|
+
Values are measured **individually**, never as a serialised document. Two calls
|
|
419
|
+
against the same schema are legitimately near-identical documents, and an array
|
|
420
|
+
of repeated records inside one argument is the shape that was asked for — the
|
|
421
|
+
same rule `redundancyScope: 'jsonValues'` follows, for the same reason.
|
|
422
|
+
|
|
423
|
+
**Nothing is measured when there is nothing to measure.** A tool that takes no
|
|
424
|
+
parameters is called with `{}`, and arguments carrying no strings at all —
|
|
425
|
+
`{"lat":-6.2,"zoom":11}` — have no prose to judge. Both are skipped rather than
|
|
426
|
+
scored, so a no-argument tool does not fail on `EMPTY`.
|
|
427
|
+
|
|
428
|
+
**Non-streaming responses only.** Arguments arrive as JSON fragments that do not
|
|
429
|
+
parse until the call is complete, so there is nothing meaningful to measure
|
|
430
|
+
mid-stream.
|
|
431
|
+
|
|
432
|
+
Off by default. Switching it on can only make a response fail that previously
|
|
433
|
+
passed, so it is opt-in like every other behaviour change in this package.
|
|
434
|
+
|
|
435
|
+
## Prompt echo
|
|
436
|
+
|
|
437
|
+
`PROMPT_ECHO` catches a model that replays your prompt instead of answering it.
|
|
438
|
+
It needs the prompt, and a guard is configured once when you wrap the client
|
|
439
|
+
while the prompt changes on every call — so this is a switch rather than a
|
|
440
|
+
value, and the adapter reads the prompt out of each request it is already
|
|
441
|
+
forwarding.
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
const client = withOutputGuard(new OpenAI(), {
|
|
445
|
+
...presets.chat,
|
|
446
|
+
checkPromptEcho: true,
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Works on all three adapters, and each reads its own request shape:
|
|
451
|
+
`messages` for `chat.completions`, `instructions` plus `input` for `responses`,
|
|
452
|
+
`system` plus `messages` for Anthropic, and the spec's normalised `params.prompt`
|
|
453
|
+
for the AI SDK. Content is read whether it is a plain string or a list of parts;
|
|
454
|
+
non-text parts (images, audio, files) contribute nothing, which is correct — they
|
|
455
|
+
are not text the model could echo back.
|
|
456
|
+
|
|
457
|
+
**Prior assistant turns are excluded.** The failure being measured is a model
|
|
458
|
+
replaying its *input*, and a model that keeps its terminology consistent across a
|
|
459
|
+
long conversation is doing its job. Counting its own earlier answers would make
|
|
460
|
+
that look worse the longer the conversation ran. Nothing is lost: a model that
|
|
461
|
+
loses the turn boundary and replays the whole transcript replays the system and
|
|
462
|
+
user text too.
|
|
463
|
+
|
|
464
|
+
**An explicit `prompt` in the options wins.** If you have a fixed system prompt
|
|
465
|
+
and prefer to state it once when wrapping the client, the adapter does not
|
|
466
|
+
override it.
|
|
467
|
+
|
|
468
|
+
**On a stream the check runs at the end**, not on the mid-stream windows,
|
|
469
|
+
because the score is a share of the whole output and a trailing window measures
|
|
470
|
+
the share of that window. See
|
|
471
|
+
[docs/detectors.md](./detectors.md#returning-the-prompt-instead-of-an-answer)
|
|
472
|
+
for the numbers.
|
|
473
|
+
|
|
474
|
+
> **Do not enable this on a rewrite, translate, summarise or extract endpoint.**
|
|
475
|
+
> Copying from the input is the job on those, so a correct answer scores high and
|
|
476
|
+
> the detector measures the task rather than a failure.
|
|
477
|
+
|
|
478
|
+
## Aborting a wrong-language stream early
|
|
479
|
+
|
|
480
|
+
`SCRIPT_MISMATCH` and `PROMPT_ECHO` are judged at the end of a stream by
|
|
481
|
+
default, which means the whole response is already paid for by the time either
|
|
482
|
+
fires. `earlyDocumentChecks: true` lets them judge the buffer as it arrives:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
const client = withOutputGuard(new OpenAI(), {
|
|
486
|
+
...presets.chat,
|
|
487
|
+
expectScript: 'latin',
|
|
488
|
+
earlyDocumentChecks: true,
|
|
489
|
+
onDegenerate: 'abort',
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Measured on a 3,000-character response answered entirely in the wrong script:
|
|
494
|
+
**7 of 30 chunks consumed instead of 30**, with the upstream request cancelled —
|
|
495
|
+
the same transport-level abort the redundancy detectors already trigger.
|
|
496
|
+
|
|
497
|
+
It is off by default and carries a measured false-positive risk, because the
|
|
498
|
+
buffer is a prefix and a prefix over-reports both detectors. Read
|
|
499
|
+
[docs/streaming.md](./streaming.md#judging-the-whole-response-early) before
|
|
500
|
+
switching it on — the floor, the bar, and the numbers behind both are there.
|