Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions mlx_lm/tokenizer_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,9 @@ def make_byte_decoder(cls):
def _infer_thinking(tokenizer):
vocab = tokenizer.get_vocab()
THINK_TOKENS = [
# Apertus has <think>/</think> in its vocabulary but never emits them,
# so its own markers must be checked first.
("<|inner_prefix|>", "<|inner_suffix|>"),
("<think>", "</think>"),
("<longcat_think>", "</longcat_think>"),
]
Expand Down Expand Up @@ -566,6 +569,8 @@ def _infer_tool_parser(chat_template):
return "qwen3_coder"
elif "<|tool_calls_section_begin|>" in chat_template:
return "kimi_k2"
elif "<|tools_prefix|>" in chat_template:
return "apertus"
elif "[TOOL_CALLS]" in chat_template:
return "mistral"
elif "<tool_call>" in chat_template and "tool_call.name" in chat_template:
Expand Down
35 changes: 35 additions & 0 deletions mlx_lm/tool_parsers/apertus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright © 2026 Apple Inc.
"""
Modified from:
https://github.com/vllm-project/vllm/blob/main/vllm/tool_parsers/apertus_tool_parser.py
"""

import json
from typing import Any

tool_call_start = "<|tools_prefix|>"
tool_call_end = "<|tools_suffix|>"


def parse_tool_call(text: str, tools: Any | None = None):
# Apertus emits an array of single key objects mapping the function name to
# its arguments, e.g.
# [{"get_weather": {"location": "London"}}, {"get_time": {}}]
calls = json.loads(text)
if not isinstance(calls, list):
calls = [calls]

tool_calls = []
for call in calls:
if not isinstance(call, dict) or not call:
continue
name, arguments = next(iter(call.items()))
# A call with no arguments can come back as null, but clients expect an
# object.
if arguments is None:
arguments = {}
tool_calls.append(dict(name=name, arguments=arguments))

if not tool_calls:
raise ValueError(f"Could not parse tool call from: {text}")
return tool_calls
30 changes: 30 additions & 0 deletions tests/test_tokenizers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
NaiveStreamingDetokenizer,
SPMStreamingDetokenizer,
TokenizerWrapper,
_infer_thinking,
)
from mlx_lm.utils import load_tokenizer

Expand Down Expand Up @@ -110,6 +111,35 @@ def test_thinking(self):
self.assertIsNone(tokenizer.think_start_id)
self.assertIsNone(tokenizer.think_end_id)

def test_thinking_marker_precedence(self):
# Apertus carries unused <think> tokens alongside the markers it
# actually emits, so its own markers have to win.
class Tokenizer:
def __init__(self, vocab):
self._vocab = vocab

def get_vocab(self):
return self._vocab

apertus_vocab = {
"<|inner_prefix|>": 32,
"<|inner_suffix|>": 33,
"<think>": 69,
"</think>": 70,
}
think_start, think_end, start_ids, end_ids = _infer_thinking(
Tokenizer(apertus_vocab)
)
self.assertEqual(think_start, "<|inner_prefix|>")
self.assertEqual(think_end, "<|inner_suffix|>")
self.assertEqual(start_ids, (32,))
self.assertEqual(end_ids, (33,))

think_start, _, _, _ = _infer_thinking(Tokenizer({"<think>": 1, "</think>": 2}))
self.assertEqual(think_start, "<think>")

self.assertEqual(_infer_thinking(Tokenizer({})), (None, None, None, None))

def test_find_token(self):
# Check that _find returns a valid index when
# searching for a think token in short system prompts
Expand Down
56 changes: 56 additions & 0 deletions tests/test_tool_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path

from mlx_lm.tool_parsers import (
apertus,
function_gemma,
gemma4,
glm47,
Expand Down Expand Up @@ -313,6 +314,61 @@ def test_kimi_k2(self):
]
self.assertEqual(tool_calls, expected)

def test_apertus(self):
# Single tool call
test_case = '[{"multiply": {"a": 12234585, "b": 48838483920}}]'
tool_calls = apertus.parse_tool_call(test_case, None)
expected = [
{"name": "multiply", "arguments": {"a": 12234585, "b": 48838483920}}
]
self.assertEqual(tool_calls, expected)

# Multiple tool calls
test_case = (
'[{"get_weather": {"location": "London"}}, '
'{"get_time": {"location": "London"}}]'
)
tool_calls = apertus.parse_tool_call(test_case, None)
expected = [
{"name": "get_weather", "arguments": {"location": "London"}},
{"name": "get_time", "arguments": {"location": "London"}},
]
self.assertEqual(tool_calls, expected)

# Nested arguments
test_case = (
'[{"complex_function": {"nested": {"inner": "value"}, "list": ["a", "b"]}}]'
)
tool_calls = apertus.parse_tool_call(test_case, None)
expected = [
{
"name": "complex_function",
"arguments": {"nested": {"inner": "value"}, "list": ["a", "b"]},
}
]
self.assertEqual(tool_calls, expected)

# Null arguments are normalized so clients receive an empty object
test_case = '[{"get_time": null}]'
tool_calls = apertus.parse_tool_call(test_case, None)
self.assertEqual(tool_calls, [{"name": "get_time", "arguments": {}}])

# A bare object rather than an array
test_case = '{"get_time": {}}'
tool_calls = apertus.parse_tool_call(test_case, None)
self.assertEqual(tool_calls, [{"name": "get_time", "arguments": {}}])

# Entries which cannot be a tool call are skipped
test_case = '[{"get_time": {}}, {}, "junk"]'
tool_calls = apertus.parse_tool_call(test_case, None)
self.assertEqual(tool_calls, [{"name": "get_time", "arguments": {}}])

# Nothing parseable raises, as does a call truncated mid generation
with self.assertRaises(ValueError):
apertus.parse_tool_call("[]", None)
with self.assertRaises(ValueError):
apertus.parse_tool_call('[{"get_weather": {"location": "London"}', None)

def test_minimax_m2(self):
test_case = (
'<invoke name="search">\n'
Expand Down