@bendyline/gezel-inference 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +90 -8
  2. package/dist/cache/adapter.d.ts +7 -0
  3. package/dist/cache/controller.d.ts +12 -0
  4. package/dist/cache/controller.js +24 -0
  5. package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
  6. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
  7. package/dist/catalog/chat-models/index.json +1 -1
  8. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
  9. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
  11. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
  12. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
  13. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
  14. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
  15. package/dist/catalog/chat-models/raw-index.json +1 -1
  16. package/dist/catalog/provenance.json +1 -1
  17. package/dist/engines/electron-native-reuse.d.ts +9 -0
  18. package/dist/engines/electron-native-reuse.js +146 -45
  19. package/dist/engines/native-manifest.d.ts +8 -0
  20. package/dist/engines/native-manifest.js +30 -0
  21. package/dist/http/openai-compat/apple-model.d.ts +2 -0
  22. package/dist/http/openai-compat/apple-model.js +21 -1
  23. package/dist/http/openai-compat/chat-target.d.ts +12 -0
  24. package/dist/http/openai-compat/chat-target.js +27 -1
  25. package/dist/http/routes/engines.js +9 -3
  26. package/dist/http/routes/v1-chat.js +21 -8
  27. package/dist/http/routes/v1-models.d.ts +6 -0
  28. package/dist/http/routes/v1-models.js +133 -28
  29. package/dist/http/routes/v1-responses.js +9 -2
  30. package/dist/in-flight-requests.d.ts +19 -0
  31. package/dist/in-flight-requests.js +34 -0
  32. package/dist/inference-service.js +3 -3
  33. package/dist/providers/llama-cpp/build-provider.js +33 -8
  34. package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
  35. package/dist/providers/llama-cpp/cache-adapter.js +34 -0
  36. package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
  37. package/dist/providers/mlx/provider.js +11 -7
  38. package/dist/providers/mlx/python/batch_processors.py +26 -0
  39. package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
  40. package/dist/providers/mlx/python/response_format.py +374 -0
  41. package/dist/providers/mlx/python/spec_decode.py +5 -0
  42. package/dist/providers/mlx/python/think_budget.py +35 -1
  43. package/dist/providers/mlx/python/tool_grammar.py +62 -3
  44. package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
  45. package/dist/providers/mlx/reasoning-stream.js +77 -16
  46. package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
  47. package/dist/providers/mlx/runtime-diagnostics.js +29 -2
  48. package/dist/providers/native/capacity-broker.d.ts +35 -0
  49. package/dist/providers/native/capacity-broker.js +69 -4
  50. package/dist/providers/native/local-engine-runtime.js +7 -2
  51. package/dist/remotes/identity.d.ts +1 -0
  52. package/dist/remotes/identity.js +6 -0
  53. package/dist/remotes/pinned-fetch.d.ts +2 -0
  54. package/dist/remotes/pinned-fetch.js +30 -1
  55. package/dist/remotes/types.d.ts +14 -0
  56. package/dist/runtime-lock.js +1 -1
  57. package/dist/system/memory.js +11 -1
  58. package/dist/utils/service-worker-entry.d.ts +4 -0
  59. package/dist/utils/service-worker-entry.js +4 -0
  60. package/package.json +14 -10
@@ -75,6 +75,8 @@ import cache_persist # noqa: E402
75
75
  import think_budget # noqa: E402
76
76
  import cache_seed # noqa: E402
77
77
  import tool_grammar # noqa: E402
78
+ import response_format # noqa: E402
79
+ import batch_processors # noqa: E402
78
80
  import spec_decode # noqa: E402
79
81
  import tool_call_stream # noqa: E402
80
82
  import template_stability # noqa: E402
@@ -186,6 +188,20 @@ def _tool_stream_payload(frag, request_id: str, model: str, chunk) -> Dict[str,
186
188
  }
187
189
 
188
190
 
191
+ def _stream_headers(prompt: str) -> Dict[str, str]:
192
+ """SSE headers, plus whether the rendered prompt opened reasoning.
193
+
194
+ The client seeds its live reasoning split from X-Gezel-Reasoning-Open:
195
+ only the rendered prompt knows, and a wrong guess streams the model's
196
+ thoughts as its reply.
197
+ """
198
+ return {
199
+ "Cache-Control": "no-cache",
200
+ "X-Accel-Buffering": "no",
201
+ "X-Gezel-Reasoning-Open": "1" if think_budget.prompt_opens_reasoning(prompt) else "0",
202
+ }
203
+
204
+
189
205
  def _scrub_leaked_markers(text: str) -> str:
