Browse Source

fix: repair the JSON a model mangles instead of paying to ask again

minimax-m3:cloud, asked with format:"json", returns a document whose opening
`{"` never arrives - the reply begins at the first key, `humanCount": 0`. The
chat nodes treated an unparseable reply as worth retrying, so a quirk that is
deterministic for that model cost four paid calls per image and could not
succeed on any of them. The user pays for overage on that API.

smartbotic.utils.repairJson recovers the shapes a model mangles JSON into and
returns WHAT it changed, not just a value, so the node can log it: "the reply
was not valid JSON and was repaired (added the missing opening brace and
quote). The model is not honouring the requested format." Repairing silently is
how a model that has stopped following its schema stays invisible.

The obvious answer was the jsonrepair library, which is what n8n users reach
for. It does not fix this. Tested against 3.x: it handles a missing closing
brace, single quotes, a trailing comma and an unquoted key, and answers
`Unexpected character ":"` on ours. This handles those four and that one.

The first version of this fix was wrong, and passed its test anyway. The test
used a retyped copy of the reply that had silently restored the opening quote,
so the repairer only had to prepend `{` - while the real bytes need `{"`,
because the model drops the brace and the quote together. The test now embeds
the 791 characters verbatim from execution exec_a0cf664e and says why it must
not be retyped. Third instance today of a test agreeing with an assumption
instead of with reality; captured production data is what breaks the tie.
fszontagh 3 weeks ago
parent
commit
22c5616183

+ 20 - 1
nodes/ai/deepinfra-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/deepseek-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/fireworks-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/groq-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/mistral-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/ollama-chat.js

