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
2 changes: 2 additions & 0 deletions mlx_lm/tokenizer_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@ def _infer_tool_parser(chat_template):
return "glm47"
elif "<|tool_list_start|>" in chat_template:
return "pythonic"
elif "<function_calls>" in chat_template:
return "olmo3"
elif (
"<tool_call>\\n<function=" in chat_template
or "<tool_call>\n<function=" in chat_template
Expand Down
55 changes: 55 additions & 0 deletions mlx_lm/tool_parsers/olmo3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright © 2026 Apple Inc.

import ast
from typing import Any

import regex as re

"""
Tool parser for Olmo 3 function call format.

Parses assistant responses containing tool calls in formats like:
<function_calls>
function_name(arg1="value1", arg2=2)
</function_calls>

Multiple tool calls are newline-separated within the tags. Argument values
are JSON literals (null, true, false) instead of Python literals.
"""


_tool_call_regex = re.compile(r"^\s*(\w+)\((.*)\)\s*$", re.MULTILINE)
_tool_args_regex = re.compile(r'(\w+)=(?:"([^"]*)"|([^,]+))(?:,\s*|$)', re.DOTALL)

_JSON_LITERALS = {"null": None, "true": True, "false": False}


def _coerce(value: str):
value = value.strip()
if value in _JSON_LITERALS:
return _JSON_LITERALS[value]
try:
return ast.literal_eval(value)
except (ValueError, SyntaxError):
return value


def parse_tool_call(text: str, tools: Any | None = None):
calls = []
for match in _tool_call_regex.finditer(text):
func_name = match.group(1)
args_str = match.group(2)
arguments = {}
if args_str:
for key, quoted, raw in _tool_args_regex.findall(args_str):
arguments[key.strip()] = quoted if quoted else _coerce(raw)
calls.append({"name": func_name, "arguments": arguments})

if not calls:
raise ValueError("No function provided.")

return calls if len(calls) > 1 else calls[0]


tool_call_start = "<function_calls>"
tool_call_end = "</function_calls>"
33 changes: 33 additions & 0 deletions tests/test_tool_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
longcat,
minimax_m2,
mistral,
olmo3,
pythonic,
qwen3_coder,
)
Expand Down Expand Up @@ -53,6 +54,10 @@ def test_parsers(self):
"[multiply(a=12234585, b=48838483920)]",
pythonic,
),
(
"multiply(a=12234585, b=48838483920)",
olmo3,
),
(
'multiply[ARGS]{"a": 12234585, "b": 48838483920}',
mistral,
Expand Down Expand Up @@ -123,6 +128,10 @@ def test_parsers(self):
'[get_current_temperature(location="London")]',
pythonic,
),
(
'get_current_temperature(location="London")',
olmo3,
),
(
'get_current_temperature[ARGS]{"location": "London"}',
mistral,
Expand Down Expand Up @@ -313,6 +322,30 @@ def test_kimi_k2(self):
]
self.assertEqual(tool_calls, expected)

def test_olmo3(self):
# Multiple tool calls
test_case = (
'search(query="weather")\n'
'read_file(path="/tmp/test.txt")'
)
tool_calls = olmo3.parse_tool_call(test_case, None)
self.assertIsInstance(tool_calls, list)
self.assertEqual(len(tool_calls), 2)
self.assertEqual(tool_calls[0], {"name": "search", "arguments": {"query": "weather"}})
self.assertEqual(
tool_calls[1],
{"name": "read_file", "arguments": {"path": "/tmp/test.txt"}},
)

# JSON literals are accepted (true/false/null instead of Python literals)
test_case = 'configure(enabled=true, name="x", missing=null)'
tool_call = olmo3.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "configure")
self.assertEqual(
tool_call["arguments"],
{"enabled": True, "name": "x", "missing": None},
)

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