190
206
  """Drop chat-template framing special-tokens that leaked as text."""
191
207
  if not text or "<" not in text:
@@ -2358,7 +2374,14 @@ class BatchEngine:
2358
2374
  caches.append(c)
2359
2375
  alltoks.append(at)
2360
2376
  samplers.append(self._sampler_for(sub.request))
2361
- lps.append(self._processors_for(sub.request, sub.grammar))
2377
+ # Never an empty list: mlx_lm misaligns the next wave's
2378
+ # grammars after a wave of unconstrained sequences
2379
+ # (batch_processors.py has the mechanism and the test).
2380
+ lps.append(
2381
+ batch_processors.per_sequence_processors(
2382
+ self._processors_for(sub.request, sub.grammar)
2383
+ )
2384
+ )
2362
2385
  maxtoks.append(int(sub.request.max_tokens) if sub.request.max_tokens else 2048)
2363
2386
  try:
2364
2387
  uids = self._gen.insert_segments(
@@ -3232,6 +3255,14 @@ class ChatRequest(BaseModel):
3232
3255
  # function name of any tool call to the known tools — see
3233
3256
  # `build_tool_grammar_processor`. Shape: {"format": "...", "mode": "name-only"}.
3234
3257
  tool_grammar: Optional[Dict[str, Any]] = None
3258
+ # OpenAI `response_format` (`json_object` / `json_schema`), enforced by
3259
+ # an llguidance JSON grammar — see response_format.py. Before it was
3260
+ # declared here, pydantic dropped it and the answer came back unconstrained.
3261
+ response_format: Optional[Dict[str, Any]] = None
3262
+ # OpenAI `tool_choice`. Read only to shape a response format's grammar
3263
+ # when tools are offered too ("none": the answer alone; "required" or a
3264
+ # named function: a call, so the tool grammar alone applies).
3265
+ tool_choice: Optional[Any] = None
3235
3266
  # Per-request circuit breaker for a session that observed the assisted
3236
3267
  # speculative path violate a required-argument grammar. The TS provider
3237
3268
  # latches this after the first impossible call and leaves the drafter
@@ -3970,13 +4001,11 @@ def _serial_prompt_token_variants(prompt_text: str) -> "List[List[int]]":
3970
4001
  return variants
3971
4002
 
3972
4003
 
3973
- async def _vision_stream(request, http_request, images):
4004
+ async def _vision_stream(request, http_request, images, prompt, response_grammar=None):
3974
4005
  """One full-tower vision request. Never share text-prefix KV or speculate."""
3975
4006
  request_id = f"chatcmpl-{uuid.uuid4()}"
3976
4007
  generator = None
3977
4008
  try:
3978
- prompt = _build_prompt(request.messages, request.tools,
3979
- request.chat_template_override, request.chat_template_kwargs)
3980
4009
  kwargs = {"prefill_step_size": ARGS.prefill_step_size, **_kv_quant_kwargs()}
3981
4010
  for key in ("max_tokens", "temperature", "top_p", "top_k",
3982
4011
  "repetition_penalty", "repetition_context_size"):
@@ -3985,7 +4014,7 @@ async def _vision_stream(request, http_request, images):
3985
4014
  kwargs[key] = value
3986
4015
  tokenizer = getattr(PROCESSOR, "tokenizer", None) or PROCESSOR
3987
4016
  processors = []
3988
- if request.tool_grammar:
4017
+ if request.tool_grammar and not response_format.covers_tool_calls(response_grammar):
3989
4018
  grammar = tool_grammar.build_tool_grammar_processor(
3990
4019
  tokenizer, request.tools, request.tool_grammar)
3991
4020
  if grammar is not None:
@@ -3996,6 +4025,10 @@ async def _vision_stream(request, http_request, images):
3996
4025
  opens_in_think=prompt.rstrip().endswith("<think>"))
3997
4026
  if processor is not None:
3998
4027
  processors.append(processor)
4028
+ processor = response_format.build_response_format_processor(
4029
+ tokenizer, response_grammar, prompt)
4030
+ if processor is not None:
4031
+ processors.append(processor)
3999
4032
  if processors:
4000
4033
  kwargs["logits_processors"] = processors
4001
4034
  last = None
@@ -4057,6 +4090,30 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4057
4090
  detail="gezel-mlx-server only supports streaming completions",
4058
4091
  )
4059
4092
 
