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.