Skip to content

Commit f6affca

Browse files
committed
fix(studio): count an MLX prompt with the resident model's own tokenizer
Studio's chat header prices a conversation against the model's context window, and the recount behind it had a llama.cpp branch only. For an MLX-served model the endpoint answered "No GGUF model loaded", so the bar showed a dash for the whole conversation until the first reply reported its own usage -- the case unslothai#8882 made visible by rendering the bar as soon as the window is known. The endpoint now asks MLX after finding no GGUF backend, below the refusals that are about the request rather than the backend and above llama.cpp's own render concerns. A count is only worth showing if it renders what the completion for the same request would render, so this answers the questions that completion answers rather than the ones the request makes easy. Which of its two paths claims the request: the tool loop, or the relay that a client catalog or replayed tool history goes to. What each then renders: the loop's action nudge and its strip of stale call markup, the relay's rebuild that keeps structured tool_calls through templating, the launcher's tools-on default where a request stated no intent of its own and its withdrawal where the request did, the catalog a named template's tool_use branch advertises, and a zero tool budget suppressing the loop entirely. Where a helper carries one of those decisions it is called rather than restated. Three shapes are refused rather than priced short: a pending turn whose retrieval would reach the document store, since only running the search would say what it adds; an image, which /apply-template swaps for a short marker; and an empty prompt, whose bare generation marker reads as a started conversation. A vision model is counted rather than skipped. It serves text turns through the processor render its generation uses, so counting shares that render, and asks mlx_vlm which special markers tokenization should add -- a per-model answer, and on the releases from before mlx_vlm exported it, the rule those releases inline. Both store questions on this path -- the enabled MCP servers and whether retrieval can run -- open SQLite, and the second can load an extension and create the schema, so they run on the worker thread and only where their answer decides something.
1 parent dabcc28 commit f6affca

4 files changed

Lines changed: 409 additions & 52 deletions

File tree

studio/backend/core/inference/mlx_inference.py

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,6 +1127,15 @@ def _processor(tokens, logits):
11271127
return _processor
11281128

11291129