4093
+ # Compiled before any generation, so a schema this engine cannot enforce
4094
+ # fails as a 422 instead of coming back as unconstrained text. Tools sent
4095
+ # alongside it are not an error: the answer is JSON or tool calls.
4096
+ response_grammar = None
4097
+ if request.response_format:
4098
+ try:
4099
+ response_grammar = response_format.compile_response_grammar(
4100
+ getattr(PROCESSOR, "tokenizer", None) or PROCESSOR,
4101
+ request.response_format,
4102
+ request.tools,
4103
+ request.tool_grammar,
4104
+ request.tool_choice,
4105
+ allow_think=(request.chat_template_kwargs or {}).get("enable_thinking", True) is not False,
4106
+ )
4107
+ except ValueError as exc:
4108
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
4109
+ # Every prompt built below (text, vision, the cache boundary probe)
4110
+ # reads request.messages, so the model is told the format once here.
4111
+ request.messages = response_format.ground_messages(
4112
+ request.messages,
4113
+ response_format.grounding_note(request.response_format),
4114
+ response_format.note_placement(request.tool_grammar),
4115
+ )
4116
+
4060
4117
  if any(message.images for message in request.messages):
4061
4118
  if not _VISION_ENABLED:
4062
4119
  raise HTTPException(status_code=422, detail="This MLX engine has no enabled vision tower")
@@ -4064,9 +4121,11 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4064
4121
  images = vision_inputs.decode_images(request.messages)
4065
4122
  except Exception as exc:
4066
4123
  raise HTTPException(status_code=422, detail=f"Invalid image input: {exc}") from exc
