Compare commits

...

15 Commits

Author SHA1 Message Date
Arun Raj df1d3f3c9a Fix Gemma 4 sanitize() not stripping KV projections for shared layers (#1240) 2026-05-04 15:26:18 -07:00
Angelos Katharopoulos ed1fca4cef Thread local generation stream (#1090) 2026-04-22 00:34:09 -07:00
glyphVault 4f5cbd2a4f Fix Gemma 4 KV-shared layers creating unused projections (#1158) 2026-04-21 16:44:13 -07:00
Angelos Katharopoulos 3cd9a52df2 Fix ArraysCache extend (#1177) 2026-04-21 16:41:49 -07:00
Eyüp Can Akman 2f1ab85aec Fix Mistral empty tool_call_end flipping state machine to normal (#1151) 2026-04-21 01:36:56 -07:00
AkashKhamkar f3bb10c141 Fix Gemma4 tool parser: support hyphenated names and braces in strings (#1150) 2026-04-21 01:15:58 -07:00
Sherry Lo e1c24b3237 fix: handle NoneType check for think tokens in TokenizerWrapper (#1167) 2026-04-21 01:13:15 -07:00
Luis Molina f39cb8e934 Fix dwq: check for actual safetensors in target_dir (#1173) 2026-04-21 00:41:53 -07:00
TechToboggan a9856b485d Fix batch dimension mismatch in ArraysCache extend() (#1169)
Co-authored-by: Tristan Stahnke <tristan@melchior.lan>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-04-20 23:42:03 -07:00
Andrei Panferov e92138cb01 Apertus tie_word_embeddings fix (#1143) 2026-04-20 23:47:40 +02:00
Siiea-ai a401730941 Fix missing tree_reduce import in models/cache.py (#1165) 2026-04-20 11:31:58 -07:00
Tarjei Mandt 6d114686e5 Fix MiniMax M2 parallel tool calling (#1171) 2026-04-20 10:57:20 -07:00
Tarjei Mandt aa4f880fb3 Fix parallel tool call handling in server (#1170) 2026-04-19 23:58:25 -07:00
razorback16 62f38aeb51 Fix batch dimension mismatch in BatchKVCache and BatchRotatingKVCache extend() (#1141) 2026-04-14 17:21:31 -07:00
Angelos Katharopoulos d9c63fff67 Bump the patch version (#1124) 2026-04-08 02:04:27 -07:00
18 changed files with 589 additions and 167 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Copyright © 2023-2025 Apple Inc.
__version__ = "0.31.2"
__version__ = "0.31.3"
+12 -4
View File
@@ -223,7 +223,7 @@ def setup_arg_parser():
# A stream on the default device just for generation
generation_stream = mx.new_stream(mx.default_device())
generation_stream = mx.new_thread_local_stream(mx.default_device())
@contextlib.contextmanager
@@ -1497,6 +1497,7 @@ class BatchGenerator:
def __init__(
self,
model: nn.Module,
*,
max_tokens: int = 128,
stop_tokens: Optional[Sequence[Sequence[int]]] = None,
sampler: Optional[Callable[[mx.array], mx.array]] = None,
@@ -1507,6 +1508,7 @@ class BatchGenerator:
prefill_batch_size: int = 8,
prefill_step_size: int = 2048,
max_kv_size: Optional[int] = None,
stream=None,
):
self.model = model
self.max_tokens = max_tokens
@@ -1518,6 +1520,8 @@ class BatchGenerator:
self.completion_batch_size = max(completion_batch_size, prefill_batch_size)
self.max_kv_size = max_kv_size
self._stream = stream or generation_stream
self._default_state_machine = SequenceStateMachine(
{"normal": [(seq, None) for seq in stop_tokens]} if stop_tokens else {},
initial="normal",
@@ -1544,9 +1548,13 @@ class BatchGenerator:
else:
self._old_wired_limit = None
@property
def stream(self):
return self._stream
def close(self):
if self._old_wired_limit is not None:
mx.synchronize(generation_stream)
mx.synchronize(self._stream)
mx.set_wired_limit(self._old_wired_limit)
self._old_wired_limit = None
@@ -1843,7 +1851,7 @@ class BatchGenerator:
Returns:
Tuple of prompt processing responses and generation responses.
"""
with mx.stream(generation_stream):
with mx.stream(self._stream):
return self._next()
def next_generated(self):
@@ -1853,7 +1861,7 @@ class BatchGenerator:
Returns:
List of GenerationBatch.Response objects
"""
with mx.stream(generation_stream):
with mx.stream(self._stream):
while True:
prompt_responses, generation_responses = self._next()
if not generation_responses and prompt_responses:
+9 -2
View File
@@ -167,7 +167,8 @@ class Model(nn.Module):
self.args = args
self.model_type = args.model_type
self.model = ApertusModel(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
@@ -175,12 +176,18 @@ class Model(nn.Module):
cache: Optional[Any] = None,
) -> mx.array:
out = self.model(inputs, cache)
return self.lm_head(out)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
for k, v in weights.items():
if k.endswith("alpha_p") or k.endswith("alpha_n"):
weights[k] = v.squeeze()
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
return weights
@property
+41 -7
View File
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_map, tree_unflatten
from mlx.utils import tree_flatten, tree_map, tree_reduce, tree_unflatten
from .base import create_causal_mask
@@ -603,6 +603,18 @@ class ArraysCache(_BaseCache):
if left_padding:
self.left_padding = mx.array(left_padding)
@property
def batch_size(self):
for c in self.cache:
if c is not None:
return c.shape[0]
if self.left_padding is not None:
return self.left_padding.size
elif self.lengths is not None:
return self.lengths.size
else:
return 1
def __setitem__(self, idx, value):
self.cache[idx] = value
@@ -622,6 +634,8 @@ class ArraysCache(_BaseCache):
In-place filter to keep just the given indices in the cache.
"""
self.cache = [c[batch_indices] if c is not None else None for c in self.cache]
if self.left_padding is not None:
self.left_padding = self.left_padding[batch_indices]
if self.lengths is not None:
self.lengths = self.lengths[batch_indices]
@@ -630,14 +644,31 @@ class ArraysCache(_BaseCache):
In-place extend this cache with the other cache.
"""
a_batch = self.batch_size
b_batch = other.batch_size
def cat(a, b):
shape = dtype = None
if a is not None:
shape = a.shape
dtype = a.dtype
if b is not None:
shape = b.shape
dtype = b.dtype
if shape is None:
return None
if a is None:
return b
a = mx.zeros((a_batch,) + shape[1:], dtype=dtype)
if b is None:
return a
b = mx.zeros((b_batch,) + shape[1:], dtype=dtype)
return mx.concatenate([a, b])
self.cache = [cat(c, o) for c, o in zip(self.cache, other.cache)]
self.left_padding = cat(self.left_padding, other.left_padding)
self.lengths = cat(self.lengths, other.lengths)
def extract(self, idx):
cache = ArraysCache(len(self.cache))
@@ -675,6 +706,7 @@ class ArraysCache(_BaseCache):
# All caches are empty so return early
if all(c.empty() for c in caches):
cache.left_padding = mx.array([0] * B)
return cache
for e in range(n_state):
@@ -1024,8 +1056,9 @@ class BatchKVCache(_BaseCache):
def pad(c):
k, v = c.keys, c.values
if k is None:
k = mx.array([]).reshape(B, H, 0, D)
v = mx.array([]).reshape(B, H, 0, M)
Bc = c.offset.shape[0]
k = mx.array([]).reshape(Bc, H, 0, D)
v = mx.array([]).reshape(Bc, H, 0, M)
left = max_idx - c._idx
right = max_size - k.shape[2] - left
if right < 0:
@@ -1360,8 +1393,9 @@ class BatchRotatingKVCache(_BaseCache):
left = max_idx - c._idx
k, v = c.keys, c.values
if k is None:
k = mx.array([]).reshape(B, H, 0, D)
v = mx.array([]).reshape(B, H, 0, M)
Bc = c.offset.shape[0]
k = mx.array([]).reshape(Bc, H, 0, D)
v = mx.array([]).reshape(Bc, H, 0, M)
right = max_size - k.shape[2] - left
if right < 0:
k = k[..., :right, :]
+27 -5
View File
@@ -180,6 +180,7 @@ class Attention(nn.Module):
self.layer_idx = layer_idx
self.layer_type = config.layer_types[layer_idx]
self.is_sliding = self.layer_type == "sliding_attention"
self.has_kv = layer_idx < config.num_hidden_layers - config.num_kv_shared_layers
self.head_dim = (
config.global_head_dim
@@ -202,14 +203,18 @@ class Attention(nn.Module):
self.scale = 1.0
self.q_proj = nn.Linear(dim, self.n_heads * self.head_dim, bias=False)
self.k_proj = nn.Linear(dim, self.n_kv_heads * self.head_dim, bias=False)
if not self.use_k_eq_v:
self.v_proj = nn.Linear(dim, self.n_kv_heads * self.head_dim, bias=False)
if self.has_kv:
self.k_proj = nn.Linear(dim, self.n_kv_heads * self.head_dim, bias=False)
if not self.use_k_eq_v:
self.v_proj = nn.Linear(
dim, self.n_kv_heads * self.head_dim, bias=False
)
self.o_proj = nn.Linear(self.n_heads * self.head_dim, dim, bias=False)
self.q_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps)
self.k_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps)
self.v_norm = RMSNormNoScale(self.head_dim, eps=config.rms_norm_eps)
if self.has_kv:
self.k_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps)
self.v_norm = RMSNormNoScale(self.head_dim, eps=config.rms_norm_eps)
# RoPE (with partial rotation support)
layer_key = "sliding_attention" if self.is_sliding else "full_attention"
@@ -238,6 +243,10 @@ class Attention(nn.Module):
if shared_kv is not None:
keys, values = shared_kv
elif not self.has_kv:
raise ValueError(
f"Layer {self.layer_idx} is a KV-shared layer but received no shared_kv"
)
else:
keys = self.k_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim)
values = keys
@@ -600,6 +609,7 @@ class Model(nn.Module):
def sanitize(self, weights):
sanitized = {}
first_kv_shared = self.args.num_hidden_layers - self.args.num_kv_shared_layers
for k, v in weights.items():
if any(
s in k
@@ -613,6 +623,18 @@ class Model(nn.Module):
):
continue
# KV-shared layers reuse K/V from earlier layers — drop their projections
if any(
s in k
for s in (".self_attn.k_proj", ".self_attn.v_proj", ".self_attn.k_norm")
):
try:
layer_idx = int(k.split("layers.")[1].split(".")[0])
if layer_idx >= first_kv_shared:
continue
except (IndexError, ValueError):
pass
if k.endswith(".experts.gate_up_proj"):
base = k.removesuffix(".gate_up_proj")
gate, up = map(mx.contiguous, mx.split(v, 2, axis=-2))
+5 -1
View File
@@ -314,7 +314,11 @@ def main():
if args.target_dir is not None:
target_dir = Path(args.target_dir)
has_targets = target_dir.exists()
has_targets = (
target_dir.is_dir()
and any((target_dir / "train").glob("*.safetensors"))
and any((target_dir / "valid").glob("*.safetensors"))
)
else:
has_targets = False
target_dir = None
+123 -110
View File
@@ -10,7 +10,7 @@ import time
import uuid
import warnings
from collections import deque
from dataclasses import dataclass
from dataclasses import dataclass, replace
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from queue import Empty as QueueEmpty
@@ -36,7 +36,6 @@ from ._version import __version__
from .generate import (
BatchGenerator,
SequenceStateMachine,
generation_stream,
stream_generate,
)
from .models.cache import (
@@ -78,7 +77,14 @@ class ToolCallFormatter:
result = []
for tool_text in tool_calls:
parsed = self._tool_parser(tool_text, self._tools)
try:
parsed = self._tool_parser(tool_text, self._tools)
except (ValueError, json.JSONDecodeError) as e:
logging.warning(
f"Failed to parse tool call ({type(e).__name__}: {e}) — "
f"tool text was likely truncated mid-generation."
)
continue
if not isinstance(parsed, list):
parsed = [parsed]
result.extend(self._format(tc) for tc in parsed)
@@ -227,6 +233,22 @@ class Response:
top_tokens: Tuple[Dict[str, Any]]
def _process_control_tokens(ctx, token_stream):
buffer_size = max(len(s) for s in ctx.sequences)
buffered_stream = deque()
for tok in token_stream:
buffered_stream.append(tok)
if tok.match is not None:
popped = [buffered_stream.pop() for _ in tok.match]
for t in reversed(popped):
buffered_stream.append(replace(t, text=""))
if len(buffered_stream) >= buffer_size:
yield buffered_stream.popleft()
while len(buffered_stream) > 0:
yield buffered_stream.popleft()
class TimeBudget:
def __init__(self, budget=0.5, iterations=25, sync_frequency=10):
self._is_distributed = mx.distributed.init().size() > 1
@@ -256,8 +278,7 @@ class TimeBudget:
self._loops += 1
self._time_spent += time.time() - self._start
if self._loops % self._sync_frequency == 0:
with mx.stream(generation_stream):
loop_time = mx.distributed.all_sum(self._time_spent).item()
loop_time = mx.distributed.all_sum(self._time_spent).item()
avg_loop_time = loop_time / (
mx.distributed.init().size() * self._sync_frequency
)
@@ -285,94 +306,92 @@ class ModelProvider:
)
self.is_distributed = group.size() > 1
# Preload the default model if it is provided
self.default_model_map = {}
if self.cli_args.model is not None:
self.default_model_map[self.cli_args.model] = "default_model"
self.load(self.cli_args.model, draft_model_path="default_model")
# Maps model and adapter paths the actual paths to be used. Used to
# map 'default_model' to the provided model by cli argument but could
# be used for more in the future.
self._model_map = {}
self._adapter_map = {}
self._draft_model_map = {}
self._model_map["default_model"] = self.cli_args.model
self._adapter_map["default_model"] = self.cli_args.adapter_path
self._draft_model_map["default_model"] = self.cli_args.draft_model
# Added in adapter_path to load dynamically
def load(self, model_path, adapter_path=None, draft_model_path=None):
model_path = self.default_model_map.get(model_path, model_path)
if self.model_key == (model_path, adapter_path, draft_model_path):
return self.model, self.tokenizer
# Build the tokenizer config for later use in load
self._tokenizer_config = {
"trust_remote_code": True if cli_args.trust_remote_code else None
}
if cli_args.chat_template:
self._tokenizer_config["chat_template"] = cli_args.chat_template
def _load(self, model_path, adapter_path=None, draft_model_path=None):
if self.is_distributed and (
adapter_path is not None or draft_model_path is not None
):
raise ValueError(
"Loading with adapters or draft models not supported in distributed mode"
)
# Remove the old model if it exists.
self.model_key = None
self.model = None
self.tokenizer = None
self.model_key = None
self.draft_model = None
# Building tokenizer_config
tokenizer_config = {
"trust_remote_code": True if self.cli_args.trust_remote_code else None
}
if self.cli_args.chat_template:
tokenizer_config["chat_template"] = self.cli_args.chat_template
if model_path == "default_model":
if self.cli_args.model is None:
raise ValueError(
"A model path has to be given as a CLI "
"argument or in the HTTP request"
)
adapter_path = adapter_path or self.cli_args.adapter_path
# TODO: Generalize distributed load
if self.is_distributed:
model, tokenizer = sharded_load(
self.cli_args.model, self.pipeline_group, self.tensor_group
)
else:
model, tokenizer = load(
self.cli_args.model,
adapter_path=adapter_path,
tokenizer_config=tokenizer_config,
)
# Load the model and tokenizer
if self.is_distributed:
model, tokenizer = sharded_load(
model_path,
pipeline_group=self.pipeline_group,
tensor_group=self.tensor_group,
tokenizer_config=self._tokenizer_config,
)
else:
# TODO: Generalize distributed load
if self.is_distributed:
model, tokenizer = sharded_load(
model_path, self.pipeline_group, self.tensor_group
)
else:
model, tokenizer = load(
model_path,
adapter_path=adapter_path,
tokenizer_config=tokenizer_config,
)
model, tokenizer = load(
model_path,
adapter_path=adapter_path,
tokenizer_config=self._tokenizer_config,
)
# Use the default chat template if needed
if self.cli_args.use_default_chat_template:
if tokenizer.chat_template is None:
tokenizer.chat_template = tokenizer.default_chat_template
self.model_key = (model_path, adapter_path, draft_model_path)
self.model = model
self.tokenizer = tokenizer
def validate_draft_tokenizer(draft_tokenizer):
# Check if tokenizers are compatible
# Load the draft model for speculative decoding
draft_model = None
if draft_model_path is not None:
draft_model, draft_tokenizer = load(draft_model_path)
if draft_tokenizer.vocab_size != tokenizer.vocab_size:
logging.warning(
"Draft model tokenizer does not match model tokenizer. "
"Speculative decoding may not work as expected."
)
# Load draft model if specified
if (
draft_model_path == "default_model"
and self.cli_args.draft_model is not None
):
self.draft_model, draft_tokenizer = load(self.cli_args.draft_model)
validate_draft_tokenizer(draft_tokenizer)
# Compute batchability
is_batchable = draft_model is None
is_batchable = is_batchable and all(
hasattr(c, "merge") for c in make_prompt_cache(model)
)
elif draft_model_path is not None and draft_model_path != "default_model":
self.draft_model, draft_tokenizer = load(draft_model_path)
validate_draft_tokenizer(draft_tokenizer)
# Update the member variables
self.model_key = (model_path, adapter_path, draft_model_path)
self.model = model
self.tokenizer = tokenizer
self.draft_model = draft_model
self.is_batchable = is_batchable
if self.draft_model is None:
self.is_batchable = all(
hasattr(c, "merge") for c in make_prompt_cache(self.model)
)
def load_default(self):
if self._model_map["default_model"] is not None:
self.load("default_model", None, "default_model")
def load(self, model_path, adapter_path=None, draft_model_path=None):
model_path = self._model_map.get(model_path, model_path)
adapter_path = self._adapter_map.get(model_path, adapter_path)
draft_model_path = self._draft_model_map.get(draft_model_path, draft_model_path)
model_key = (model_path, adapter_path, draft_model_path)
if self.model_key != model_key:
self._load(*model_key)
return self.model, self.tokenizer
@@ -466,22 +485,21 @@ class ResponseGenerator:
if not self._is_distributed:
return obj
with mx.stream(generation_stream):
if self._rank == 0:
if obj is None:
mx.eval(mx.distributed.all_sum(0))
return None
data = mx.array(pickle.dumps(obj))
mx.eval(mx.distributed.all_sum(data.size))
mx.eval(mx.distributed.all_sum(data))
return obj
else:
size = mx.distributed.all_sum(0).item()
if size == 0:
return None
data = mx.zeros(size, dtype=mx.uint8)
data = mx.distributed.all_sum(data)
return pickle.loads(data)
if self._rank == 0:
if obj is None:
mx.eval(mx.distributed.all_sum(0))
return None
data = mx.array(pickle.dumps(obj))
mx.eval(mx.distributed.all_sum(data.size))
mx.eval(mx.distributed.all_sum(data))
return obj
else:
size = mx.distributed.all_sum(0).item()
if size == 0:
return None
data = mx.zeros(size, dtype=mx.uint8)
data = mx.distributed.all_sum(data)
return pickle.loads(data)
def _share_request(self, request):
if not self._is_distributed:
@@ -651,10 +669,11 @@ class ResponseGenerator:
ts = tokenizer.tool_call_start_tokens
te = tokenizer.tool_call_end_tokens
transitions["normal"].append((ts, "tool"))
transitions["tool"] = [(te, "normal")]
transitions["tool"] = [(te, "normal")] if te else []
transitions["tool"].extend(common_stops)
sequences[ts] = tokenizer.tool_call_start
sequences[te] = tokenizer.tool_call_end
if te:
sequences[te] = tokenizer.tool_call_end
sm = SequenceStateMachine(transitions, initial=initial_state)
if len(self._state_machine_cache) > 100:
@@ -667,6 +686,14 @@ class ResponseGenerator:
return self.model_provider.is_batchable and args.seed is None
def _generate(self):
# Local thread stream that we 'll pass to the BatchGenerator to make
# sure that all generation runs in the same stream as the
# synchronization messages.
generation_stream = mx.default_stream(mx.default_device())
# Load the default model if it is given
self.model_provider.load_default()
current_model = None
current_sampling = None
current_tokenizer = None
@@ -796,6 +823,7 @@ class ResponseGenerator:
completion_batch_size=self.cli_args.decode_concurrency,
prefill_batch_size=self.cli_args.prompt_concurrency,
prefill_step_size=self.cli_args.prefill_step_size,
stream=generation_stream,
)
unprocessed_requests.append((rqueue, request, args))
continue
@@ -885,12 +913,11 @@ class ResponseGenerator:
uids_to_remove = self._share_object(uids_to_remove)
if uids_to_remove:
with mx.stream(generation_stream):
batch_generator.remove(uids_to_remove)
for uid in uids_to_remove:
# It may have already been removed during
# generation
batch_results.pop(uid, None)
batch_generator.remove(uids_to_remove)
for uid in uids_to_remove:
# It may have already been removed during
# generation
batch_results.pop(uid, None)
def _serve_single(self, request):
rqueue, request, args = request
@@ -1018,20 +1045,6 @@ class ResponseGenerator:
continue
yield response
def _process_control_tokens(ctx, token_stream):
buffer_size = max(len(s) for s in ctx.sequences)
buffered_stream = deque()
for tok in token_stream:
buffered_stream.append(tok)
if tok.match is not None:
for _ in tok.match:
buffered_stream.pop()
if len(buffered_stream) >= buffer_size:
yield buffered_stream.popleft()
while len(buffered_stream) > 0:
yield buffered_stream.popleft()
ctx = response_queue.get()
if isinstance(ctx, Exception):
raise ctx
+4
View File
@@ -397,6 +397,8 @@ class TokenizerWrapper:
@property
def think_start_id(self):
if self._think_start_tokens is None:
return None
if len(self._think_start_tokens) > 1:
raise ValueError("The start thinking sequence is more than 1 token")
return self._think_start_tokens[0]
@@ -411,6 +413,8 @@ class TokenizerWrapper:
@property
def think_end_id(self):
if self._think_end_tokens is None:
return None
if len(self._think_end_tokens) > 1:
raise ValueError("The end thinking sequence is more than 1 token")
return self._think_end_tokens[0]
+12 -3
View File
@@ -5,10 +5,19 @@ from typing import Any, Optional
import regex as re
# Matches <|"|>...<|"|> string literals (Gemma 4's string delimiter).
_GEMMA4_STR = r'<\|"\|>(?:(?!<\|"\|>)[\s\S])*?<\|"\|>'
# Matches call:name{...} with balanced braces via the regex module's
# recursive (?R)-style support. (\{(?:[^{}]|(?2))*\}) recurses on the
# second capture group so nested objects like {a:{b:1}} are captured whole.
_tool_call_regex = re.compile(r"call:(\w+)(\{(?:[^{}]|(?2))*\})", re.DOTALL)
# recursive (?R)-style support. The inner alternatives handle:
# [^{}<] any char that is not a brace or start of <|"|>
# <(?!\|"\|>) a lone '<' that is NOT the start of <|"|>
# <|"|>...<|"|> a complete string literal (braces inside are ignored)
# (?2) recursively balanced nested brace group
_tool_call_regex = re.compile(
r"call:([\w-]+)(\{(?:[^{}<]|<(?!\|\"\|>)|" + _GEMMA4_STR + r"|(?2))*\})",
re.DOTALL,
)
def _gemma4_args_to_json(text: str) -> str:
+31 -30
View File
@@ -157,43 +157,44 @@ def _get_param_types_from_config(param_name: str, param_config: dict) -> list[st
def parse_tool_call(text: str, tools: list | None = None):
invoke_match = _invoke_complete_regex.findall(text)
if not invoke_match:
invoke_matches = _invoke_complete_regex.findall(text)
if not invoke_matches:
raise ValueError("No tool call found")
invoke_text = invoke_match[0]
name_match = re.search(r"^([^>]+)", invoke_text)
if not name_match:
return None
function_name = _extract_name(name_match.group(1))
# Get parameter configuration
param_config = {}
param_config_for = {}
if tools:
for tool in tools:
if func := tool.get("function", False):
if func["name"] != function_name:
continue
if params := func.get("parameters", False):
param_config = params.get("properties", {})
param_config_for[func["name"]] = params.get("properties", {})
# Extract parameters
param_dict = {}
for match in _parameter_complete_regex.findall(invoke_text):
param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
if param_match:
param_name = _extract_name(param_match.group(1))
param_value = param_match.group(2).strip()
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
calls = []
for invoke_text in invoke_matches:
name_match = re.search(r"^([^>]+)", invoke_text)
if not name_match:
continue
function_name = _extract_name(name_match.group(1))
param_config = param_config_for.get(function_name, {})
param_type = _get_param_types_from_config(param_name, param_config)
param_dict = {}
for match in _parameter_complete_regex.findall(invoke_text):
param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
if param_match:
param_name = _extract_name(param_match.group(1))
param_value = param_match.group(2).strip()
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
param_dict[param_name] = _convert_param_value_with_types(
param_value, param_type
)
param_type = _get_param_types_from_config(param_name, param_config)
return dict(name=function_name, arguments=param_dict)
param_dict[param_name] = _convert_param_value_with_types(
param_value, param_type
)
calls.append(dict(name=function_name, arguments=param_dict))
if len(calls) == 1:
return calls[0]
return calls
+3 -1
View File
@@ -507,6 +507,8 @@ def sharded_load(
pipeline_group: Optional[mx.distributed.Group] = None,
tensor_group: Optional[mx.distributed.Group] = None,
return_config: bool = False,
*,
tokenizer_config: Optional[Dict[str, Any]] = None,
):
# Get model path with everything but weight safetensors
model_path = _download(
@@ -571,7 +573,7 @@ def sharded_load(
# Load and shard the model, and load the weights
tokenizer = load_tokenizer(
model_path,
{"trust_remote_code": True},
tokenizer_config or {"trust_remote_code": True},
eos_token_ids=config.get("eos_token_id", None),
)
model, _ = load_model(model_path, lazy=True, strict=False)
+1 -1
View File
@@ -10,7 +10,7 @@ sys.path.append(str(package_dir))
from _version import __version__
MIN_MLX_VERSION = "0.30.4"
MIN_MLX_VERSION = "0.31.2"
setup(
name="mlx-lm",
+3
View File
@@ -35,6 +35,9 @@ class TestConvertToGGUFWithoutMocks(unittest.TestCase):
mock_tokenizer.get_vocab.return_value = {"<pad>": 0, "hello": 1, "world": 2}
mock_tokenizer.all_special_tokens = ["<pad>"]
mock_tokenizer.all_special_ids = [0]
mock_tokenizer.bos_token_id = None
mock_tokenizer.eos_token_id = None
mock_tokenizer.unk_token_id = None
mock_from_pretrained.return_value = mock_tokenizer
model_path = Path(self.test_dir)
+73 -1
View File
@@ -5,7 +5,7 @@ import unittest
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_map
from mlx.utils import tree_flatten, tree_map
from mlx_lm.models import rope_utils
from mlx_lm.models.base import create_causal_mask, scaled_dot_product_attention
@@ -1547,6 +1547,78 @@ class TestModels(unittest.TestCase):
mx.allclose(logits, mx.ones((1, 1, 4), dtype=mx.float32) * 32.0)
)
def test_gemma4_kv_shared_layers_omit_kv_projections(self):
"""KV-shared layers must not create k_proj/v_proj/k_norm/v_norm so that
models saved without redundant weights (e.g. via transformers
save_pretrained) can be loaded with strict=True."""
from mlx_lm.models import gemma4_text
args = gemma4_text.ModelArgs(
model_type="gemma4_text",
hidden_size=128,
num_hidden_layers=10,
intermediate_size=256,
num_attention_heads=4,
head_dim=32,
global_head_dim=64,
rms_norm_eps=1e-6,
vocab_size=1000,
vocab_size_per_layer_input=1000,
num_key_value_heads=1,
num_kv_shared_layers=4,
hidden_size_per_layer_input=32,
sliding_window=8,
sliding_window_pattern=5,
final_logit_softcapping=30.0,
layer_types=[
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
],
rope_parameters={
"full_attention": {
"partial_rotary_factor": 0.25,
"rope_theta": 1000000.0,
},
"sliding_attention": {
"rope_theta": 10000.0,
},
},
)
model = gemma4_text.Model(args)
# Non-shared layers (0-5) should have KV projections
for i in range(6):
attn = model.model.layers[i].self_attn
self.assertTrue(attn.has_kv)
self.assertTrue(hasattr(attn, "k_proj"))
self.assertTrue(hasattr(attn, "k_norm"))
# Shared layers (6-9) should NOT have KV projections
for i in range(6, 10):
attn = model.model.layers[i].self_attn
self.assertFalse(attn.has_kv)
self.assertFalse(hasattr(attn, "k_proj"))
self.assertFalse(hasattr(attn, "k_norm"))
self.assertFalse(hasattr(attn, "v_proj"))
# Verify the model can load weights that omit shared-layer KV params
weights = dict(tree_flatten(model.parameters()))
kv_keys = [
k for k in weights if "k_proj" in k or "v_proj" in k or "k_norm" in k
]
for k in kv_keys:
# All KV keys should belong to non-shared layers (0-5)
layer_idx = int(k.split("layers.")[1].split(".")[0])
self.assertLess(layer_idx, 6)
def test_gemma4_input_embeddings_reconstruct_per_layer_inputs(self):
from mlx_lm.models import gemma4_text
+96
View File
@@ -662,6 +662,102 @@ class TestPromptCache(unittest.TestCase):
c_out = KVCache.merge((c1, c2))
self.assertEqual(c_out.keys.shape, (2, 4, 4, 4))
def test_extend_with_empty_and_nonempty_batch_caches(self):
"""Extending a batch cache when one side has keys=None should use the
correct batch size for the placeholder, not the batch size from the
non-None side. Regression test for broadcast error in dynamic_roll."""
H, D = 8, 64
max_size = 512
# -- BatchRotatingKVCache --
# Create 2 caches with content and 3 empty caches
c1 = RotatingKVCache(max_size=max_size)
c2 = RotatingKVCache(max_size=max_size)
c1.update_and_fetch(mx.ones((1, H, 5, D)), mx.ones((1, H, 5, D)))
c2.update_and_fetch(mx.ones((1, H, 3, D)), mx.ones((1, H, 3, D)))
batch_full = BatchRotatingKVCache.merge([c1, c2])
empty_caches = [RotatingKVCache(max_size=max_size) for _ in range(3)]
batch_empty = BatchRotatingKVCache.merge(empty_caches)
# Extend non-empty with empty (different batch sizes)
batch_full.extend(batch_empty)
self.assertEqual(batch_full.keys.shape[0], 5)
self.assertEqual(batch_full.offset.shape[0], 5)
# Prompt processing with right padding should not crash
batch_full.prepare(lengths=[10, 8, 12, 7, 11], right_padding=[2, 4, 0, 5, 1])
new_kv = mx.ones((5, H, 12, D))
batch_full.update_and_fetch(new_kv, new_kv)
# Also test empty extending non-empty
batch_full2 = BatchRotatingKVCache.merge(
[RotatingKVCache(max_size=max_size) for _ in range(3)]
)
c3 = RotatingKVCache(max_size=max_size)
c4 = RotatingKVCache(max_size=max_size)
c3.update_and_fetch(mx.ones((1, H, 4, D)), mx.ones((1, H, 4, D)))
c4.update_and_fetch(mx.ones((1, H, 6, D)), mx.ones((1, H, 6, D)))
batch_content = BatchRotatingKVCache.merge([c3, c4])
batch_full2.extend(batch_content)
self.assertEqual(batch_full2.keys.shape[0], 5)
self.assertEqual(batch_full2.offset.shape[0], 5)
# -- BatchKVCache --
c1 = KVCache()
c2 = KVCache()
c1.update_and_fetch(mx.ones((1, H, 5, D)), mx.ones((1, H, 5, D)))
c2.update_and_fetch(mx.ones((1, H, 3, D)), mx.ones((1, H, 3, D)))
batch_full = BatchKVCache.merge([c1, c2])
empty_caches = [KVCache() for _ in range(3)]
batch_empty = BatchKVCache.merge(empty_caches)
batch_full.extend(batch_empty)
self.assertEqual(batch_full.keys.shape[0], 5)
self.assertEqual(batch_full.offset.shape[0], 5)
def test_arrays_cache_extend_with_empty(self):
# test simple merge
c1 = ArraysCache(2)
c2 = ArraysCache(2)
c1[0] = mx.zeros((1, 4, 8))
c1[1] = mx.zeros((1, 4))
c2[0] = mx.zeros((1, 4, 8))
c2[1] = mx.zeros((1, 4))
full = ArraysCache.merge((c1, c2))
self.assertEqual(full[0].shape, (2, 4, 8))
# extend with empty
empty = ArraysCache.merge((ArraysCache(2),))
full.extend(empty)
self.assertEqual(full[0].shape, (3, 4, 8))
self.assertEqual(full[1].shape, (3, 4))
self.assertTrue(mx.all(full[0][2:] == 0))
# making an empty cache with 2 sequences and merging it with
# another one with 2 sequences
empty2 = ArraysCache.merge((ArraysCache(2), ArraysCache(2)))
content = ArraysCache.merge((c1, c2))
empty2.extend(content)
self.assertEqual(empty2[0].shape, (4, 4, 8))
self.assertEqual(empty2[1].shape, (4, 4))
# Extend content with empty
content = ArraysCache.merge((c1, c2))
empty2 = ArraysCache.merge((ArraysCache(2), ArraysCache(2)))
content.extend(empty2)
self.assertEqual(content[0].shape, (4, 4, 8))
self.assertEqual(content[1].shape, (4, 4))
self.assertEqual(content.make_mask(10).shape, (4, 10))
# multiple empty extensions accumulate correctly
stepwise = ArraysCache.merge((c1,))
stepwise.extend(ArraysCache(2))
stepwise.extend(ArraysCache.merge((ArraysCache(2), ArraysCache(2))))
self.assertEqual(stepwise[0].shape, (4, 4, 8))
self.assertEqual(stepwise[1].shape, (4, 4))
def test_window_mask_with_full_kv_cache(self):
c = KVCache()
kv = mx.zeros((1, 1, 32, 128))
+103 -1
View File
@@ -4,13 +4,20 @@ import http
import io
import json
import threading
import types
import unittest
import mlx.core as mx
import requests
from mlx_lm.models.cache import KVCache
from mlx_lm.server import APIHandler, LRUPromptCache, ResponseGenerator
from mlx_lm.server import (
APIHandler,
LRUPromptCache,
Response,
ResponseGenerator,
_process_control_tokens,
)
from mlx_lm.utils import load
@@ -61,6 +68,9 @@ class DummyModelProvider:
assert model in ["default_model", "chat_model"]
return self.model, self.tokenizer
def load_default(self):
return self.load("default_model", None, "default_model")
class MockCache:
def __init__(self, value, is_trimmable: bool = True):
@@ -82,6 +92,71 @@ class MockCache:
return n
class TestProcessControlTokens(unittest.TestCase):
@staticmethod
def _r(text, state, match=None):
return Response(text, 0, state, match, 0.0, None, ())
def test_single_tool_call_passes_body_with_open_and_close_crossings(self):
r = self._r
stream = [
r("hi ", "normal"),
r("<tool_call>", "tool", match=(0,)),
r("body", "tool"),
r("</tool_call>", "normal", match=(1,)),
r(" bye", "normal"),
]
ctx = types.SimpleNamespace(
sequences={(0,): "<tool_call>", (1,): "</tool_call>"}
)
out = list(_process_control_tokens(ctx, iter(stream)))
self.assertEqual("".join(t.text for t in out), "hi body bye")
states = [t.state for t in out]
self.assertEqual(sum(1 for a, b in zip(states, states[1:]) if a != b), 2)
def test_back_to_back_tool_calls_emit_state_crossings(self):
r = self._r
stream = [
r("<tool_call>", "tool", match=(0,)),
r("call1_body", "tool"),
r("</tool_call>", "normal", match=(1,)),
r("<tool_call>", "tool", match=(0,)),
r("call2_body", "tool"),
r("</tool_call>", "normal", match=(1,)),
]
ctx = types.SimpleNamespace(
sequences={(0,): "<tool_call>", (1,): "</tool_call>"}
)
out = list(_process_control_tokens(ctx, iter(stream)))
self.assertEqual("".join(t.text for t in out), "call1_bodycall2_body")
states = [t.state for t in out]
crossings = sum(
1 for a, b in zip(states, states[1:]) if a == "tool" and b == "normal"
)
self.assertEqual(crossings, 2)
def test_multi_token_match_preserves_order(self):
r = self._r
match = (10, 11, 12)
stream = [
r("body", "tool"),
r("</", "tool"),
r("tool", "tool"),
r("_call>", "normal", match=match),
r(" ok", "normal"),
]
ctx = types.SimpleNamespace(sequences={match: "</tool_call>"})
out = list(_process_control_tokens(ctx, iter(stream)))
self.assertEqual([t.text for t in out], ["body", "", "", "", " ok"])
self.assertEqual(
[t.state for t in out],
["tool", "tool", "tool", "normal", "normal"],
)
class TestServer(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -205,6 +280,33 @@ class TestServer(unittest.TestCase):
self.assertIn("id", response_body)
self.assertIn("choices", response_body)
def test_make_state_machine_empty_tool_call_end(self):
class FakeTokenizer:
has_thinking = False
has_tool_calling = True
tool_call_start = "[TOOL_CALLS]"
tool_call_end = ""
tool_call_start_tokens = (100,)
tool_call_end_tokens = ()
eos_token_ids = [2]
def convert_ids_to_tokens(self, t):
return f"<eos{t}>"
sm, _ = self.response_generator._make_state_machine(
("fake-empty-end", None, None),
FakeTokenizer(),
stop_words=[],
)
state = sm.make_state()
state, _, s = sm.match(state, 100)
self.assertEqual(s, "tool")
for tok in [42, 43, 44]:
state, _, s = sm.match(state, tok)
self.assertEqual(s, "tool")
state, _, s = sm.match(state, 2)
self.assertIsNone(s)
def test_handle_models(self):
url = f"http://localhost:{self.port}/v1/models"
response = requests.get(url)
+8
View File
@@ -101,6 +101,14 @@ class TestTokenizers(unittest.TestCase):
self.assertEqual(tokenizer.think_start, "<think>")
self.assertEqual(tokenizer.think_end, "</think>")
tokenizer_repo = "mlx-community/Llama-3.2-1B-Instruct-4bit"
tokenizer = load_tokenizer(tokenizer_repo)
self.assertFalse(tokenizer.has_thinking)
self.assertIsNone(tokenizer.think_start)
self.assertIsNone(tokenizer.think_end)
self.assertIsNone(tokenizer.think_start_id)
self.assertIsNone(tokenizer.think_end_id)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -254,6 +254,27 @@ class TestToolParsing(unittest.TestCase):
{"settings": {"enabled": True, "name": "test"}},
)
# Hyphenated function name (e.g. manim-video)
test_case = (
'call:manim-video{mode:<|"|>plan<|"|>,prompt:<|"|>explain KV caching<|"|>}'
)
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "manim-video")
self.assertEqual(
tool_call["arguments"],
{"mode": "plan", "prompt": "explain KV caching"},
)
# Braces inside a string argument (e.g. code snippets or markdown in content)
test_case = (
'call:skill_manage{action:<|"|>create<|"|>,'
'content:<|"|>use a dict like {key: value} in your code<|"|>}'
)
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "skill_manage")
self.assertEqual(tool_call["arguments"]["action"], "create")
self.assertIn("{", tool_call["arguments"]["content"])
def test_kimi_k2(self):
# Single tool call
test_case = (
@@ -292,6 +313,22 @@ class TestToolParsing(unittest.TestCase):
]
self.assertEqual(tool_calls, expected)
def test_minimax_m2(self):
test_case = (
'<invoke name="search">\n'
'<parameter name="query">weather</parameter>\n'
"</invoke>\n"
'<invoke name="read_file">\n'
'<parameter name="path">/tmp/test.txt</parameter>\n'
"</invoke>"
)
expected = [
{"name": "search", "arguments": {"query": "weather"}},
{"name": "read_file", "arguments": {"path": "/tmp/test.txt"}},
]
tool_calls = minimax_m2.parse_tool_call(test_case, None)
self.assertEqual(expected, tool_calls)
if __name__ == "__main__":
unittest.main()