diff --git a/package-lock.json b/package-lock.json index 1eb104f18..e12304a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21232,6 +21232,7 @@ "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", "jimp": "^1.6.1", + "json5": "^2.2.3", "jsonwebtoken": "^9.0.2", "node-cron": "^4.2.1", "nodemailer": "^9.0.1", diff --git a/server/package.json b/server/package.json index ff1e485d1..2898d7df9 100644 --- a/server/package.json +++ b/server/package.json @@ -40,6 +40,7 @@ "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", "jimp": "^1.6.1", + "json5": "^2.2.3", "jsonwebtoken": "^9.0.2", "node-cron": "^4.2.1", "nodemailer": "^9.0.1", diff --git a/server/src/nest/llm-parse/clients/openai-compatible.client.ts b/server/src/nest/llm-parse/clients/openai-compatible.client.ts index c3352deec..f2a6aa50f 100644 --- a/server/src/nest/llm-parse/clients/openai-compatible.client.ts +++ b/server/src/nest/llm-parse/clients/openai-compatible.client.ts @@ -1,5 +1,6 @@ import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface'; import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract'; +import { parseLenientJson } from '../lenient-json'; import { safeFetchLlm } from '../../../utils/ssrfGuard'; // Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then @@ -110,27 +111,16 @@ export class OpenAiCompatibleClient implements LlmExtractionClient { } } -/** Strip code fences and JSON.parse; `null` on failure. */ -function parseJson(content: string | undefined | null): unknown { - if (!content) return null; - const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim(); - try { - return JSON.parse(stripped); - } catch { - return null; - } -} - /** Parse a NuExtract response and map its flat template output to KiReservation nodes. */ function parseNuExtract(content: string | undefined | null): Record[] { - return nuExtractToKiReservations(parseJson(content)); + return nuExtractToKiReservations(parseLenientJson(content)); } const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.'; -/** Tolerant parse: strip code fences, JSON.parse, pull `reservations`. `[]` on failure. */ +/** Tolerant parse: strip code fences, JSON(5).parse, pull `reservations`. `[]` on failure. */ function parseReservations(content: string | undefined | null): Record[] { - const parsed = parseJson(content); + const parsed = parseLenientJson(content); if (Array.isArray(parsed)) return parsed as Record[]; if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { reservations?: unknown }).reservations)) { return (parsed as { reservations: Record[] }).reservations; diff --git a/server/src/nest/llm-parse/lenient-json.ts b/server/src/nest/llm-parse/lenient-json.ts new file mode 100644 index 000000000..5a9f930cf --- /dev/null +++ b/server/src/nest/llm-parse/lenient-json.ts @@ -0,0 +1,31 @@ +import JSON5 from 'json5'; + +/** + * Parse LLM output that is *meant* to be JSON but may not be strict JSON. + * + * Cloud providers reached through the OpenAI-compatible endpoint don't all honour + * `response_format` faithfully — Gemini in particular emits JavaScript-object-literal + * text: single-quoted strings, unquoted keys, and trailing commas (#1638), e.g. + * + * [ { '@type': 'LodgingReservation', checkinTime: '2026-08-28T00:00:00', price: 146.25, } ] + * + * Strict `JSON.parse` throws on all three, so the reservation list came back empty and + * the UI showed nothing. We try strict JSON first (the common, cheapest path) and fall + * back to JSON5, which accepts exactly that relaxed superset. Returns `null` on failure. + * + * The leading/trailing code-fence strip stays here because some models still wrap the + * payload in a ```json fence even when asked for raw JSON. + */ +export function parseLenientJson(content: string | undefined | null): unknown { + if (!content) return null; + const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim(); + try { + return JSON.parse(stripped); + } catch { + try { + return JSON5.parse(stripped); + } catch { + return null; + } + } +} diff --git a/server/src/nest/llm-parse/router/ollama-format.client.ts b/server/src/nest/llm-parse/router/ollama-format.client.ts index f3cf83552..49db787fe 100644 --- a/server/src/nest/llm-parse/router/ollama-format.client.ts +++ b/server/src/nest/llm-parse/router/ollama-format.client.ts @@ -11,6 +11,7 @@ * tool/response_format and keep using the existing clients.) */ +import { parseLenientJson } from '../lenient-json'; import { safeFetchLlm } from '../../../utils/ssrfGuard'; const TIMEOUT_MS = 300_000; @@ -34,17 +35,6 @@ export function toNativeBase(baseUrl: string): string { return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, ''); } -/** Strip code fences and JSON.parse; returns null on failure. */ -function parseJson(content: string | undefined | null): unknown { - if (!content) return null; - const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim(); - try { - return JSON.parse(stripped); - } catch { - return null; - } -} - /** * Run one schema-constrained chat completion against Ollama's native `/api/chat`. * Returns the parsed JSON object (constrained to `schema`), or null if the request @@ -94,6 +84,6 @@ export async function extractEnforced(input: EnforcedExtractInput): Promise) : null; } diff --git a/server/tests/unit/nest/llm-parse/clients.test.ts b/server/tests/unit/nest/llm-parse/clients.test.ts index 78562b2a2..95dd221d9 100644 --- a/server/tests/unit/nest/llm-parse/clients.test.ts +++ b/server/tests/unit/nest/llm-parse/clients.test.ts @@ -47,6 +47,23 @@ describe('OpenAiCompatibleClient', () => { expect(out).toEqual([{ '@type': 'TrainReservation' }]); }); + it('tolerates single-quoted, non-strict JSON output (Gemini, #1638)', async () => { + mockFetch(() => + jsonResponse({ + choices: [ + { + message: { + content: + "[{ '@type': 'LodgingReservation', checkinTime: '2026-08-28T00:00:00', price: 146.25, }]", + }, + }, + ], + }), + ); + const out = await new OpenAiCompatibleClient().extract(baseInput); + expect(out).toEqual([{ '@type': 'LodgingReservation', checkinTime: '2026-08-28T00:00:00', price: 146.25 }]); + }); + it('returns [] on malformed content', async () => { mockFetch(() => jsonResponse({ choices: [{ message: { content: 'not json' } }] })); expect(await new OpenAiCompatibleClient().extract(baseInput)).toEqual([]); diff --git a/server/tests/unit/nest/llm-parse/lenient-json.test.ts b/server/tests/unit/nest/llm-parse/lenient-json.test.ts new file mode 100644 index 000000000..841f4aa52 --- /dev/null +++ b/server/tests/unit/nest/llm-parse/lenient-json.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; + +import { parseLenientJson } from '../../../../src/nest/llm-parse/lenient-json'; + +describe('parseLenientJson', () => { + it('parses strict JSON', () => { + expect(parseLenientJson('{"a":1,"b":"x"}')).toEqual({ a: 1, b: 'x' }); + }); + + it('strips a ```json code fence', () => { + expect(parseLenientJson('```json\n[{"@type":"FlightReservation"}]\n```')).toEqual([ + { '@type': 'FlightReservation' }, + ]); + }); + + it('parses single-quoted, unquoted-key, trailing-comma output (Gemini, #1638)', () => { + const gemini = `[ + { + '@type': 'LodgingReservation', + checkinTime: '2026-08-28T00:00:00', + checkoutTime: '2026-08-30T11:00:00', + price: 146.25, + priceCurrency: 'EUR', + } + ]`; + expect(parseLenientJson(gemini)).toEqual([ + { + '@type': 'LodgingReservation', + checkinTime: '2026-08-28T00:00:00', + checkoutTime: '2026-08-30T11:00:00', + price: 146.25, + priceCurrency: 'EUR', + }, + ]); + }); + + it('parses a code-fenced non-strict object', () => { + expect(parseLenientJson("```\n{ reservations: [{ '@type': 'TrainReservation' }] }\n```")).toEqual({ + reservations: [{ '@type': 'TrainReservation' }], + }); + }); + + it('returns null on empty or truly unparseable input', () => { + expect(parseLenientJson('')).toBeNull(); + expect(parseLenientJson(null)).toBeNull(); + expect(parseLenientJson(undefined)).toBeNull(); + expect(parseLenientJson('this is prose, not json')).toBeNull(); + }); +});