Skip to content

Commit b0cfe00

Browse files
Programcodeclaude
andcommitted
Reject ambiguous or embedded JSON in the local repair
Review of 451eae7 found three ways the parser took the wrong text: - the fence regex backtracked for seconds on long whitespace, and ran before the plain parse; - a fence inside a string of an unfinished object was taken as the answer; - the first parsable span won, so "{} means omitted" beat the real object. Plain JSON now parses first, a fence counts only when it wraps the whole reply (found with indexOf, no regex), and a span is accepted only when it is the one span in the reply that parses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 451eae7 commit b0cfe00

2 files changed

Lines changed: 29 additions & 9 deletions

File tree

‎supabase/functions/ai/provider-errors_test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,15 @@ Deno.test("common JSON slips are repaired locally instead of paying for a retry"
5252
assertEquals(parse('{"a":"keep , ] inside strings",}'), { a: "keep , ] inside strings" });
5353
// A broken outer object never yields one of its nested objects.
5454
assertThrows(() => parse('{"case": {"title": "x"}, oops}'));
55-
// A reply cut off mid-object is not completed.
55+
// A reply cut off mid-object is not completed, even around a fenced example.
5656
assertThrows(() => parse('{"contentions": [{"title": "x"}, {"title": "y"'));
57+
assertThrows(() => parse('{"response":"Example:\n```\n{"response":"inner"}\n```\nunfinished'));
58+
// Two objects that both parse leave the answer ambiguous.
59+
assertThrows(() => parse('Note: {} means omitted. Answer: {"response":"Use evidence."}'));
60+
// Fence-like text inside valid JSON parses at once.
61+
const started = performance.now();
62+
assertEquals(parse(JSON.stringify({ a: "```" + " ".repeat(20_000) + "x" })), { a: "```" + " ".repeat(20_000) + "x" });
63+
assertEquals(performance.now() - started < 1_000, true);
5764
});
5865

5966
Deno.test("a reply cut at the output cap is not retried, and a schema is sent as strict json_schema", async () => {

‎supabase/functions/ai/provider.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,21 +74,34 @@ export function parseProviderContent(
7474
* second answer, so the common slips are fixed here first: markdown fences,
7575
* prose around the object, trailing commas, and raw line breaks inside
7676
* strings. A reply cut off mid-object is not completed: that would ship a
77-
* partial result as if it were whole.
77+
* partial result as if it were whole. When prose holds more than one object
78+
* that parses, the answer is ambiguous and the reply is rejected.
7879
*/
7980
export function parseJsonFromText(text: string, providerName: string) {
8081
if (!text) throw new Error(`${providerName} returned empty response content`);
81-
const candidates = [text];
82-
const fenced = text.match(/```[a-z]*\s*([\s\S]*?)\s*```/i)?.[1];
83-
if (fenced) candidates.push(fenced);
84-
candidates.push(...balancedObjects(text));
85-
for (const candidate of candidates) {
86-
try { return JSON.parse(candidate); } catch { /* try the repaired text */ }
87-
try { return JSON.parse(repairJsonText(candidate)); } catch { /* next candidate */ }
82+
const whole = parseOrRepair(text);
83+
if (whole.ok) return whole.value;
84+
// A fence counts only when it wraps the reply. One inside a string of an
85+
// unfinished object is an example, not the answer.
86+
const trimmed = text.trim();
87+
if (trimmed.startsWith("```")) {
88+
const open = trimmed.indexOf("\n");
89+
const close = trimmed.lastIndexOf("```");
90+
if (open !== -1 && close > open) {
91+
const fenced = parseOrRepair(trimmed.slice(open + 1, close));
92+
if (fenced.ok) return fenced.value;
93+
}
8894
}
95+
const parsed = balancedObjects(text).map(parseOrRepair).filter((result): result is { ok: true; value: unknown } => result.ok);
96+
if (parsed.length === 1) return parsed[0].value;
8997
throw new InvalidJsonResponseError(providerName, text);
9098
}
9199

100+
function parseOrRepair(text: string): { ok: true; value: unknown } | { ok: false } {
101+
try { return { ok: true, value: JSON.parse(text) }; } catch { /* try the repaired text */ }
102+
try { return { ok: true, value: JSON.parse(repairJsonText(text)) }; } catch { return { ok: false }; }
103+
}
104+
92105
// Prose before the answer may itself hold braces, so a few starts are tried.
93106
const MAX_OBJECT_STARTS = 8;
94107

0 commit comments

Comments
 (0)