From 7424d138583e2764bb75cebe1b6c3cb705664241 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Fri, 21 Aug 2026 17:52:25 +1000 Subject: [PATCH] intake: only a reply that finished is a reply the schema constrained liteLLMExtractor rejected finish_reason "length" and accepted everything else, so a content_filter or tool_calls stop was parsed as if the strict json_schema had held. Reject any reason present and not "stop", naming it. --- src/lib/intake-extract.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/lib/intake-extract.ts b/src/lib/intake-extract.ts index 189b752..98919a3 100644 --- a/src/lib/intake-extract.ts +++ b/src/lib/intake-extract.ts @@ -185,9 +185,19 @@ export function liteLLMExtractor(opts: { }); if (!response.ok) throw new ExtractionError(`LiteLLM returned ${response.status}`); const raw = (await response.json()) as { choices?: { message?: { content?: string }; finish_reason?: string }[] }; - // A truncated reply can still parse once the JSON is closed, which quietly - // drops the tail of a long receipt. Losing lines silently is worse than failing. - if (raw.choices?.[0]?.finish_reason === "length") throw new ExtractionError("The receipt was too long to read in one pass."); + // Only `stop` means the strict schema was satisfied. A truncated reply can + // still parse once the JSON is closed, which quietly drops the tail of a + // long receipt; a `content_filter` or `tool_calls` stop is a different reply + // shape entirely. Accepting anything but `stop` accepts a reply the schema + // never constrained — losing lines silently is worse than failing. + const finish = raw.choices?.[0]?.finish_reason; + if (finish && finish !== "stop") { + throw new ExtractionError( + finish === "length" + ? "The receipt was too long to read in one pass." + : `The model stopped before it finished (${finish}).` + ); + } const content = raw.choices?.[0]?.message?.content; if (!content) throw new ExtractionError("LiteLLM returned no content"); try {