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 {