1130+
# The families the mlx_vlm releases from before should_add_special_tokens existed inlined into
1131+
# their generation path: their chat template emits the special markers, so tokenization must not
1132+
# add them again. Deliberately not the current helper's list, which also carries laguna -- laguna
1133+
# models run on 0.6.0 onward but only 0.6.9 stopped tokenizing them with the markers, and 0.6.0
1134+
# through 0.6.8 are what this stands in for. A family that arrived with its list entry is safe to
1135+
# keep here, since a release without the entry cannot load the model either.
1136+
_VLM_INLINE_SPECIAL_TOKEN_FAMILIES = ("gemma3", "gemma3n", "gemma4", "gemma4_unified")
1137+
1138+
11301139
class MLXInferenceBackend:
11311140
def __init__(self):
11321141
self.models = {}
@@ -1724,13 +1733,37 @@ def count_chat_tokens(
17241733
"""
17251734
if self._model is None:
17261735
raise RuntimeError("No model loaded")
1727-
if self._is_vlm:
1728-
# A vision model renders through its processor, which recovers from template
1729-
# failures by rewriting the conversation; the text renderer would not.
1730-
raise RuntimeError("Counting is not supported for vision models")
1731-
17321736
full_messages = self._with_system_prompt(messages, system_prompt)
17331737

1738+
if self._is_vlm:
1739+
# Through the processor, which is what a vision generation renders with; the
1740+
# text renderer would not recover the template failures it recovers from.
1741+
# images=None: an image anywhere in the conversation makes the structured-item
1742+
# check raise, and the caller declines rather than pricing a prompt without it.
1743+
prompt, _ = self._render_vlm_prompt(
1744+
full_messages,
1745+
None,
1746+
tools = tools,
1747+
enable_thinking = enable_thinking,
1748+
reasoning_effort = reasoning_effort,
1749+
preserve_thinking = preserve_thinking,
1750+
)
1751+
# Whether the markers belong to the template or to tokenization is a per-model
1752+
# answer mlx_vlm makes for every generation; ask it rather than guess, or the
1753+
# count is off by whatever the generation's own choice would have added.
1754+
_model_type = getattr(getattr(self._model, "config", None), "model_type", None)
1755+
try:
1756+
from mlx_vlm.utils import should_add_special_tokens
1757+
add_special = should_add_special_tokens(_model_type, self._processor)
1758+
except Exception:
1759+
# The rule those releases inline, which Studio's runtime gate still accepts.
1760+
add_special = (
1761+
getattr(self._processor, "chat_template", None) is None
1762+
if _model_type in _VLM_INLINE_SPECIAL_TOKEN_FAMILIES
1763+
else True
1764+
)
1765+
return len(self._tokenizer.encode(prompt, add_special_tokens = add_special))
1766+
17341767
render_result = self._render_text_prompt(
17351768
full_messages,
17361769
tools = tools,
@@ -1988,28 +2021,22 @@ def _generate_text(
19882021
normalized_output += tail
19892022
yield normalized_output
19902023

1991-
def _generate_vlm(
2024+
def _render_vlm_prompt(
19922025
self,
19932026
messages,
1994-
image,
1995-
temperature,
1996-
top_p,
1997-
top_k,
1998-
min_p,
1999-
max_new_tokens,
2000-
repetition_penalty,
2001-
cancel_event,
2027+
images,
20022028
*,
20032029
tools = None,
20042030
enable_thinking = None,
20052031
reasoning_effort = None,
20062032
preserve_thinking = None,
20072033
continue_final_message = False,
2008-
presence_penalty = 0.0,
2009-
_adapter_state = None,
20102034
):
2011-
from mlx_vlm import stream_generate as vlm_stream
2035+
"""Render the prompt a vision generation sends, and the target that rendered it.
20122036
2037+
Shared with counting, as _render_text_prompt is for text models, so a count cannot
2038+
price a prompt the model never sees. A text-only conversation passes images=None.
2039+
"""
20132040
from core.inference.chat_template_helpers import (
20142041
apply_chat_template_for_generation,
20152042
chat_render_target,
@@ -2021,8 +2048,6 @@ def _generate_vlm(
20212048
# to authorize against the same template this line selects (#7066).
20222049
chat_target = chat_render_target(self._processor)
20232050

2024-
# mlx_vlm's stream_generate handles pixel_values (None for text-only)
2025-
images = [image] if image is not None else None
20262051
attached_images = 0 if images is None else len(images)
20272052
structured_images = sum(
20282053
_count_vlm_images(message.get("content"))
@@ -2104,6 +2129,40 @@ def _generate_vlm(
21042129
prompt = recovered_prompt
21052130
elif prompt_issue:
21062131
raise RuntimeError(f"VLM chat template returned {prompt_issue}.") from prompt_error
2132+
return prompt, chat_target
2133+
2134+
def _generate_vlm(
2135+
self,
2136+
messages,
2137+
image,
2138+
temperature,
2139+
top_p,
2140+
top_k,
2141+
min_p,
2142+
max_new_tokens,
2143+
repetition_penalty,
2144+
cancel_event,
2145+
*,
2146+
tools = None,
2147+
enable_thinking = None,
2148+
reasoning_effort = None,
2149+
preserve_thinking = None,
2150+
continue_final_message = False,
2151+
presence_penalty = 0.0,
2152+
_adapter_state = None,
2153+
):
2154+
from mlx_vlm import stream_generate as vlm_stream
2155+
2156+
images = [image] if image is not None else None
2157+
prompt, chat_target = self._render_vlm_prompt(
2158+
messages,
2159+
images,
2160+
tools = tools,
2161+
enable_thinking = enable_thinking,
2162+
reasoning_effort = reasoning_effort,
2163+
preserve_thinking = preserve_thinking,
2164+
continue_final_message = continue_final_message,
2165+
)
21072166

21082167
from core.inference.chat_template_helpers import detect_think_prefill
21092168

studio/backend/models/inference.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,22 +2044,41 @@ class ChatCountTokensRequest(ReasoningControlsRequest):
20442044
)
20452045
permission_mode: Optional[str] = Field(
20462046
None,
2047-
description = "[x-unsloth] Permission level the completion would send. Only 'full' changes "
2048-
"the prompt: it swaps the python/terminal descriptions for the unsandboxed pair and adds a "
2049-
"sentence to the tool nudge, so a count that omits it prices a prompt the completion will "
2050-
"not send.",
2047+
description = "[x-unsloth] Permission level the completion would send. 'full' swaps the "
2048+
"python/terminal descriptions for the unsandboxed pair and adds a sentence to the tool "
2049+
"nudge; 'ask' holds the tool loop's first-pass retrieval behind its confirmation gate, so "
2050+
"a pending turn under a retrieval scope is countable. A count that omits this prices a "
2051+
"prompt the completion will not send, or declines one it could have priced.",
20512052
)
20522053
bypass_permissions: Optional[bool] = Field(
20532054
None,
20542055
description = "[x-unsloth] Equivalent of permission_mode='full'. Declared explicitly (not "
20552056
"left to extra='allow') so an omitted flag reads as None instead of raising AttributeError.",
20562057
)
20572058

2059+
confirm_tool_calls: Optional[bool] = Field(
2060+
None,
2061+
description = "[x-unsloth] Whether the completion's tool loop would gate each call. "
2062+
"Declared so it is typed and folded into permission_mode as it is on a completion.",
2063+
)
2064+
max_tool_calls_per_message: Optional[int] = Field(
2065+
None,
2066+
ge = 0,
2067+
description = "[x-unsloth] Tool-call budget the completion would send. Zero suppresses the "
2068+
"tool loop, so a count that never sees it prices a catalog the relay does not render.",
2069+
)
2070+
20582071
@field_validator("permission_mode", mode = "before")
20592072
@classmethod
20602073
def _coerce_permission_mode(cls, value: Any) -> Any:
20612074
return _normalize_permission_mode(value)
20622075

2076+
# The very function the completion request runs, not a copy: a count renders replayed
2077+
# tool history through the same templates, which read the id off the result message.
2078+
_resolve_missing_tool_call_ids = model_validator(mode = "after")(
2079+
ChatCompletionRequest._resolve_missing_tool_call_ids
2080+
)
2081+
20632082
@model_validator(mode = "after")
20642083
def _fold_full_permission_into_bypass(self) -> "ChatCountTokensRequest":
20652084
"""Mirrors ChatCompletionRequest: the prompt builders read only the
@@ -2068,6 +2087,11 @@ def _fold_full_permission_into_bypass(self) -> "ChatCountTokensRequest":
20682087
self.bypass_permissions = True
20692088
elif self.bypass_permissions:
20702089
self.permission_mode = "full"
2090+
elif self.permission_mode is None and self.confirm_tool_calls is True:
2091+
# The same reading a completion gives it: gating every call is the
2092+
# pre-permission-mode way of asking for "ask", and the loop's retrieval
2093+
# gate turns on that. No provider clause -- this endpoint is local only.
2094+
self.permission_mode = "ask"
20712095
return self
20722096

20732097

0 commit comments

Comments
 (0)