[bug] Partial parser turns unquoted null in (string | Class)[] into a string and swallows the rest of minified JSON
BAML version: 0.217.0 (TypeScript client; also reproduces via b.parseStream alone, no LLM involved)
Summary
For a field typed (string | SomeClass)[]?, the partial/streaming parser turns an unquoted null into a string element and, when the surrounding JSON is minified (no whitespace after null,), keeps appending the rest of the raw JSON source to that string until the enclosing object closes. Consumers that render partials to end users see raw JSON text like null,"content":["Hello ",{"id":"kw-1","text":"world" in place of the field.
With pretty-printed JSON the same mis-guess happens for 3 ticks (["n"], ["nu"], ["nul"]) but the newline after null, makes the parser recover immediately, so it is rarely noticed. Any prompt that asks the model for compact/minified output exposes it for the full duration of the object.
Repro
baml_src/main.baml:
class Ref {
id string
text string
}
class Block {
main (string | Ref)[]?
content (string | Ref)[]
}
class Result {
blocks Block[]
}
client<llm> Dummy {
provider openai
options { model "gpt-4o-mini" }
}
function Extract(input: string) -> Result {
client Dummy
prompt #"{{ input }} {{ ctx.output_format }}"#
}repro.ts (feeds every prefix of a valid final response to the partial parser):
import { b } from "./baml_client/index.js";
const obj = {
blocks: [{ main: null, content: ["Hello ", { id: "kw-1", text: "world" }, "!"] }],
};
function sweep(label: string, raw: string) {
let last = "";
for (let i = 1; i <= raw.length; i++) {
const r = b.parseStream.Extract(raw.slice(0, i));
const out = JSON.stringify(r?.blocks?.[0]?.main ?? null);
if (out !== last) { last = out; console.log(`${label} prefix=${i}: main=${out}`); }
}
}
sweep("MINIFIED", JSON.stringify(obj));
console.log("-----");
sweep("PRETTY ", JSON.stringify(obj, null, 2));Output (abridged)
MINIFIED prefix=20: main=["n"]
MINIFIED prefix=21: main=["nu"]
MINIFIED prefix=22: main=["nul"]
MINIFIED prefix=23: main=null
MINIFIED prefix=25: main=["null,\""]
MINIFIED prefix=26: main=["null,\"c"]
...
MINIFIED prefix=35: main=["null,\"content\":["]
MINIFIED prefix=37: main=["null,\"content\":[\"H"]
...
MINIFIED prefix=71: main=["null,\"content\":[\"Hello \",{\"id\":\"kw-1\",\"text\":\"world\""]
MINIFIED prefix=80: main=null <- only correct once the Block object closes
-----
PRETTY prefix=37: main=["n"]
PRETTY prefix=38: main=["nu"]
PRETTY prefix=39: main=["nul"]
PRETTY prefix=40: main=null <- recovers at the newline after "null,"Note prefix 23 ("main":null) is parsed correctly as null; it is prefix 24+ ("main":null, followed by a non-whitespace char) that re-interprets the already-complete null as the start of an unquoted string.
Expected
main should be null/absent for every prefix; an unquoted literal null should never be coerced into a string array element, and a complete literal followed by , should not be reopened as a string.
Notes
- Looks related to #2567 / #2572: that fix stopped
AnyOf[...]debug repr from leaking into strings, but the underlying "ambiguous token coerced tostringvariant of a union" behaviour remains — it now leaks the raw source text instead. - Workarounds we found: avoid asking the model for minified JSON, or avoid
nullinstring | Xunion arrays (make the field non-optional / instruct the model to omit it).
Source: BoundaryML/baml