llm-output-guard 1.0.0 → 1.2.0
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 +211 -23
- package/dist/{adapter-options-DafMCHtz.d.ts → adapter-options-Da4mVjXh.d.ts} +1 -1
- package/dist/{adapter-options-DQQaELAX.d.cts → adapter-options-LkLsvNy6.d.cts} +1 -1
- package/dist/ai-sdk.cjs +51 -6
- package/dist/ai-sdk.cjs.map +1 -1
- package/dist/ai-sdk.d.cts +3 -3
- package/dist/ai-sdk.d.ts +3 -3
- package/dist/ai-sdk.js +18 -4
- package/dist/ai-sdk.js.map +1 -1
- package/dist/anthropic.cjs +566 -0
- package/dist/anthropic.cjs.map +1 -0
- package/dist/anthropic.d.cts +52 -0
- package/dist/anthropic.d.ts +52 -0
- package/dist/anthropic.js +34 -0
- package/dist/anthropic.js.map +1 -0
- package/dist/chunk-4X6WOBSA.js +103 -0
- package/dist/chunk-4X6WOBSA.js.map +1 -0
- package/dist/chunk-4YJWBJ4K.js +17 -0
- package/dist/chunk-4YJWBJ4K.js.map +1 -0
- package/dist/{chunk-P3VLZO6Y.js → chunk-XHP4LSIH.js} +25 -5
- package/dist/chunk-XHP4LSIH.js.map +1 -0
- package/dist/index.cjs +23 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -6
- package/dist/index.d.ts +27 -6
- package/dist/index.js +1 -1
- package/dist/openai.cjs +133 -25
- package/dist/openai.cjs.map +1 -1
- package/dist/openai.d.cts +26 -9
- package/dist/openai.d.ts +26 -9
- package/dist/openai.js +58 -77
- package/dist/openai.js.map +1 -1
- package/dist/{stream-Be2WhoKn.d.cts → stream-D-GVZ6iE.d.cts} +78 -1
- package/dist/{stream-Be2WhoKn.d.ts → stream-D-GVZ6iE.d.ts} +78 -1
- package/package.json +45 -12
- package/dist/chunk-P3VLZO6Y.js.map +0 -1
package/README.md
CHANGED
|
@@ -76,6 +76,47 @@ const verdict = checkOutput(raw, {
|
|
|
76
76
|
if (verdict.ok) use(verdict.json); // already parsed, fence stripped
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
`requiredKeys` only asks whether a name is present. A model returning
|
|
80
|
+
`{ "score": "very good" }` where you wanted a number satisfies it and still
|
|
81
|
+
breaks everything downstream that does arithmetic. Pass a **schema** to check
|
|
82
|
+
the shape rather than the spelling:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { z } from 'zod';
|
|
86
|
+
|
|
87
|
+
const Review = z.object({
|
|
88
|
+
score: z.number().min(0).max(10),
|
|
89
|
+
notes: z.string(),
|
|
90
|
+
followUp: z.array(z.string()),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const verdict = checkOutput(raw, { ...presets.strictJson, schema: Review });
|
|
94
|
+
|
|
95
|
+
if (verdict.ok) use(verdict.json); // parsed, validated, defaults applied
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Any [Standard Schema](https://standardschema.dev) validator works — **Zod 4,
|
|
99
|
+
Valibot, ArkType**, or your own. The spec is types-only, so this costs an
|
|
100
|
+
interface and **no dependency**: your validator is one you already have, and
|
|
101
|
+
`llm-output-guard` still installs with nothing behind it.
|
|
102
|
+
|
|
103
|
+
On success `verdict.json` is the schema's *output*, so defaults, coercions and
|
|
104
|
+
transforms are applied and the value matches the type you declared. On failure
|
|
105
|
+
you get `INVALID_JSON` with the failing path in the message —
|
|
106
|
+
`score: Expected number, received string`. It is the same reason code as a
|
|
107
|
+
missing key or an unparseable payload because it wants the same handling: retry,
|
|
108
|
+
or fall through to another provider.
|
|
109
|
+
|
|
110
|
+
The two compose, and keys are checked first, so a missing key is still reported
|
|
111
|
+
as a missing key rather than as whatever the schema calls it.
|
|
112
|
+
|
|
113
|
+
> **The schema must validate synchronously.** `checkOutput` is synchronous by
|
|
114
|
+
> design — that is what makes it safe on a hot path — so a schema carrying an
|
|
115
|
+
> async refinement throws a `TypeError` telling you so, rather than silently
|
|
116
|
+
> passing. Everything Zod, Valibot and ArkType produce otherwise is synchronous.
|
|
117
|
+
> This is the one thing in the package that throws about your configuration; it
|
|
118
|
+
> still never throws about a response.
|
|
119
|
+
|
|
79
120
|
### Streaming, where it stops costing you tokens
|
|
80
121
|
|
|
81
122
|
Checking a finished response tells you that you already paid for it. A model
|
|
@@ -213,7 +254,8 @@ call `checkOutput` on the result yourself.
|
|
|
213
254
|
|
|
214
255
|
### OpenAI SDK — and anything speaking its protocol
|
|
215
256
|
|
|
216
|
-
One wrap, and both
|
|
257
|
+
One wrap, and both APIs are guarded — `chat.completions.create` and
|
|
258
|
+
`responses.create`, streaming and not:
|
|
217
259
|
|
|
218
260
|
```ts
|
|
219
261
|
import OpenAI from 'openai';
|
|
@@ -224,12 +266,30 @@ const client = withOutputGuard(new OpenAI(), {
|
|
|
224
266
|
...presets.chat,
|
|
225
267
|
onDegenerate: 'abort',
|
|
226
268
|
});
|
|
269
|
+
|
|
270
|
+
await client.chat.completions.create({ model, messages }); // guarded
|
|
271
|
+
await client.responses.create({ model, input }); // guarded
|
|
227
272
|
```
|
|
228
273
|
|
|
274
|
+
The Responses API spells its stop reason `incomplete_details.reason` rather than
|
|
275
|
+
`finish_reason`, and its length stop `max_output_tokens` rather than `length`.
|
|
276
|
+
Both are mapped, so `TRUNCATED` fires the same way on either. `content_filter`
|
|
277
|
+
is deliberately *not* read as truncation — a filtered response is a different
|
|
278
|
+
failure, and reporting it as `TRUNCATED` would send a retry layer after the
|
|
279
|
+
wrong fix.
|
|
280
|
+
|
|
281
|
+
> **`responses.stream()` is not guarded.** It returns a `ResponseStream` — an
|
|
282
|
+
> event emitter with `.on()` and `.finalResponse()`, not just an async iterable
|
|
283
|
+
> — and wrapping only its iteration would guard a `for await` consumer while
|
|
284
|
+
> leaving `.finalResponse()` unchecked. A guard you believe in and do not have
|
|
285
|
+
> is the failure this package was written about, so it is left plainly
|
|
286
|
+
> unguarded rather than half-wrapped. Use `create({ stream: true })`, which is
|
|
287
|
+
> guarded, or run `checkOutput` on `await stream.finalResponse()` yourself.
|
|
288
|
+
|
|
229
289
|
This is also how you guard **Groq, Together, OpenRouter, Fireworks, DeepInfra,
|
|
230
290
|
vLLM and Ollama** — anything you reach through an OpenAI-compatible `baseURL`
|
|
231
|
-
works, because the adapter is typed against the
|
|
232
|
-
|
|
291
|
+
works, because the adapter is typed against the wire shapes rather than against
|
|
292
|
+
OpenAI the company.
|
|
233
293
|
|
|
234
294
|
Non-streaming calls are already paid for by the time anything can run, so a
|
|
235
295
|
degenerate one throws `DegenerateOutputError` for your fallback layer to catch:
|
|
@@ -288,8 +348,80 @@ deferred to `end()`. `LOW_ENTROPY` is deferred too, for cost — it is ~100x the
|
|
|
288
348
|
other detectors, and everything it would have caught early is caught by
|
|
289
349
|
`REPETITION`, or by `TAIL_LOOP`'s character mode on non-spaced scripts.
|
|
290
350
|
|
|
291
|
-
|
|
292
|
-
`createStreamGuard`.
|
|
351
|
+
Every adapter shares this behaviour because they all drive the same
|
|
352
|
+
`createStreamGuard`. None of them reimplements it.
|
|
353
|
+
|
|
354
|
+
### Anthropic SDK
|
|
355
|
+
|
|
356
|
+
Same one wrap, same options:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
360
|
+
import { withOutputGuard } from 'llm-output-guard/anthropic';
|
|
361
|
+
import { presets } from 'llm-output-guard';
|
|
362
|
+
|
|
363
|
+
const client = withOutputGuard(new Anthropic(), {
|
|
364
|
+
...presets.chat,
|
|
365
|
+
onDegenerate: 'abort',
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await client.messages.create({ model, max_tokens, messages }); // guarded
|
|
369
|
+
await client.messages.create({ model, max_tokens, messages, stream: true }); // guarded
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Two things are specific to this API:
|
|
373
|
+
|
|
374
|
+
**Extended thinking is not read as the answer.** `thinking` blocks are the
|
|
375
|
+
model's reasoning, they are often longer than the answer, and they repeat
|
|
376
|
+
themselves as a matter of course while working a problem. Folding them into the
|
|
377
|
+
measured text would raise every repetition score on every thinking response and
|
|
378
|
+
flag the ones that thought hardest — so only `text` blocks are measured, and a
|
|
379
|
+
`thinking` block is not mistaken for a tool call either.
|
|
380
|
+
|
|
381
|
+
**Both of Anthropic's length stops map to `TRUNCATED`.** `max_tokens` passes
|
|
382
|
+
straight through; `model_context_window_exceeded` is the same event under a
|
|
383
|
+
different name and is normalised in the adapter. `refusal` is deliberately *not*
|
|
384
|
+
truncation — a refusal is a complete response that says no, which is a content
|
|
385
|
+
judgement this package does not make.
|
|
386
|
+
|
|
387
|
+
> **`messages.stream()` is not guarded**, for the same reason `responses.stream()`
|
|
388
|
+
> isn't: it returns a `MessageStream` — an event emitter with `.on()` and
|
|
389
|
+
> `.finalMessage()` — and guarding only its iteration would leave
|
|
390
|
+
> `.finalMessage()` unchecked. Use `create({ stream: true })`, or run
|
|
391
|
+
> `checkOutput` on `await stream.finalMessage()` yourself. `messages.batches` is
|
|
392
|
+
> unguarded too, and less interestingly: a batch is retrieved later as a file of
|
|
393
|
+
> results, so there is no response at `create` time to inspect.
|
|
394
|
+
|
|
395
|
+
`@anthropic-ai/sdk` is an **optional peer dependency**, declared
|
|
396
|
+
`>=0.60.0 <1.0.0` and verified at 0.60.0, 0.90.0 and 0.117.1 — each installing
|
|
397
|
+
the packed tarball and running the adapter for real, not just typechecking.
|
|
398
|
+
|
|
399
|
+
### Tool calls and agents
|
|
400
|
+
|
|
401
|
+
A model that answers by calling a tool returns no assistant text — OpenAI sends
|
|
402
|
+
`content: null` beside `tool_calls`, and the AI SDK sends a `content` array with
|
|
403
|
+
no `text` part. Handed to `checkOutput`, that is an empty string, and an empty
|
|
404
|
+
string scores `EMPTY`.
|
|
405
|
+
|
|
406
|
+
So **the presence of tool calls means the text, if any, is a preamble rather
|
|
407
|
+
than the answer**, and both adapters judge it as one:
|
|
408
|
+
|
|
409
|
+
| | On a tool-calling turn |
|
|
410
|
+
|---|---|
|
|
411
|
+
| No text at all | Nothing is judged, and nothing is reported to `onVerdict` |
|
|
412
|
+
| Text beside the call | `REPETITION`, `TAIL_LOOP` and `LOW_ENTROPY` still run |
|
|
413
|
+
| `TOO_SHORT` | Off — "Let me look that up" is sixteen characters and correct |
|
|
414
|
+
| `TRUNCATED` | Off — a preamble ends without terminal punctuation as a matter of course |
|
|
415
|
+
| `INVALID_JSON` | Off — the JSON is in the call arguments, which your provider already validated against the schema |
|
|
416
|
+
|
|
417
|
+
The redundancy detectors stay on because a model looping in its preamble is
|
|
418
|
+
still a model that is looping. `EMPTY` is not disarmed either: a response with
|
|
419
|
+
neither text nor tool calls still fails, which is the case this package exists
|
|
420
|
+
for.
|
|
421
|
+
|
|
422
|
+
Nothing is reported to `onVerdict` for a text-free tool call on purpose. Those
|
|
423
|
+
samples are what a `calibrate` run is built from, and a spike of `EMPTY: 1` in
|
|
424
|
+
them would describe your agent's tool use rather than any degeneration.
|
|
293
425
|
|
|
294
426
|
---
|
|
295
427
|
|
|
@@ -318,18 +450,34 @@ gives you a number that describes neither.
|
|
|
318
450
|
|
|
319
451
|
## Detectors
|
|
320
452
|
|
|
321
|
-
| Code | Catches | Signal |
|
|
322
|
-
|
|
323
|
-
| `EMPTY` | Whitespace, lone punctuation, `{}`, empty fences | Content presence |
|
|
324
|
-
| `TOO_SHORT` | Non-empty but useless | Length vs. minimum |
|
|
325
|
-
| `REPETITION` | Loops and stutters | Duplicate word n-gram fraction |
|
|
326
|
-
| `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window, over words or characters |
|
|
327
|
-
| `LOW_ENTROPY` | Character-level collapse, token artifacts | Hand-rolled LZ77 compression ratio |
|
|
328
|
-
| `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences/brackets |
|
|
329
|
-
| `INVALID_JSON` | Prose around the payload, missing keys | Parse + key contract |
|
|
330
|
-
| `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (coarse, opt-in) |
|
|
453
|
+
| Code | Catches | Signal | Exported as |
|
|
454
|
+
|---|---|---|---|
|
|
455
|
+
| `EMPTY` | Whitespace, lone punctuation, `{}`, empty fences | Content presence | `emptinessScore` |
|
|
456
|
+
| `TOO_SHORT` | Non-empty but useless | Length vs. minimum | `shortnessScore` |
|
|
457
|
+
| `REPETITION` | Loops and stutters | Duplicate word n-gram fraction | `repetitionScore` |
|
|
458
|
+
| `TAIL_LOOP` | Good start, then a stuck ending | Periodicity in the trailing window, over words or characters | `tailLoopScore`, `tailLoopDetail` |
|
|
459
|
+
| `LOW_ENTROPY` | Character-level collapse, token artifacts | Hand-rolled LZ77 compression ratio | `compressibilityScore`, `compressionRatio` |
|
|
460
|
+
| `TRUNCATED` | Cut off mid-thought | `finish_reason`, unbalanced fences/brackets | `truncationScore` |
|
|
461
|
+
| `INVALID_JSON` | Prose around the payload, missing keys, wrong types | Parse + key contract + optional schema | `jsonScore`, `stripFence` |
|
|
462
|
+
| `LANG_MISMATCH` | Answered in the wrong language | Function-word profile (coarse, opt-in) | `languageMismatchScore`, `languageProfile`, `supportedLanguages` |
|
|
463
|
+
|
|
464
|
+
Every detector is exported on its own if you only want one, and every name in
|
|
465
|
+
that last column is covered by semver — see **Stability**.
|
|
331
466
|
|
|
332
|
-
|
|
467
|
+
```ts
|
|
468
|
+
import { repetitionScore, tailLoopDetail, stripFence } from 'llm-output-guard';
|
|
469
|
+
|
|
470
|
+
repetitionScore(text); // 0..1, higher is worse
|
|
471
|
+
repetitionScore(text, { n: 4 }); // n-gram size
|
|
472
|
+
tailLoopDetail(text, { mode: 'char' }); // { score, mode } — which tokenizer ran
|
|
473
|
+
stripFence('```json\n{"a":1}\n```'); // '{"a":1}'
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Each takes `(text, options?)` and returns a `0..1` score, with three exceptions
|
|
477
|
+
worth knowing: `shortnessScore(text, minChars)` takes its minimum positionally,
|
|
478
|
+
`stripFence` returns a string, and `jsonScore` / `tailLoopDetail` return a detail
|
|
479
|
+
object rather than a bare number. `supportedLanguages` is a value, not a
|
|
480
|
+
function — the array `['id', 'en', 'es']`.
|
|
333
481
|
|
|
334
482
|
## Presets
|
|
335
483
|
|
|
@@ -390,6 +538,42 @@ the bulk and a cluster of outliers is real separation observed in your data
|
|
|
390
538
|
rather than an assumption about rarity — and when that hole rests on one or
|
|
391
539
|
two samples, the report says so.
|
|
392
540
|
|
|
541
|
+
### The same thing, as a function
|
|
542
|
+
|
|
543
|
+
The CLI is a wrapper. If your scores already live somewhere the shell cannot
|
|
544
|
+
reach them — a metrics store, a warehouse query, a test — call `calibrate`
|
|
545
|
+
directly. It takes the same flat objects the JSONL format describes:
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import { calibrate } from 'llm-output-guard';
|
|
549
|
+
|
|
550
|
+
const { n, summaries } = calibrate(
|
|
551
|
+
[
|
|
552
|
+
{ REPETITION: 0.03, TAIL_LOOP: 0 },
|
|
553
|
+
{ REPETITION: 0.91, TAIL_LOOP: 0.88, modes: { TAIL_LOOP: 'char' } },
|
|
554
|
+
// ...one entry per logged verdict
|
|
555
|
+
],
|
|
556
|
+
{ falsePositiveRate: 0.001 },
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
for (const s of summaries) {
|
|
560
|
+
s.code; // 'REPETITION'
|
|
561
|
+
s.mode; // 'word' | 'char', when the samples recorded one
|
|
562
|
+
s.suggested; // threshold flagging falsePositiveRate of this sample
|
|
563
|
+
s.gap; // { below, above, count, share } | null — stronger evidence
|
|
564
|
+
s.distribution; // { n, nonZero, min, max, p50, p90, p99, p999 }
|
|
565
|
+
s.caveats; // everything that makes `suggested` untrustworthy
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
`modes` rides along in the same object and is not read as a score. Log it, and
|
|
570
|
+
`summaries` comes back segmented — one entry per `code`+`mode` — for the reason
|
|
571
|
+
in the paragraph above. `summarise(code, scores, options)` is exported too, for
|
|
572
|
+
when you have one detector's numbers already grouped.
|
|
573
|
+
|
|
574
|
+
**Read `caveats` before `suggested`.** It is where a sample too small for the
|
|
575
|
+
requested rate says so, and a `suggested` number carries no warning of its own.
|
|
576
|
+
|
|
393
577
|
## On thresholds
|
|
394
578
|
|
|
395
579
|
A miss is annoying. **A false positive is worse**: a healthy response gets discarded and retried against a slower provider for nothing.
|
|
@@ -531,13 +715,15 @@ onward; under `0.x` they described an intent, and the surface was frozen — exp
|
|
|
531
715
|
by export — in the 1.0.0 release.
|
|
532
716
|
|
|
533
717
|
**The public API is:** everything exported from `llm-output-guard`, plus
|
|
534
|
-
`outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk
|
|
535
|
-
`withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from
|
|
536
|
-
Each subpath is its own contract; the
|
|
537
|
-
today and are free to diverge, so an option added to
|
|
538
|
-
the
|
|
539
|
-
no stability guarantee, and may move in any release
|
|
540
|
-
`
|
|
718
|
+
`outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk`, and
|
|
719
|
+
`withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from each of
|
|
720
|
+
`./openai` and `./anthropic`. Each subpath is its own contract; the adapters
|
|
721
|
+
share internal base types today and are free to diverge, so an option added to
|
|
722
|
+
one is not a promise about the others. Anything not exported from those four
|
|
723
|
+
entry points is internal, has no stability guarantee, and may move in any release
|
|
724
|
+
— `internal/proxy-guard.ts` and `internal/tool-calls.ts` included, however much
|
|
725
|
+
behaviour they carry. The list is asserted in `test/surface.test.ts`, so an
|
|
726
|
+
export cannot join it by accident.
|
|
541
727
|
|
|
542
728
|
**Threshold and preset values are behaviour, not implementation.** This is the
|
|
543
729
|
interesting case, so it gets a rule of its own:
|
|
@@ -577,6 +763,8 @@ patches.
|
|
|
577
763
|
## Limitations
|
|
578
764
|
|
|
579
765
|
- Not a hallucination detector. It measures *shape*, never truth.
|
|
766
|
+
- Tool *arguments* are not checked, only the prose beside them. A model that loops inside a JSON argument string is invisible here — your provider validates those against the schema you gave it.
|
|
767
|
+
- `openai`'s `responses.stream()` helper is not wrapped. See the note above; `create({ stream: true })` is.
|
|
580
768
|
- `REPETITION` does not work on Chinese, Japanese or Thai. See above — this is a known, measured gap, not an oversight.
|
|
581
769
|
- Language detection is a function-word heuristic covering `id`/`en`/`es`. Opt-in, and unreliable under 25 words.
|
|
582
770
|
- Truncation from a missing full stop is weak evidence, scored 0.55 and left below the default thresholds on purpose. Lower `maxTruncation` to ~0.5 to catch it, and expect false positives.
|
package/dist/ai-sdk.cjs
CHANGED
|
@@ -154,8 +154,14 @@ function stripFence(text) {
|
|
|
154
154
|
const fenced = text.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
155
155
|
return fenced ? fenced[1] : text.trim();
|
|
156
156
|
}
|
|
157
|
+
function describe(issue) {
|
|
158
|
+
const path = (issue.path ?? []).map(
|
|
159
|
+
(segment) => typeof segment === "object" && segment !== null && "key" in segment ? String(segment.key) : String(segment)
|
|
160
|
+
).join(".");
|
|
161
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
162
|
+
}
|
|
157
163
|
function jsonScore(text, options = {}) {
|
|
158
|
-
const { allowFence = true, requiredKeys = [] } = options;
|
|
164
|
+
const { allowFence = true, requiredKeys = [], schema } = options;
|
|
159
165
|
const candidate = allowFence ? stripFence(text) : text.trim();
|
|
160
166
|
let value;
|
|
161
167
|
try {
|
|
@@ -173,6 +179,19 @@ function jsonScore(text, options = {}) {
|
|
|
173
179
|
return { score: 1, value, reason: "missing-keys", missingKeys: missing };
|
|
174
180
|
}
|
|
175
181
|
}
|
|
182
|
+
if (schema) {
|
|
183
|
+
const result = schema["~standard"].validate(value);
|
|
184
|
+
if (typeof result?.then === "function") {
|
|
185
|
+
throw new TypeError(
|
|
186
|
+
"llm-output-guard: `schema` must validate synchronously, and this one returned a promise. checkOutput is synchronous by design. Remove the async refinement, or validate the payload yourself after checkOutput returns."
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const sync = result;
|
|
190
|
+
if (sync.issues) {
|
|
191
|
+
return { score: 1, value, reason: "schema", issues: sync.issues.map(describe) };
|
|
192
|
+
}
|
|
193
|
+
return { score: 0, value: sync.value };
|
|
194
|
+
}
|
|
176
195
|
return { score: 0, value };
|
|
177
196
|
}
|
|
178
197
|
|
|
@@ -297,14 +316,15 @@ function checkOutput(text, options = {}) {
|
|
|
297
316
|
if (opts.expectJson) {
|
|
298
317
|
const result = jsonScore(text, {
|
|
299
318
|
allowFence: opts.allowJsonFence,
|
|
300
|
-
requiredKeys: opts.requiredKeys
|
|
319
|
+
requiredKeys: opts.requiredKeys,
|
|
320
|
+
schema: opts.schema
|
|
301
321
|
});
|
|
302
322
|
parsedJson = result.value;
|
|
303
323
|
add(
|
|
304
324
|
"INVALID_JSON",
|
|
305
325
|
result.score,
|
|
306
326
|
0,
|
|
307
|
-
result.reason === "missing-keys" ? `JSON is missing required keys: ${result.missingKeys?.join(", ")}.` : "Response is not parseable JSON."
|
|
327
|
+
result.reason === "missing-keys" ? `JSON is missing required keys: ${result.missingKeys?.join(", ")}.` : result.reason === "schema" ? `JSON does not match the schema: ${result.issues?.join("; ")}.` : "Response is not parseable JSON."
|
|
308
328
|
);
|
|
309
329
|
}
|
|
310
330
|
if (opts.expectLang) {
|
|
@@ -405,7 +425,20 @@ function createStreamGuard(options = {}) {
|
|
|
405
425
|
};
|
|
406
426
|
}
|
|
407
427
|
|
|
428
|
+
// src/internal/tool-calls.ts
|
|
429
|
+
var TOOL_CALL_PREAMBLE = {
|
|
430
|
+
minLength: 0,
|
|
431
|
+
maxTruncation: null,
|
|
432
|
+
expectJson: false,
|
|
433
|
+
finishReason: void 0
|
|
434
|
+
};
|
|
435
|
+
function checkPreamble(text, options) {
|
|
436
|
+
if (text.trim().length === 0) return null;
|
|
437
|
+
return checkOutput(text, { ...options, ...TOOL_CALL_PREAMBLE });
|
|
438
|
+
}
|
|
439
|
+
|
|
408
440
|
// src/ai-sdk.ts
|
|
441
|
+
var isToolPart = (part) => part.type.startsWith("tool-");
|
|
409
442
|
function finishReasonOf(value) {
|
|
410
443
|
if (typeof value === "string") return value;
|
|
411
444
|
if (value && typeof value === "object") return value.unified ?? value.raw;
|
|
@@ -443,7 +476,13 @@ function outputGuard(options = {}) {
|
|
|
443
476
|
doGenerate
|
|
444
477
|
}) {
|
|
445
478
|
const result = await doGenerate();
|
|
446
|
-
const
|
|
479
|
+
const content = result.content ?? [];
|
|
480
|
+
const text = content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("");
|
|
481
|
+
if (content.some(isToolPart)) {
|
|
482
|
+
const verdict = checkPreamble(text, guardOptions);
|
|
483
|
+
if (verdict) act(verdict, false);
|
|
484
|
+
return result;
|
|
485
|
+
}
|
|
447
486
|
act(
|
|
448
487
|
checkOutput(text, {
|
|
449
488
|
...guardOptions,
|
|
@@ -459,12 +498,14 @@ function outputGuard(options = {}) {
|
|
|
459
498
|
const result = await doStream();
|
|
460
499
|
const guard = createStreamGuard(guardOptions);
|
|
461
500
|
let fired = false;
|
|
501
|
+
let sawToolCall = false;
|
|
462
502
|
let finishReason;
|
|
463
503
|
const guarded = result.stream.pipeThrough(
|
|
464
504
|
new TransformStream({
|
|
465
505
|
transform(part, controller) {
|
|
466
506
|
controller.enqueue(part);
|
|
467
507
|
if (part.type === "finish") finishReason = part.finishReason;
|
|
508
|
+
if (isToolPart(part)) sawToolCall = true;
|
|
468
509
|
if (part.type !== "text-delta" || fired) return;
|
|
469
510
|
const verdict = guard.push(part.delta ?? "");
|
|
470
511
|
if (!verdict || verdict.ok) return;
|
|
@@ -478,9 +519,13 @@ function outputGuard(options = {}) {
|
|
|
478
519
|
}
|
|
479
520
|
},
|
|
480
521
|
flush() {
|
|
481
|
-
if (
|
|
482
|
-
|
|
522
|
+
if (fired) return;
|
|
523
|
+
if (sawToolCall) {
|
|
524
|
+
const verdict = checkPreamble(guard.text, guardOptions);
|
|
525
|
+
if (verdict) onVerdict?.(verdict, { streaming: true });
|
|
526
|
+
return;
|
|
483
527
|
}
|
|
528
|
+
onVerdict?.(guard.end(finishReasonOf(finishReason)), { streaming: true });
|
|
484
529
|
}
|
|
485
530
|
})
|
|
486
531
|
);
|