#4585·baml

0.15→0.17 openai-generic wire change: `messages[].content` part array became a plain string — intentional? needs a migration note (breaks byte-stable request consumers)

Author: BenSpexCreated Aug 25, 2026Updated Aug 25, 2026

Summary

Between 0.15.0 (classic syntax, provider: "openai-generic") and 0.17.0 (openai.GenericClient), the chat-completions request body changed shape: messages[].content was a part array [{"type":"text","text":"…"}] on 0.15.0 and is a plain string on 0.17.0. Same function, same args, same endpoint path — captured against a local HTTP sink.

If intentional (it looks like a reasonable normalization for text-only prompts), it deserves a migration-guide line: any consumer that records/byte-compares requests, proxies them, or runs a server that branches on the content type sees every single call change shape when migrating.

Reproduce

Same probe function on each line, VLLM_QWEN_BASE_URL pointed at a local capture sink.

0.15.0 source:

baml
client<llm> Sink {
    provider: "openai-generic",
    options: {
        base_url: env.VLLM_QWEN_BASE_URL,
        model: "m",
        api_key: env.VLLM_API_KEY,
        temperature: 0.0,
        max_tokens: 6144,
    },
}

class Out { a string, }

function Probe(txt: string, opt: string?) -> Out {
    client: Sink
    prompt: #"
        {{ _.role("user") }}
        Text: {{ txt }}
        Opt: {{ opt }}
        {{ ctx.output_format }}
    "#
}

0.17.0 source (the migration of the same function):

baml
client Sink = openai.GenericClient.new(
    model = "m",
    base_url = env.VLLM_QWEN_BASE_URL,
    api_key = env.VLLM_API_KEY,
    temperature = 0.0,
    max_tokens = 6144,
);

class Out { a string, }

function Probe(txt: string, opt: string?) -> Out {
    client: Sink
    prompt: `${role("user")}
Text: ${txt}
Opt: ${opt}
${ctx.output_format}`
}

Captured POST /v1/chat/completions bodies (identical args txt="hallo", opt=null):

0.15.0:

json
{"model":"m","messages":[{"role":"user","content":[{"type":"text","text":"Text: hallo\nOpt: null\nAnswer in JSON using this schema:\n{\n  a: string,\n}"}]}],"temperature":0.0,"max_tokens":6144}

0.17.0:

json
{"model":"m","messages":[{"role":"user","content":"\nText: hallo\nOpt: null\nAnswer in JSON using this schema:\n{\n  a: string,\n}"}],"temperature":0.0,"max_tokens":6144}

(The leading \n in the 0.17 content is a separate, also-migration-relevant semantics change: classic #"…"# prompt blocks dedented and swallowed the newline after {{ _.role("user") }}; backtick templates are literal — the newline after ${role("user")} is emitted. Both differences together mean a byte-stable migration of a prompt is not achievable by mechanical rewriting alone.)

Why it matters to a real consumer

zenzy-atlas runs a recorded-transcript eval discipline: prompt/toolchain changes are bounded by byte-comparing captured request JSON against a sink, and only lanes whose bytes change pay a live re-record. The 0.15→0.17 migration flips every lane's bytes via this envelope change alone, independent of prompt text — so the cheap "byte-identical ⇒ no re-record" bound is lost for the whole migration, and every recorded lane must be re-verified against the live model. A changelog note (and ideally a documented stability policy for the wire encoding of text-only prompts) would let consumers distinguish "the engine re-encoded" from "my prompt changed".

Asks

  1. Confirm the part-array → plain-string change is intentional in 0.17, and document it in the migration notes.
  2. Document the template dedent/role-marker newline semantics change (classic prompt blocks dedent; backtick templates are literal) in the same place — it is the other unavoidable render diff in a mechanical migration.

Environment: Linux x86_64, baml wrapper 0.2.0, 0.15.0 vs 0.17.0 via BAML_VERSION selector; capture sink is a ~20-line local HTTP server, available on request.