4067
- return StreamingResponse(_vision_stream(request, http_request, images),
4124
+ prompt = _build_prompt(request.messages, request.tools,
4125
+ request.chat_template_override, request.chat_template_kwargs)
4126
+ return StreamingResponse(_vision_stream(request, http_request, images, prompt, response_grammar),
4068
4127
  media_type="text/event-stream",
4069
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
4128
+ headers=_stream_headers(prompt))
4070
4129
 
4071
4130
  prompt_text = _build_prompt(
4072
4131
  request.messages,
@@ -4080,7 +4139,9 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4080
4139
  # None — the turn then runs unconstrained + TS salvage, so this never
4081
4140
  # breaks a turn.
4082
4141
  tool_grammar_processor = None
4083
- if request.tool_grammar:
4142
+ # A response grammar that also covers calls replaces the tool grammar:
4143
+ # stacked, the two would leave only JSON, and no call could get through.
4144
+ if request.tool_grammar and not response_format.covers_tool_calls(response_grammar):
4084
4145
  _grammar_tok = getattr(PROCESSOR, "tokenizer", None) or PROCESSOR
4085
4146
  tool_grammar_processor = tool_grammar.build_tool_grammar_processor(
4086
4147
  _grammar_tok, request.tools, request.tool_grammar
@@ -4097,6 +4158,14 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4097
4158
  # the prompt tail.
4098
4159
  opens_in_think=prompt_text.rstrip().endswith("<think>"),
4099
4160
  )
4161
+ # Held back while the prompt leaves the model inside reasoning, whichever
4162
+ # family's opener did it (Gemma 4 opens its thought channel after every
4163
+ # tool response), so the grammar shapes the answer, not the thoughts.
4164
+ response_processor = response_format.build_response_format_processor(
4165
+ getattr(PROCESSOR, "tokenizer", None) or PROCESSOR,
4166
+ response_grammar,
4167
+ prompt_text,
4168
+ )
4100
4169
 
4101
4170
  # Look up or initialize cache state. Cascade:
4102
4171
  # 1. In-memory hit on cache_id — best case, no I/O.
@@ -4151,7 +4220,9 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4151
4220
  request=request,
4152
4221
  prompt_tokens=prompt_tokens,
4153
4222
  grammar=[
4154
- p for p in (tool_grammar_processor, think_budget_processor) if p is not None
4223
+ p
4224
+ for p in (tool_grammar_processor, think_budget_processor, response_processor)
4225
+ if p is not None
4155
4226
  ]
4156
4227
  or None,
4157
4228
  seed_state=cache_state,
@@ -4179,7 +4250,7 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4179
4250
  return StreamingResponse(
4180
4251
  _batched_stream_iter(sub),
4181
4252
  media_type="text/event-stream",
4182
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
4253
+ headers=_stream_headers(prompt_text),
4183
4254
  )
4184
4255
 
4185
4256
  # Defense in depth: the tower selector requires --max-concurrency >= 1
@@ -4255,7 +4326,9 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4255
4326
  request.repetition_context_size
4256
4327
  )
4257
4328
  _lps = [
4258
- p for p in (tool_grammar_processor, think_budget_processor) if p is not None
4329
+ p
4330
+ for p in (tool_grammar_processor, think_budget_processor, response_processor)
4331
+ if p is not None
4259
4332
  ]
4260
4333
  if _lps:
4261
4334
  # Forwarded through stream_generate(**kwargs) → generate_step,
@@ -4448,18 +4521,13 @@ async def chat_completions(request: ChatRequest, http_request: Request):
4448
4521
  # active allocation and is not touched by clear_cache().
4449
4522
  _reclaim_mlx_buffer_cache("turn-end", force=True, log=True)
4450
4523
 
4524
+ # NOTE: mlx_vlm.server also sets `Connection: keep-alive`, which our
4525
+ # daemon would have to strip over HTTP/2; omitted to keep behavior
4526
+ # predictable across transports.
4451
4527
  return StreamingResponse(
4452
4528
  stream_iter(),
4453
4529
  media_type="text/event-stream",
4454
- headers={
4455
- "Cache-Control": "no-cache",
4456
- # NOTE: mlx_vlm.server sets `Connection: keep-alive` here,
4457
- # which our gezel daemon would have to strip when running
4458
- # over HTTP/2. We're a separate process not on HTTP/2 so
4459
- # the header is fine to send, but explicitly omit to keep
4460
- # behavior predictable across transports.
4461
- "X-Accel-Buffering": "no",
4462
- },
4530
+ headers=_stream_headers(prompt_text),
4463
4531
  )
4464
4532
 
4465
4533
 
@@ -0,0 +1,374 @@
1
+ """response_format — decode-time JSON for OpenAI's `response_format` on the
2
+ gezel MLX server.
3
+
4
+ `json_object` asks for any JSON object and `json_schema` for one shape.
5
+ llama.cpp enforces both with a grammar. This server used to accept the
6
+ field and drop it, so a caller asking for a schema got free text with no sign
7
+ that anything was wrong — on the engine gezel defaults to on Apple silicon.
8
+ The answer is now constrained by the same llguidance backend the tool-call
9
+ grammar uses (`tool_grammar.py`). Unlike `mlx_vlm.structured`, the grammar
10
+ lets whitespace through: held to one compact layout, a model that wants to
11
+ pretty-print (and every thinking model after `</think>`) was forced off its
12
+ path and wrote objects keyed `""` or `":"`. Measured 2026-10-14 on
13
+ gemma4-e4b and qwen3.8-27b: 22/32 and 14/16 useful JSON-mode answers
14
+ compact, 31/32 and 16/16 with flexible whitespace, no whitespace runaways.
15
+
16
+ A thinking model reasons first: when the prompt opens inside reasoning
17
+ (Qwen's `<think>`, Gemma 4's `<|channel>thought` after every tool response),
18
+ the grammar holds off until that block's closer (mlx_vlm's
19
+ ThinkingAwareLogitsProcessor), so it shapes the answer and not the private
20
+ reasoning. It once waited only for `<think>`: on Gemma the grammar then
21
+ held the open thought channel to the answer, the model never wrote
22
+ `<channel|>`, and a streaming client, told reasoning was open, got the whole
23
+ answer as reasoning and an empty reply.
24
+
25
+ A request may carry tools as well, as OpenAI allows: the model can call
26
+ them, and when it answers, the answer follows the schema. Where the model's
27
+ tool-call format has a grammar (`tool_grammar.SUPPORTED_FORMATS`), one grammar
28
+ covers both: optional reasoning, then calls or a JSON answer. It replaces the
29
+ plain tool grammar for that request. With no format grammar, calls stay free
30
+ and the answer is not enforced, as before this module existed. `tool_choice`
31
+ "none" asks for the answer alone; "required" or a named function asks for a
32
+ call, so the ordinary tool grammar runs.
33
+
34
+ The model is told the format too (`grounding_note`, placed per family by
35
+ `note_placement`), as OpenAI's own models are: a grammar alone only masks, so a model that does not know the schema
36
+ picks whatever the mask leaves. Live, 2026-10-14: asked for a country's
37
+ capital with a weather tool offered, qwen3.8-27b called the weather tool;
38
+ told "none", it wrote the one field it knew about and then filled the
39
+ required second one with whitespace until max_tokens. Whitespace runs are
40
+ also capped (`_BoundedWhitespace`), because llguidance's flexible whitespace
41
+ has no bound and that loop is the classic JSON-mode failure.
42
+
43
+ A schema llguidance refuses as written is compiled again with `oneOf` held as
44
+ `anyOf` and the keywords it does not implement left unenforced
45
+ (`_RELAXED_OPTIONS`), as llama.cpp serves them. One that still cannot compile
46
+ raises ValueError for the server to return as a 422, which /v1 answers as the
47
+ caller's 400: free text would hide the mistake. Only a matcher fault
48
+ mid-answer degrades to unconstrained text, as tool grammars do, rather than
49
+ killing a turn already under way.
50
+ """
51
+
52
+ import json
53
+ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
54
+
55
+ import think_budget
56
+ import tool_grammar
57
+
58
+
59
+ def response_schema(response_format: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
60
+ """The JSON Schema a `response_format` asks for, or None for plain text."""
61
+ if not response_format:
62
+ return None
63
+ kind = response_format.get("type")
64
+ if kind in (None, "text"):
65
+ return None
66
+ if kind == "json_object":
67
+ return {"type": "object"}
68
+ if kind == "json_schema":
69
+ spec = response_format.get("json_schema")
70
+ schema = spec.get("schema") if isinstance(spec, dict) else None
71
+ if not isinstance(schema, dict):
72
+ raise ValueError("response_format json_schema needs a `schema` object")
73
+ return schema
74
+ raise ValueError(f"Unsupported response_format type: {kind!r}")
75
+
76
+
77
+ class ResponseGrammar(NamedTuple):
78
+ """A compiled response grammar and whether it also covers tool calls."""
79
+
80
+ text: str
81
+ covers_tool_calls: bool = False
82
+
83
+
84
+ def covers_tool_calls(grammar: Optional[ResponseGrammar]) -> bool:
85
+ """Whether the request's tool grammar must stand down for this one."""
86
+ return bool(grammar is not None and grammar.covers_tool_calls)
87
+
88
+
89
+ def _wants_a_call(tool_choice: Any) -> bool:
90
+ return tool_choice == "required" or isinstance(tool_choice, dict)
91
+
92
+
93
+ def compile_response_grammar(
94
+ tokenizer: Any,
95
+ response_format: Optional[Dict[str, Any]],
96
+ tools: Optional[List[Any]] = None,
97
+ tool_grammar_hint: Optional[Dict[str, Any]] = None,
98
+ tool_choice: Any = None,
99
+ allow_think: bool = True,
100
+ ) -> Optional[ResponseGrammar]:
101
+ """Compile and validate the llguidance grammar for a response format.
102
+
103
+ Called before generation starts so a bad schema fails as a 422 rather
104
+ than mid-stream. Returns None when the request asks for plain text, or
105
+ when tools are offered and nothing here can hold the answer without
106
+ blocking a call.
107
+ """
108
+ schema = response_schema(response_format)
109
+ if schema is None:
110
+ return None
111
+ answer, options = _compile_answer(tokenizer, schema)
112
+ if not tools or tool_choice == "none":
113
+ # With "none" the answer is the whole reply, so the tool grammar
114
+ # stands down too.
115
+ return ResponseGrammar(answer, covers_tool_calls=bool(tools))
116
+ if _wants_a_call(tool_choice):
117
+ print("[response-format] tool_choice asks for a call; the tool grammar alone applies", flush=True)
118
+ return None
119
+ if options:
120
+ schema = {**schema, "x-guidance": {**schema.get("x-guidance", {}), **options}}
121
+ combined = tool_grammar.build_answer_or_calls_grammar(
122
+ tools, tool_grammar_hint or {}, schema, allow_think=allow_think
123
+ )
124
+ if combined is None:
125
+ print(
126
+ "[response-format] tools without a tool-call grammar for this model; "
127
+ "calls stay free and the answer's JSON is not enforced",
128
+ flush=True,
129
+ )
130
+ return None
131
+ from llguidance import LLMatcher
132
+
133
+ err = LLMatcher.validate_grammar(combined, tool_grammar._get_llg_tokenizer(tokenizer))
134
+ if err:
135
+ # The schema compiled on its own, so this is the splice, not the
136
+ # caller: keep the calls working rather than refuse the request.
137
+ print(f"[response-format] answer-or-calls grammar invalid; answer not enforced: {err}", flush=True)
138
+ return None
139
+ return ResponseGrammar(combined, covers_tool_calls=True)
140
+
141
+
142
+ # What lets a schema through that llguidance refuses as written. `oneOf` is
143
+ # held as `anyOf`: pydantic writes every discriminated union as `oneOf`, and
144
+ # llguidance accepts one only when it can prove the branches disjoint.
145
+ # Keywords it does not implement (`uniqueItems` from a pydantic `set`, `not`,
146
+ # `if`/`then`/`else`) go unenforced, as llama.cpp's converter leaves them.
147
+ # Refusing them failed requests llama.cpp serves, and that this engine served
148
+ # as free text before it enforced schemas at all.
149
+ _RELAXED_OPTIONS = {"coerce_one_of": True, "lenient": True}
150
+
151
+
152
+ def _unenforceable(detail: Any) -> ValueError:
153
+ return ValueError(f"The response format's schema cannot be enforced: {detail}")
154
+
155
+
156
+ def _compile_answer(tokenizer: Any, schema: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
157
+ """The answer grammar and the compiler options it needed beyond the defaults.
158
+
159
+ A schema that compiles as written gets exactly the grammar it always got;
160
+ only one llguidance refuses is compiled again with `_RELAXED_OPTIONS`.
161
+ """
162
+ import llguidance as llg
163
+ from llguidance import LLMatcher
164
+
165
+ schema_text = json.dumps(schema)
166
+ llg_tok = tool_grammar._get_llg_tokenizer(tokenizer)
167
+ try:
168
+ if hasattr(llg, "JsonCompiler"):
169
+ grammar = llg.JsonCompiler(whitespace_flexible=True).compile(
170
+ schema_text
171
+ )
172
+ else:
173
+ grammar = llg.grammar_from("json_schema", schema_text)
174
+ except Exception as exc: # noqa: BLE001 - any compiler rejection is the caller's schema
175
+ return _compile_relaxed(schema_text, llg_tok, exc), dict(_RELAXED_OPTIONS)
176
+ err = LLMatcher.validate_grammar(grammar, llg_tok)
177
+ if err:
178
+ raise _unenforceable(err)
179
+ return grammar, {}
180
+
181
+
182
+ def _compile_relaxed(schema_text: str, llg_tok: Any, refusal: Exception) -> str:
183
+ from llguidance import LLMatcher
184
+
185
+ compile_schema = getattr(LLMatcher, "grammar_from_json_schema", None)
186
+ validate = getattr(LLMatcher, "validate_grammar_with_warnings", None)
187
+ if compile_schema is None or validate is None:
188
+ raise _unenforceable(refusal) from refusal
189
+ try:
190
+ grammar = compile_schema(
191
+ schema_text, overrides={"whitespace_flexible": True, **_RELAXED_OPTIONS}
192
+ )
193
+ is_error, messages = validate(grammar, llg_tok)
194
+ except Exception as exc: # noqa: BLE001 - any compiler rejection is the caller's schema
195
+ raise _unenforceable(exc) from exc
196
+ if is_error:
197
+ # The relaxed pass's error, not the first refusal: it names what is
198
+ # left once every relaxation has been applied.
199
+ raise _unenforceable(messages[0] if messages else refusal)
200
+ print(
201
+ f"[response-format] schema relaxed to compile: {'; '.join(messages) or refusal}",
202
+ flush=True,
203
+ )
204
+ return grammar
205
+
206
+
207
+ def grounding_note(response_format: Optional[Dict[str, Any]]) -> Optional[str]:
208
+ """What the model is told about the format, or None for plain text.
209
+
210
+ No word about tools: the template already lists them, and "call a tool if
211
+ you need one" made qwen3.8-27b call one for questions it could answer.
212
+ """
213
+ schema = response_schema(response_format)
214
+ if schema is None:
215
+ return None
216
+ if schema == {"type": "object"}:
217
+ return "Reply with a single JSON object."
218
+ return "Reply with a single JSON value matching this JSON Schema:\n" + json.dumps(
219
+ schema, separators=(",", ":")
220
+ )
221
+
222
+
223
+ def note_placement(tool_grammar_hint: Optional[Dict[str, Any]]) -> str:
224
+ """Where a model family reads the format note best: "system" or "user".
225
+
226
+ Measured 2026-10-14 with a weather tool offered and thinking off, on
227
+ questions the model can answer itself. qwen3.8-27b (hermes) answered in
228
+ JSON with the note in the system message, but called the weather tool for
229
+ "What is 17 times 23?" with it in the user's turn. gemma4-e4b was the
230
+ reverse: in the system message it reached for a tool it did not have
231
+ (`google_search`), in the user's turn it answered in JSON. Both called
232
+ the tool for the weather either way. Unknown families get the system
233
+ message, the conventional place.
234
+ """
235
+ return "user" if str((tool_grammar_hint or {}).get("format") or "") == "gemma" else "system"
236
+
237
+
238
+ def _with_note(message: Any, note: str) -> Any:
239
+ content = getattr(message, "content", None)
240
+ if isinstance(content, str):
241
+ return message.model_copy(update={"content": f"{content}\n\n{note}"})
242
+ if isinstance(content, list):
243
+ return message.model_copy(update={"content": [*content, {"type": "text", "text": note}]})
244
+ return message
245
+
246
+
247
+ def ground_messages(messages: List[Any], note: Optional[str], place: str = "system") -> List[Any]:
248
+ """The messages with `note` added where the model family reads it best.
249
+
250
+ "system": appended to the first system message, or added as one when
251
+ there is none, never as a second (Qwen's template rejects two). "user":
252
+ appended to the last user turn, the tail of the prompt, where a prefix
253
+ cache can best afford a change. See `note_placement`.
254
+ """
255
+ if not note or not messages:
256
+ return messages
257
+ out = list(messages)
258
+ if place == "user":
259
+ for i in range(len(out) - 1, -1, -1):
260
+ if getattr(out[i], "role", None) == "user":
261
+ out[i] = _with_note(out[i], note)
262
+ return out
263
+ return out
264
+ if getattr(out[0], "role", None) == "system":
265
+ out[0] = _with_note(out[0], note)
266
+ return out
267
+ return [type(out[0])(role="system", content=note), *out]
268
+
269
+
270
+ _WHITESPACE_RUN_LIMIT = 24
271
+ _WHITESPACE_TOKENS: Dict[int, Dict[int, int]] = {}
272
+
273
+
274
+ def _whitespace_tokens(tokenizer: Any) -> Dict[int, int]:
275
+ """Token id -> character count, for every token that decodes to whitespace."""
276
+ hf = tool_grammar._resolve_hf_tokenizer(tokenizer)
277
+ cached = _WHITESPACE_TOKENS.get(id(hf))
278
+ if cached is not None:
279
+ return cached
280
+ size = len(hf)
281
+ texts = hf.batch_decode([[i] for i in range(size)])
282
+ table = {i: len(t) for i, t in enumerate(texts) if t and not t.strip()}
283
+ _WHITESPACE_TOKENS[id(hf)] = table
284
+ return table
285
+
286
+
287
+ class _BoundedWhitespace:
288
+ """Forbid whitespace once a run of it reaches `limit` characters.
289
+
290
+ Sits inside the thinking wrapper, so it counts only the answer. A model
291
+ that wants to stop while the schema still owes a field pads with
292
+ whitespace forever, since that is all the grammar never blocks; past the
293
+ limit it has to write the field.
294
+ """
295
+
296
+ def __init__(self, inner: Any, whitespace: Dict[int, int], limit: int = _WHITESPACE_RUN_LIMIT) -> None:
297
+ import mlx.core as mx
298
+
299
+ self._inner = inner
300
+ self._whitespace = whitespace
301
+ self._limit = limit
302
+ self._run = 0
303
+ self._first = True
304
+ self._ids = mx.array(sorted(whitespace), dtype=mx.int32)
305
+
306
+ def __call__(self, input_ids: Any, logits: Any) -> Any:
307
+ if self._first:
308
+ self._first = False
309
+ else:
310
+ last = int(input_ids.reshape(-1)[-1].item())
311
+ width = self._whitespace.get(last)
312
+ self._run = self._run + width if width is not None else 0
313
+ logits = self._inner(input_ids, logits)
314
+ if self._run >= self._limit and self._ids.size:
315
+ logits[..., self._ids] = float("-inf")
316
+ return logits
317
+
318
+
319
+ def reasoning_gate(tokenizer: Any, prompt: str) -> Optional[Tuple[str, str]]:
320
+ """The reasoning tags the grammar waits out, or None to apply it at once.
321
+
322
+ Set when the rendered prompt leaves generation inside a reasoning block.
323
+ The grammar is released on the closer's token id, so a closer this
324
+ tokenizer splits into pieces cannot gate it: released on its last piece
325
+ (often `>`), it would trip on ordinary reasoning text.
326
+ """
327
+ tags = think_budget.open_reasoning_tags(prompt)
328
+ if tags is None:
329
+ return None
330
+ try:
331
+ ids = tokenizer.encode(tags[1], add_special_tokens=False)
332
+ except Exception: # noqa: BLE001 - no encode, no gate
333
+ ids = []
334
+ if len(ids) != 1:
335
+ print(
336
+ f"[response-format] the prompt opens reasoning but {tags[1]} is not one token; "
337
+ "the grammar applies from the first token",
338
+ flush=True,
339
+ )
340
+ return None
341
+ return tags
342
+
343
+
344
+ def build_response_format_processor(
345
+ tokenizer: Any, grammar: Optional[ResponseGrammar], prompt: str
346
+ ) -> Optional[Any]:
347
+ """The logits processor for a compiled response grammar, or None."""
348
+ if grammar is None:
349
+ return None
350
+ from mlx_vlm.structured import LLGuidanceLogitsProcessor, ThinkingAwareLogitsProcessor
351
+
352
+ processor: Any = _BoundedWhitespace(
353
+ LLGuidanceLogitsProcessor(grammar.text, tool_grammar._get_llg_tokenizer(tokenizer)),
354
+ _whitespace_tokens(tokenizer),
355
+ )
356
+ tags = reasoning_gate(tokenizer, prompt)
357
+ thinking = "off"
358
+ if tags is not None:
359
+ processor = ThinkingAwareLogitsProcessor(
360
+ processor,
361
+ tokenizer,
362
+ thinking_start_token=tags[0],
363
+ thinking_end_token=tags[1],
364
+ enable_thinking=True,
365
+ )
366
+ thinking = f"deferred until={tags[1]}"
367
+ print(
368
+ f"[response-format] active answer={'json-or-calls' if grammar.covers_tool_calls else 'json'} "
369
+ f"thinking={thinking}",
370
+ flush=True,
371
+ )
372
+ return tool_grammar.SafeToolGrammarProcessor(
373
+ processor, label="response-format", fallback="unconstrained text"
374
+ )
@@ -456,6 +456,11 @@ def spec_mode(request, grammar) -> Tuple[Optional[str], str]:
456
456
  """
457
457
  if bool(getattr(request, "disable_speculation", False)):
458
458
  return None, "request disabled speculative decoding after a grammar-contract violation"
459
+ # The assisted walk once let a required-argument tool grammar slip; a
460
+ # response format promises its caller exact JSON, so it decodes
461
+ # sequentially rather than risk the same on a schema's required keys.
462
+ if getattr(request, "response_format", None):
463
+ return None, "response formats decode sequentially so the JSON stays exact"
459
464
 
460
465
  t = getattr(request, "temperature", None)
461
466
  greedy = t in (None, 0, 0.0)
@@ -22,7 +22,41 @@ disables enforcement for the rest of the turn rather than killing it.
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
- from typing import Any, Optional
25
+ import re
26
+ from typing import Any, Optional, Tuple
27
+
28
+ # An opener the chat template left as the last thing before generation: the
29
+ # model then starts inside reasoning and only ever streams the closer. Gemma 4
30
+ # names its channel on the opener's line (`<|channel>thought\n`).
31
+ _OPEN_TAIL = re.compile(r"(?:<think>|<reasoning>|\[THINK\]|<\|channel>[A-Za-z_][\w-]*)\s*$", re.IGNORECASE)
32
+ _REASONING_TAGS = (
33
+ ("<think>", "</think>"),
34
+ ("<reasoning>", "</reasoning>"),
35
+ ("[THINK]", "[/THINK]"),
36
+ ("<|channel>", "<channel|>"),
37
+ )
38
+
39
+
40
+ def open_reasoning_tags(prompt: str) -> Optional[Tuple[str, str]]:
41
+ """The opener the rendered prompt leaves generation inside, and the tag
42
+ that closes it; None when the prompt opens no reasoning block."""
43
+ match = _OPEN_TAIL.search(prompt or "")
44
+ if not match:
45
+ return None
46
+ tail = match.group(0).lower()
47
+ return next((tags for tags in _REASONING_TAGS if tail.startswith(tags[0].lower())), None)
48
+
49
+
50
+ def prompt_opens_reasoning(prompt: str) -> bool:
51
+ """Whether the rendered prompt leaves generation inside a reasoning block.
52
+
53
+ The client splits live reasoning from the reply and cannot get this from
54
+ the template text: Qwen 3.5's small models open `<think>` only when
55
+ `enable_thinking` is explicitly true, and Gemma 4 opens its thought
56
+ channel only after a tool response. Guessing wrong streams the model's
57
+ thoughts as its answer.
58
+ """
59
+ return open_reasoning_tags(prompt) is not None
26
60
 
27
61
 
28
62
  class ThinkBudgetProcessor: