Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95238e5d34 | |||
| ed1fca4cef | |||
| 4f5cbd2a4f | |||
| 3cd9a52df2 | |||
| 2f1ab85aec | |||
| f3bb10c141 | |||
| e1c24b3237 | |||
| f39cb8e934 | |||
| a9856b485d | |||
| e92138cb01 | |||
| a401730941 | |||
| 6d114686e5 | |||
| aa4f880fb3 | |||
| 62f38aeb51 | |||
| d9c63fff67 |
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Copyright © 2023-2025 Apple Inc.
|
||||
|
||||
__version__ = "0.31.2"
|
||||
__version__ = "0.31.3"
|
||||
|
||||
+12
-4
@@ -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:
|
||||
|
||||
@@ -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
@@ -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, :]
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Optional
|
||||
|
||||
import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
from mlx.utils import tree_flatten, tree_unflatten
|
||||
|
||||
from . import gemma4_text
|
||||
from .base import BaseModelArgs
|
||||
@@ -90,3 +89,6 @@ class Model(nn.Module):
|
||||
|
||||
def make_cache(self):
|
||||
return self.language_model.make_cache()
|
||||
|
||||
def shard(self, group: Optional[mx.distributed.Group] = None):
|
||||
self.language_model.shard(group)
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
from mlx.nn.layers.distributed import shard_inplace, shard_linear, sum_gradients
|
||||
|
||||
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
|
||||
from .cache import KVCache, RotatingKVCache, _BaseCache
|
||||
@@ -164,13 +165,23 @@ class Experts(nn.Module):
|
||||
bias=False,
|
||||
)
|
||||
|
||||
self.sharding_group = None
|
||||
|
||||
def __call__(
|
||||
self, x: mx.array, top_k_indices: mx.array, top_k_weights: mx.array
|
||||
) -> mx.array:
|
||||
if self.sharding_group is not None:
|
||||
x = sum_gradients(self.sharding_group)(x)
|
||||
|
||||
w = mx.expand_dims(top_k_weights, -1)
|
||||
y = self.switch_glu(x, top_k_indices)
|
||||
|
||||
return (w * y).sum(-2)
|
||||
y = (w * y).sum(-2)
|
||||
|
||||
if self.sharding_group is not None:
|
||||
y = mx.distributed.all_sum(y, group=self.sharding_group)
|
||||
|
||||
return y
|
||||
|
||||
|
||||
class Attention(nn.Module):
|
||||
@@ -180,6 +191,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 +214,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 +254,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
|
||||
@@ -664,3 +684,45 @@ class Model(nn.Module):
|
||||
)
|
||||
)
|
||||
return caches
|
||||
|
||||
def shard(self, group: Optional[mx.distributed.Group] = None):
|
||||
group = group or mx.distributed.init()
|
||||
N = group.size()
|
||||
for layer in self.model.layers:
|
||||
layer.self_attn.q_proj = shard_linear(
|
||||
layer.self_attn.q_proj, "all-to-sharded", group=group
|
||||
)
|
||||
layer.self_attn.k_proj = shard_linear(
|
||||
layer.self_attn.k_proj, "all-to-sharded", group=group
|
||||
)
|
||||
if hasattr(layer.self_attn, "v_proj"):
|
||||
layer.self_attn.v_proj = shard_linear(
|
||||
layer.self_attn.v_proj, "all-to-sharded", group=group
|
||||
)
|
||||
layer.self_attn.o_proj = shard_linear(
|
||||
layer.self_attn.o_proj, "sharded-to-all", group=group
|
||||
)
|
||||
layer.self_attn.n_heads //= N
|
||||
layer.self_attn.n_kv_heads //= N
|
||||
|
||||
layer.mlp.gate_proj = shard_linear(
|
||||
layer.mlp.gate_proj, "all-to-sharded", group=group
|
||||
)
|
||||
layer.mlp.down_proj = shard_linear(
|
||||
layer.mlp.down_proj, "sharded-to-all", group=group
|
||||
)
|
||||
layer.mlp.up_proj = shard_linear(
|
||||
layer.mlp.up_proj, "all-to-sharded", group=group
|
||||
)
|
||||
|
||||
if layer.enable_moe:
|
||||
layer.experts.sharding_group = group
|
||||
shard_inplace(
|
||||
layer.experts.switch_glu.gate_proj, "all-to-sharded", group=group
|
||||
)
|
||||
shard_inplace(
|
||||
layer.experts.switch_glu.down_proj, "sharded-to-all", group=group
|
||||
)
|
||||
shard_inplace(
|
||||
layer.experts.switch_glu.up_proj, "all-to-sharded", group=group
|
||||
)
|
||||
|
||||
+5
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user