#4760·baml

[bug] Partial parser turns unquoted null in (string | Class)[] into a string and swallows the rest of minified JSON

Author: devin-ai-integration[bot]Created Sep 5, 2026Updated Sep 15, 2026

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:

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):

typescript
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 to string variant 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 null in string | X union arrays (make the field non-optional / instruct the model to omit it).