@@ -181,6 +181,25 @@ function getPath(root, path) {
     return current;
 }
 
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -396,7 +415,7 @@ module.exports = {
             try {
                 content = callOllama(config, image.base64, headers);
                 if (wantJson) {
-                    parsed = JSON.parse(stripFences(content));
+                    parsed = parseModelJson(stripFences(content), 'Ollama');
                 }
                 lastError = '';
                 break;

+ 20 - 1
nodes/ai/openai-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/openrouter-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/perplexity-chat.js

@@ -269,6 +269,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -491,7 +510,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/together-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
nodes/ai/xai-chat.js

@@ -277,6 +277,25 @@ function getPath(root, path) {
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }
+    if (repaired.repairs.length > 0) {
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }
+    return repaired.value;
+}
+
 function stripFences(text) {
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {
@@ -517,7 +536,7 @@ async function execute(config, input, context) {
         try {
             answer = chat(config, image, headers);
             if (wantJson) {
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }
             lastError = '';
             break;

+ 20 - 1
scripts/gen-openai-compatible-nodes.py

@@ -392,6 +392,25 @@ function getPath(root, path) {{
 
 // Models are asked for JSON and answer with a fenced code block often enough
 // that refusing it would mean retrying a perfectly good answer.
+// A model asked for JSON that returns something almost-JSON is repaired rather
+// than retried. The retry existed for a model having a bad moment, but where
+// the fault is deterministic - a model that always omits the opening brace -
+// every extra attempt is another paid call for the same malformed answer.
+// What was repaired is logged, so a model that has started ignoring the schema
+// is visible rather than quietly patched over on every run.
+function parseModelJson(text, provider) {{
+    var repaired = smartbotic.utils.repairJson(text);
+    if (!repaired.ok) {{
+        // Nothing salvageable - let the strict parser raise the real message.
+        return JSON.parse(text);
+    }}
+    if (repaired.repairs.length > 0) {{
+        smartbotic.log.warn(provider + ': the reply was not valid JSON and was repaired (' +
+            repaired.repairs.join('; ') + '). The model is not honouring the requested format.');
+    }}
+    return repaired.value;
+}}
+
 function stripFences(text) {{
     const out = String(text || '').trim();
     if (out.indexOf('```') === -1) {{
@@ -612,7 +631,7 @@ async function execute(config, input, context) {{
         try {{
             answer = chat(config, image, headers);
             if (wantJson) {{
-                parsed = JSON.parse(stripFences(answer.content));
+                parsed = parseModelJson(stripFences(answer.content), PROVIDER);
             }}
             lastError = '';
             break;

+ 100 - 0
src/runner/engine/script_engine.cpp

@@ -4502,6 +4502,99 @@ static JSValue js_smtp_send(JSContext* ctx, JSValue this_val, int argc, JSValue*
 // it came from. Since almost every such value is a server's XML or HTML reply,
 // the parser is wrapped once for every node so the failure describes what
 // arrived instead of only which character stopped it.
+// smartbotic.utils.repairJson - best-effort recovery of JSON a model mangled.
+//
+// A model asked for JSON usually returns JSON, and when it does not the damage
+// is from a small, recurring set. Retrying the call does not help when the
+// fault is deterministic for that model, and where the API is paid the retry
+// costs real money for an answer that will be malformed the same way.
+//
+// It returns what it changed rather than only the value, so a caller can say so
+// out loud. Quietly accepting a repaired document is how a genuine problem
+// upstream - a truncating proxy, a model that has started ignoring the schema -
+// stays invisible.
+//
+// Note the shape this exists for is one the well-known jsonrepair library does
+// not handle: a document that begins at a key because the opening brace is
+// missing. Verified against jsonrepair 3.x, which answers
+// `Unexpected character ":"`. It fixes the four classic faults below; this
+// fixes those and that one.
+static const char* kJsonRepairPrelude = R"JS(
+(function () {
+    if (typeof smartbotic === 'undefined' || !smartbotic.utils) {
+        return;
+    }
+    smartbotic.utils.repairJson = function (text) {
+        var repairs = [];
+        var raw = String(text == null ? '' : text);
+
+        function attempt(candidate) {
+            try { return { ok: true, value: JSON.parse(candidate) }; }
+            catch (e) { return { ok: false }; }
+        }
+
+        var first = attempt(raw);
+        if (first.ok) { return { ok: true, value: first.value, repairs: [] }; }
+
+        var work = raw.trim();
+
+        // Code fences, and any prose either side of the document.
+        if (work.indexOf('```') !== -1) {
+            var open = work.indexOf('{');
+            var close = work.lastIndexOf('}');
+            if (open === -1) { open = work.indexOf('['); close = work.lastIndexOf(']'); }
+            if (open !== -1 && close > open) {
+                work = work.substring(open, close + 1);
+                repairs.push('removed code fences');
+            }
+        }
+
+        // A bare object body: the document starts at a key, the opening brace
+        // never arrived.
+        //
+        // Two forms, and the second is the one that actually occurs. The model
+        // drops `{"` as a pair, so the first key arrives without its opening
+        // quote as well - `humanCount": 0`, not `"humanCount": 0`. Handling
+        // only the tidier first form looks correct and fixes nothing real: it
+        // was caught by testing against the stored reply rather than a retyped
+        // one, which had quietly put the quote back.
+        if (/^"[^"]*"\s*:/.test(work)) {
+            work = '{' + work;
+            repairs.push('added the missing opening brace');
+        } else if (/^[A-Za-z_$][A-Za-z0-9_$]*"\s*:/.test(work)) {
+            work = '{"' + work;
+            repairs.push('added the missing opening brace and quote');
+        }
+
+        // Trailing comma before a closer.
+        var without = work.replace(/,(\s*[}\]])/g, '$1');
+        if (without !== work) { work = without; repairs.push('removed a trailing comma'); }
+
+        // Unclosed strings, brackets and braces - counted outside strings, so a
+        // brace inside a description does not confuse the tally.
+        var curly = 0, square = 0, inString = false, escaped = false;
+        for (var i = 0; i < work.length; i++) {
+            var ch = work[i];
+            if (escaped) { escaped = false; continue; }
+            if (ch === '\\') { escaped = true; continue; }
+            if (ch === '"') { inString = !inString; continue; }
+            if (inString) { continue; }
+            if (ch === '{') { curly++; }
+            else if (ch === '}') { curly--; }
+            else if (ch === '[') { square++; }
+            else if (ch === ']') { square--; }
+        }
+        if (inString) { work += '"'; repairs.push('closed an unterminated string'); }
+        if (square > 0) { work += new Array(square + 1).join(']'); repairs.push('closed ' + square + ' bracket(s)'); }
+        if (curly > 0) { work += new Array(curly + 1).join('}'); repairs.push('closed ' + curly + ' brace(s)'); }
+
+        var done = attempt(work);
+        if (done.ok) { return { ok: true, value: done.value, repairs: repairs }; }
+        return { ok: false, value: null, repairs: repairs };
+    };
+})();
+)JS";
+
 static const char* kJsonParsePrelude = R"JS(
 (function () {
     var originalParse = JSON.parse;
@@ -4962,6 +5055,13 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
     }
     JS_FreeValue(context_, smartbotic);
 
+    JSValue repair_prelude = JS_Eval(context_, kJsonRepairPrelude, strlen(kJsonRepairPrelude),
+                                     "<prelude:repair>", JS_EVAL_TYPE_GLOBAL);
+    if (JS_IsException(repair_prelude)) {
+        JS_FreeValue(context_, JS_GetException(context_));
+    }
+    JS_FreeValue(context_, repair_prelude);
+
     JSValue prelude = JS_Eval(context_, kJsonParsePrelude, strlen(kJsonParsePrelude),
                               "<prelude>", JS_EVAL_TYPE_GLOBAL);
     if (JS_IsException(prelude)) {

+ 52 - 0
tests/nodes/utils-repair-json.json

@@ -0,0 +1,52 @@
+{
+  "name": "utils-repair-json",
+  "description": "smartbotic.utils.repairJson, exercised against the exact reply that broke 35photo2anime - 791 characters taken verbatim from the stored execution, not retyped. The model dropped `{\"` as a pair, so the document begins at a key with no opening quote. jsonrepair 3.x does not fix this shape, and neither did the first version of this repairer - a reconstructed test string had put the quote back and hidden that. Also pins that valid JSON is left alone, that non-JSON is refused rather than guessed at, and that a brace inside a string value does not confuse the bracket count.",
+  "nodes": [
+    {
+      "id": "n1",
+      "name": "Trigger",
+      "type": "click-trigger",
+      "position": {
+        "x": 0,
+        "y": 0
+      },
+      "config": {}
+    },
+    {
+      "id": "n2",
+      "name": "Repair",
+      "type": "code",
+      "position": {
+        "x": 0,
+        "y": 100
+      },
+      "config": {
+        "code": "// The reply minimax-m3:cloud actually returned on 2026-09-03, taken verbatim\n// from execution exec_a0cf664e. Not retyped: an earlier version of this test\n// used a hand-written approximation that had silently restored the opening\n// quote, so it passed against a repairer that could not fix the real thing.\nvar real = \"humanCount\\\": 0, \\\"people\\\": [], \\\"nsfw\\\": false, \\\"nsfwLevel\\\": \\\"none\\\", \\\"nsfwReason\\\": \\\"no people present\\\", \\\"description\\\": \\\"a black-backed jackal standing alert in tall golden-green savanna grass, its body angled to the right while its head turns back to look over its left shoulder, distinctive black and silver saddle across the back, rust-colored legs and flanks, pointed ears erect, muzzle closed, tall dry grass surrounding and partially obscuring its lower legs, natural outdoor setting\\\", \\\"person_count\\\": 0, \\\"genders\\\": [], \\\"eroticism_level\\\": \\\"none\\\", \\\"is_black_and_white\\\": false, \\\"face_visible\\\": false, \\\"described_image_content\\\": \\\"A black-backed jackal (Canis mesomelas) standing in tall savanna grass, body shown in profile with head turned looking backward. No humans present in the image.\\\"}\";\nvar a = smartbotic.utils.repairJson(real);\nvar b = smartbotic.utils.repairJson('{\"a\": 1}');\nvar c = smartbotic.utils.repairJson('not json at all <html>');\nvar d = smartbotic.utils.repairJson('{\"text\": \"a { brace\", \"n\": 1');\nreturn {\n    realOk: a.ok,\n    realRepairs: a.repairs.join('; '),\n    realHumanCount: a.value ? a.value.humanCount : -1,\n    realNsfw: a.value ? a.value.nsfw : null,\n    validUntouched: b.ok && b.repairs.length === 0,\n    brokenRefused: c.ok === false,\n    braceInStringText: d.value ? d.value.text : ''\n};\n"
+      }
+    }
+  ],
+  "connections": [
+    {
+      "sourceNodeId": "n1",
+      "sourceOutput": "main",
+      "targetNodeId": "n2",
+      "targetInput": "data"
+    }
+  ],
+  "expect": {
+    "n2": {
+      "status": "completed",
+      "output": {
+        "result": {
+          "realOk": true,
+          "realRepairs": "added the missing opening brace and quote",
+          "realHumanCount": 0,
+          "realNsfw": false,
+          "validUntouched": true,
+          "brokenRefused": true,
+          "braceInStringText": "a { brace"
+        }
+      }
+    }
+  }
+}