@bendyline/gezel-inference 1.0.0 → 1.0.2
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.
- package/README.md +90 -8
- package/dist/cache/adapter.d.ts +7 -0
- package/dist/cache/controller.d.ts +12 -0
- package/dist/cache/controller.js +24 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
- package/dist/catalog/chat-models/index.json +1 -1
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
- package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
- package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
- package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
- package/dist/catalog/chat-models/raw-index.json +1 -1
- package/dist/catalog/provenance.json +1 -1
- package/dist/engines/electron-native-reuse.d.ts +9 -0
- package/dist/engines/electron-native-reuse.js +146 -45
- package/dist/engines/native-file-manifest.json +404 -404
- package/dist/engines/native-manifest.d.ts +11 -3
- package/dist/engines/native-manifest.js +48 -18
- package/dist/http/openai-compat/apple-model.d.ts +2 -0
- package/dist/http/openai-compat/apple-model.js +21 -1
- package/dist/http/openai-compat/chat-target.d.ts +12 -0
- package/dist/http/openai-compat/chat-target.js +27 -1
- package/dist/http/openai-compat/engine-failure.d.ts +25 -0
- package/dist/http/openai-compat/engine-failure.js +76 -0
- package/dist/http/openai-compat/responses-streaming.js +2 -1
- package/dist/http/openai-compat/responses-translate.d.ts +1 -1
- package/dist/http/openai-compat/translate.d.ts +1 -1
- package/dist/http/routes/engines.js +44 -3
- package/dist/http/routes/v1-chat.js +30 -8
- package/dist/http/routes/v1-models.d.ts +6 -0
- package/dist/http/routes/v1-models.js +137 -30
- package/dist/http/routes/v1-responses.js +16 -3
- package/dist/in-flight-requests.d.ts +19 -0
- package/dist/in-flight-requests.js +34 -0
- package/dist/inference-service.js +3 -3
- package/dist/knowledge/catalog-host.d.ts +9 -1
- package/dist/knowledge/catalog-host.js +2 -0
- package/dist/knowledge/manager.d.ts +14 -0
- package/dist/knowledge/manager.js +53 -6
- package/dist/knowledge/search-worker.d.ts +1 -1
- package/dist/knowledge/spatial-query.d.ts +2 -3
- package/dist/knowledge/spatial-query.js +12 -6
- package/dist/knowledge/worker-host.js +2 -0
- package/dist/memory/embed-core.d.ts +16 -0
- package/dist/memory/embed-core.js +31 -0
- package/dist/memory/embed-worker.d.ts +7 -3
- package/dist/memory/embed-worker.js +13 -4
- package/dist/memory/embeddings.d.ts +17 -2
- package/dist/memory/embeddings.js +36 -7
- package/dist/models/ensure.d.ts +2 -0
- package/dist/models/ensure.js +9 -0
- package/dist/perf/responsiveness.d.ts +14 -1
- package/dist/perf/responsiveness.js +77 -7
- package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
- package/dist/providers/llama-cpp/backend-health.js +64 -0
- package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
- package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
- package/dist/providers/llama-cpp/build-provider.js +140 -118
- package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
- package/dist/providers/llama-cpp/cache-adapter.js +36 -1
- package/dist/providers/llama-cpp/devices.d.ts +11 -2
- package/dist/providers/llama-cpp/devices.js +10 -4
- package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
- package/dist/providers/llama-cpp/engine-flags.js +12 -18
- package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
- package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
- package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
- package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
- package/dist/providers/llama-cpp/models.d.ts +6 -0
- package/dist/providers/llama-cpp/models.js +19 -1
- package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
- package/dist/providers/llama-cpp/offload-planner.js +3 -14
- package/dist/providers/llama-cpp/provider.d.ts +8 -0
- package/dist/providers/llama-cpp/provider.js +4 -1
- package/dist/providers/mlx/provider.js +14 -11
- package/dist/providers/mlx/python/batch_processors.py +26 -0
- package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
- package/dist/providers/mlx/python/qwen4_ple.py +9 -2
- package/dist/providers/mlx/python/response_format.py +374 -0
- package/dist/providers/mlx/python/spec_decode.py +5 -0
- package/dist/providers/mlx/python/think_budget.py +35 -1
- package/dist/providers/mlx/python/tool_grammar.py +62 -3
- package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
- package/dist/providers/mlx/reasoning-stream.js +77 -16
- package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
- package/dist/providers/mlx/runtime-diagnostics.js +29 -2
- package/dist/providers/native/capacity-broker.d.ts +51 -10
- package/dist/providers/native/capacity-broker.js +80 -9
- package/dist/providers/native/capacity-budget.d.ts +35 -3
- package/dist/providers/native/capacity-budget.js +49 -5
- package/dist/providers/native/device-capacity.d.ts +1 -1
- package/dist/providers/native/device-capacity.js +23 -5
- package/dist/providers/native/local-engine-runtime.js +15 -13
- package/dist/providers/native/measured-budget.js +2 -0
- package/dist/providers/native/supervisor.d.ts +25 -0
- package/dist/providers/native/supervisor.js +59 -7
- package/dist/remotes/identity.d.ts +1 -0
- package/dist/remotes/identity.js +6 -0
- package/dist/remotes/pinned-fetch.d.ts +2 -0
- package/dist/remotes/pinned-fetch.js +30 -1
- package/dist/remotes/types.d.ts +14 -0
- package/dist/runtime-lock.js +1 -1
- package/dist/system/amdgpu-adapters.d.ts +18 -0
- package/dist/system/amdgpu-adapters.js +55 -0
- package/dist/system/gpu-carve-out.d.ts +84 -0
- package/dist/system/gpu-carve-out.js +111 -0
- package/dist/system/memory.d.ts +17 -1
- package/dist/system/memory.js +105 -14
- package/dist/transformers-cache.d.ts +2 -0
- package/dist/transformers-cache.js +6 -3
- package/dist/utils/service-worker-entry.d.ts +4 -0
- package/dist/utils/service-worker-entry.js +4 -0
- 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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
|
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
|
|
|
@@ -14,7 +14,6 @@ so concurrent replicas cannot observe a half-built view.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import fcntl
|
|
18
17
|
import hashlib
|
|
19
18
|
import json
|
|
20
19
|
import os
|
|
@@ -23,6 +22,13 @@ import uuid
|
|
|
23
22
|
from pathlib import Path
|
|
24
23
|
from typing import Callable, Optional, Tuple
|
|
25
24
|
|
|
25
|
+
try:
|
|
26
|
+
import fcntl
|
|
27
|
+
except ImportError:
|
|
28
|
+
# Windows has no fcntl. The MLX engine never runs there, but this module's
|
|
29
|
+
# stdlib suite does (python-suites.test.ts), so it must still import.
|
|
30
|
+
fcntl = None
|
|
31
|
+
|
|
26
32
|
|
|
27
33
|
PLE_MARKER = ".ple.ple_embedding.ngram_embedding.shards."
|
|
28
34
|
VIEW_MARKER = "GEZEL_PLE_VIEW.json"
|
|
@@ -122,7 +128,8 @@ def prepare_external_ple_view(
|
|
|
122
128
|
lock_path = target.parent / f".{target.name}.lock"
|
|
123
129
|
try:
|
|
124
130
|
with lock_path.open("a+b") as lock:
|
|
125
|
-
fcntl
|
|
131
|
+
if fcntl is not None:
|
|
132
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
126
133
|
if _valid_view(target, signature):
|
|
127
134
|
return str(target), "reused", True
|
|
128
135
|
|
|
@@ -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
|
-
|
|
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:
|