File size: 8,506 Bytes
664fbed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | # Structured output (`response_format`) postmortem
## Symptom
`POST /v1/chat/completions` with `response_format: {"type": "json_object"}` or
`{"type": "json_schema", ...}` returned:
```
HTTP 400
{"error":{"code":400,"message":"Failed to initialize samplers: std::exception","type":"invalid_request_error"}}
```
for **every** request that used `response_format`, regardless of schema
shape. Plain, unconstrained chat completions worked fine.
## Deployment audit (Phase 1)
- `start.sh` runs `/app/llama-server` directly, single model
(`--hf-repo openbmb/MiniCPM4-0.5B-GGUF:Q8_0 --alias minicpm4-0.5b`), with
`--parallel 1 --cont-batching --flash-attn auto`, no `--jinja`, no router
flags, no grammar-related flags. **Router/model-preset mode is not in
use** — this is llama.cpp's plain single-model server. This was verified
both by reading `start.sh`/`Dockerfile` and by reproducing the exact same
failure against a from-source build run the same way, single model, no
router.
- The Docker image is `FROM ghcr.io/ggml-org/llama.cpp:server` — a floating
tag. The public Space's `system_fingerprint` on a plain completion
reported `b11118-e6ab7c1a4`, i.e. build b11118 at commit `e6ab7c1a4...`.
- `gateway.py` forwarded `response_format`/`grammar`/`tools`/`stream`
fields to llama.cpp unmodified, and turned upstream HTTP errors into an
`HTTPException` with the same status code — it did not swallow or fake
success, but it re-wrapped llama.cpp's JSON error body as an escaped
string inside `{"detail": "..."}"`, which is awkward for API clients (see
"Gateway changes" below).
## Root cause (Phase 3)
Reproduced directly against llama-server (bypassing the gateway), including
against a **from-source build of current llama.cpp master** (commit
`84e76d8`, ~12 days newer than the deployed build), so this is **not** a
stale/floating-tag regression — it is a live upstream bug.
Two distinct bugs, found by attaching `gdb` (`catch throw`) to a from-source
debug build and by adding a temporary diagnostic print of the caught
exception's `typeid` and `what()`:
### Bug 1 — the "std::exception" message is a symptom, not the disease
`common/sampling.cpp`, in `common_sampler_init`, prefills the grammar
sampler with the chat template's generation-prompt text before generation
starts:
```cpp
try {
for (const auto & token : prefill_tokens) {
llama_sampler_accept(grmr, token);
}
} catch (std::exception &e) {
LOG_ERR(...);
throw e; // <-- BUG: copies/slices `e` down to plain std::exception
}
```
`throw e;` **copies** the caught exception object using the **static** type
of the catch clause (`std::exception`), discarding whatever real subclass
(here `std::runtime_error`) and message it actually was. That's why every
failure surfaced as the useless `"std::exception"` instead of the real
message. The fix is the standard one-line correction: bare `throw;`
(verified locally — see below).
### Bug 2 — the real error: a tokenizer/grammar-literal mismatch
With bug 1 patched, the real message is:
```
Failed to initialize samplers: Unexpected empty grammar stack after accepting piece: assistant (16434)
```
llama.cpp's generic auto-parser (used because minicpm4 has no dedicated
tool-call parser, only `minicpm5` does) builds `response_format`'s grammar
wrapped in a literal chat-template prefix, e.g.:
```
root ::= "<|im_start|>assistant\n" space space ( "```json" space response-format space "```" | space response-format space )
```
and then "prefills" that literal into the grammar sampler by tokenizing the
prefix text and feeding the tokens through `llama_sampler_accept`
(`common/sampling.cpp`). For minicpm4-0.5b, the token for the word
"assistant" immediately after the `<|im_start|>` control token decodes to
`" assistant"` (**with a spurious leading space**) — the tokenizer has no
space-less "assistant" token, because in ordinary text that word is always
preceded by real whitespace. That leading space doesn't match the raw
literal text the grammar rule was built from (`"assistant"`, no space), so
the grammar's internal stack empties out and
`llama_grammar_accept_token` (`src/llama-grammar.cpp`) throws.
This reproduces for **every** `response_format` request for this model —
`json_object` with no schema, and any `json_schema`, simple or complex —
because the broken step is the literal-prefix prefill, not anything
schema-shape-specific. (A separate, already-fixed upstream bug —
`json-schema-to-grammar`'s "empty object schema" `"{" space space "}"`
GBNF bug, ggml-org/llama.cpp#25923, fixed in PR #28736 on 2026-09-12 — is
**not** the cause here: the deployed build (`e6ab7c1a4`, 2026-09-22)
already contains that fix.)
A plain `"grammar": "<gbnf>"` request (bypassing `response_format` and
the auto-parser's literal-prefix/prefill path entirely) works reliably —
confirmed directly against the exact deployed llama-server build. That is
the basis for the fix shipped here.
## Fix (Phase 4/5)
Per the task's preferred order, option **A** (server/request configuration)
turned out to be sufficient and lowest-risk — no image rebuild, no llama.cpp
version change needed:
- `schema_grammar.py` deterministically compiles the JSON Schemas this
service needs (object/boolean/string(+enum), `required`,
`additionalProperties: false`, and untyped/empty-object placeholders)
into GBNF, and `gateway.py` sends that as `grammar` instead of forwarding
`response_format` to llama.cpp. This avoids the buggy literal-prefix
prefill path entirely.
- The converter accepts the OpenAI-style nested
`{"type": "json_schema", "json_schema": {"schema": {...}}}` shape, the
flatter `{"type": "json_schema", "schema": {...}}` shape, and
`{"type": "json_object"}` (with or without a `schema`).
It never modifies the schema's semantics — it raises
`SchemaToGrammarError` for anything outside its supported subset (arrays,
`pattern`, `$ref`, `oneOf`, mixed required/optional ordering it can't
express as a simple sequential grammar, etc.) rather than silently
dropping a constraint. The gateway turns that into an HTTP 422.
- Unit tests: `test_schema_grammar.py` (23 tests, `python3 -m unittest
test_schema_grammar.py`).
- Also fixed, and verified against a from-source build, but **not** shipped
in this Docker image (which uses the prebuilt
`ghcr.io/ggml-org/llama.cpp:server` image, not a source build): the
`throw e;` → `throw;` slicing bug in `common/sampling.cpp` described
above. Worth upstreaming separately; tracked here for anyone who does
rebuild llama.cpp from source for this Space.
### Why not upgrade or patch llama.cpp itself?
The bug is present in current llama.cpp master (verified by building
commit `84e76d8` from source), so upgrading the floating tag would not have
fixed it. Patching the tokenizer/grammar-literal mismatch correctly (so the
literal-prefix grammar tolerates the model's real detokenization) touches
shared, security-and-correctness-sensitive code used by every model's
`response_format` handling, and doing that safely needs llama.cpp's own
test suite and review — out of scope for a single-model gateway fix, and
unnecessary given the request-level workaround fully resolves the problem
for this deployment.
## Remaining limitations
- The GBNF converter in `schema_grammar.py` only supports the schema
shapes this service's tool-repair verifier flow needs (see its module
docstring). Anything else is rejected with a clear 422, not silently
passed through — if broader JSON Schema support is needed later, extend
`_compile_node`/`_compile_object` and their tests.
- `minicpm4-0.5b` is small; at nonzero temperature its *content* judgement
(e.g. the `repairable` boolean) can vary run-to-run even though the JSON
is always syntactically valid and schema-conformant. The repair test in
`scripts/validate_structured_output.py` uses `temperature: 0` for
repeatable content, which is also the right default for a
verifier/repair role.
- `start.sh`'s default `MODEL_REPO` (`openbmb/MiniCPM4-0.5B-GGUF`) does not
currently resolve on the Hugging Face Hub (only
`openbmb/MiniCPM4-0.5B-QAT-Int4-GGUF` and the non-GGUF
`openbmb/MiniCPM4-0.5B` exist there today). The running Space still works
because the model is already cached, but a cold rebuild/restart that has
to re-download the model would fail to start. This is outside this
task's scope (structured output) but worth fixing separately.
|