llm-output-guard 1.11.4 → 1.12.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 CHANGED
@@ -349,7 +349,7 @@ re-released as 0.5.0. The rule it broke is the one in the table above.
349
349
  - `PROMPT_ECHO` cannot tell a degenerate echo from a rewrite or translation — the difference is in what you asked for, not in the text.
350
350
  - `SCRIPT_MISMATCH` and `PROMPT_ECHO` do not run mid-stream by default: both measure a property of the whole response, and a mid-stream check reads a trailing window. `earlyDocumentChecks: true` opts in, with a measured false-positive risk — see **[docs/streaming.md](docs/streaming.md)**.
351
351
  - Tool *arguments* are measured only with `checkToolArguments: true`, non-streaming responses only.
352
- - `openai`'s `responses.stream()` helper is not wrapped; `create({ stream: true })` is.
352
+ - `anthropic`'s `messages.stream()` helper is not wrapped; `create({ stream: true })` is. `openai`'s `responses.stream()` **is** — including `finalResponse()` and `done()` — but an `on()`-only consumer gets the report without an exception, because a callback has no error channel.
353
353
  - Truncation from a missing full stop is weak evidence, scored 0.55 and deliberately left below the defaults. Lower `maxTruncation` to ~0.5 to catch it, and expect false positives.
354
354
  - A JSON array of repeated identical records reads as a loop and fails from three records up. Set `redundancyScope: 'jsonValues'`.
355
355
  - `AGENT_LOOP` needs an *exact* cycle. An agent circling without repeating — `build`, read a file, `build`, read another, `build` — is **not** detected: the only signal that reads it scores that 0.444 against a healthy edit/test rhythm's 0.375, a margin too small to ship. Measured, rejected, and pinned by a test.
@@ -667,6 +667,77 @@ function checkPreamble(text, options) {
667
667
  return checkOutput(text, { ...options, ...TOOL_CALL_PREAMBLE });
668
668
  }
669
669
 
670
+ // src/internal/event-stream-guard.ts
671
+ function guardEventStream(stream, reader, options) {
672
+ const { onVerdict, onDegenerate = "throw", ...checkOptions } = options;
673
+ const guard = createStreamGuard(checkOptions);
674
+ let failure = null;
675
+ let sawToolCall = false;
676
+ let finishReason;
677
+ let ended = false;
678
+ const fire = (verdict) => {
679
+ failure = verdict;
680
+ onVerdict?.(verdict, { streaming: true });
681
+ if (onDegenerate === "ignore") return;
682
+ stream.abort?.();
683
+ stream.controller?.abort();
684
+ };
685
+ if (typeof stream.on === "function") {
686
+ stream.on(reader.delta.event, ((payload) => {
687
+ if (failure || ended) return;
688
+ const verdict = guard.push(reader.delta.read(payload));
689
+ if (verdict && !verdict.ok) fire(verdict);
690
+ }));
691
+ for (const event of reader.toolCall.events) {
692
+ stream.on(event, ((payload) => {
693
+ if (reader.toolCall.is(payload)) sawToolCall = true;
694
+ }));
695
+ }
696
+ for (const event of reader.terminal.events) {
697
+ stream.on(event, ((payload) => {
698
+ if (ended || failure) return;
699
+ ended = true;
700
+ finishReason = reader.terminal.finishReason(payload) ?? finishReason;
701
+ const verdict = sawToolCall ? checkPreamble(guard.text, checkOptions) : guard.end(finishReason);
702
+ if (verdict) onVerdict?.(verdict, { streaming: true });
703
+ }));
704
+ }
705
+ }
706
+ const ours = () => failure !== null;
707
+ async function* guarded() {
708
+ try {
709
+ for await (const event of stream) yield event;
710
+ } catch (error) {
711
+ if (!ours()) throw error;
712
+ if (onDegenerate === "throw") throw new DegenerateOutputError(failure);
713
+ return;
714
+ }
715
+ if (ours() && onDegenerate === "throw") throw new DegenerateOutputError(failure);
716
+ }
717
+ const wrapTerminal = (fn) => async function(...args) {
718
+ try {
719
+ const result = await fn.apply(stream, args);
720
+ if (ours() && onDegenerate === "throw") throw new DegenerateOutputError(failure);
721
+ return result;
722
+ } catch (error) {
723
+ if (error instanceof DegenerateOutputError) throw error;
724
+ if (ours() && onDegenerate === "throw") throw new DegenerateOutputError(failure);
725
+ throw error;
726
+ }
727
+ };
728
+ return new Proxy(stream, {
729
+ get(target, prop) {
730
+ if (prop === Symbol.asyncIterator) return guarded;
731
+ const value = Reflect.get(target, prop, target);
732
+ if (typeof value !== "function") return value;
733
+ if (prop === "finalResponse" || prop === "done") {
734
+ return wrapTerminal(value);
735
+ }
736
+ return value.bind(target);
737
+ }
738
+ });
739
+ }
740
+
670
741
  // src/internal/tool-arguments.ts
671
742
  var ARGUMENT_SCOPE = {
672
743
  minLength: 0,
@@ -800,10 +871,18 @@ function guardClient(client, paths, options = {}) {
800
871
  }
801
872
  });
802
873
  };
803
- const wrapCreate = (create, surface) => function(...args) {
874
+ const wrapCreate = (create, guardedPath) => function(...args) {
875
+ const { surface } = guardedPath;
804
876
  const prepared = surface.abortable?.(args);
805
877
  const callArgs = prepared?.args ?? args;
806
878
  const result = create.apply(this, callArgs);
879
+ if (guardedPath.eventStream && result && typeof result === "object" && typeof result[Symbol.asyncIterator] === "function") {
880
+ return guardEventStream(result, guardedPath.eventStream, {
881
+ ...optionsFor(surface, callArgs[0]),
882
+ onVerdict,
883
+ onDegenerate
884
+ });
885
+ }
807
886
  if (!result || typeof result.then !== "function") return result;
808
887
  let checked;
809
888
  const guarded = () => checked ??= Promise.resolve(result).then(
@@ -832,9 +911,9 @@ function guardClient(client, paths, options = {}) {
832
911
  if (matching.length === 0) return typeof value === "function" ? value.bind(obj) : value;
833
912
  const terminal = matching.find((g) => g.path.length === 1);
834
913
  if (terminal && typeof value === "function") {
835
- return wrapCreate(value.bind(obj), terminal.surface);
914
+ return wrapCreate(value.bind(obj), terminal);
836
915
  }
837
- const deeper = matching.filter((g) => g.path.length > 1).map((g) => ({ path: g.path.slice(1), surface: g.surface }));
916
+ const deeper = matching.filter((g) => g.path.length > 1).map((g) => ({ ...g, path: g.path.slice(1) }));
838
917
  if (deeper.length === 0 || !value || typeof value !== "object") {
839
918
  return typeof value === "function" ? value.bind(obj) : value;
840
919
  }