450 lines
14 KiB
Python
450 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
Tokenizer utilities for oMLX.
|
|
|
|
This module provides shared tokenizer configuration and fixes that are used
|
|
across multiple modules in the codebase.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from collections.abc import Callable
|
|
from functools import lru_cache, partial
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def unwrap_tokenizer(tokenizer):
|
|
"""Unwrap mlx-lm TokenizerWrapper to a HuggingFace PreTrainedTokenizer.
|
|
|
|
xgrammar accepts HuggingFace ``PreTrainedTokenizer`` /
|
|
``PreTrainedTokenizerFast`` but NOT the raw ``tokenizers.Tokenizer``
|
|
nor the mlx-lm ``TokenizerWrapper``. This helper peels exactly one
|
|
layer of mlx-lm wrapping while keeping the HuggingFace object intact.
|
|
"""
|
|
try:
|
|
from transformers import PreTrainedTokenizerBase
|
|
|
|
if isinstance(tokenizer, PreTrainedTokenizerBase):
|
|
return tokenizer
|
|
except ImportError:
|
|
pass
|
|
if hasattr(tokenizer, "_tokenizer"):
|
|
inner = tokenizer._tokenizer
|
|
try:
|
|
from transformers import PreTrainedTokenizerBase
|
|
|
|
if isinstance(inner, PreTrainedTokenizerBase):
|
|
return inner
|
|
except ImportError:
|
|
pass
|
|
return inner
|
|
return tokenizer
|
|
|
|
|
|
def resolve_vocab_size(model: Any) -> int | None:
|
|
"""Extract vocab_size from a model's config/args, handling nested configs.
|
|
|
|
Tries ``model.config.vocab_size``, then ``model.args.vocab_size``,
|
|
then ``text_config.vocab_size`` for VLM composite models (e.g. Qwen3.5).
|
|
|
|
Args:
|
|
model: An MLX model object (LLM, VLM, or any object with config/args).
|
|
|
|
Returns:
|
|
The vocabulary size, or None if it cannot be determined.
|
|
"""
|
|
if model is None:
|
|
return None
|
|
for attr in ("config", "args"):
|
|
config = getattr(model, attr, None)
|
|
if config is None:
|
|
continue
|
|
vs = getattr(config, "vocab_size", None)
|
|
if isinstance(vs, int):
|
|
return vs
|
|
text_cfg = getattr(config, "text_config", None)
|
|
if isinstance(text_cfg, dict):
|
|
vs = text_cfg.get("vocab_size")
|
|
elif text_cfg is not None:
|
|
vs = getattr(text_cfg, "vocab_size", None)
|
|
if isinstance(vs, int):
|
|
return vs
|
|
return None
|
|
|
|
|
|
def is_harmony_model(model_name: str, config: dict[str, Any] | None = None) -> bool:
|
|
"""
|
|
Check if the model uses Harmony format.
|
|
|
|
Harmony format is used by gpt-oss models with special tokens like
|
|
<|start|>, <|channel|>, <|message|>, <|end|>, <|return|>, <|call|>.
|
|
|
|
Detection priority:
|
|
1. model_type == "gpt_oss" in config.json
|
|
2. Fallback: model_name contains "gpt-oss" or "gptoss" (case-insensitive)
|
|
|
|
Args:
|
|
model_name: The model name or path.
|
|
config: Optional model config dict (from config.json).
|
|
|
|
Returns:
|
|
True if the model uses Harmony format.
|
|
"""
|
|
# Primary detection: config.model_type
|
|
if config is not None:
|
|
model_type = config.get("model_type", "")
|
|
if model_type == "gpt_oss":
|
|
logger.debug(f"Harmony model detected via config.model_type: {model_name}")
|
|
return True
|
|
|
|
# Fallback detection: model name pattern
|
|
if model_name:
|
|
name_lower = model_name.lower()
|
|
if "gpt-oss" in name_lower or "gptoss" in name_lower:
|
|
logger.debug(f"Harmony model detected via model name pattern: {model_name}")
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def is_gemma4_model(model_name: str, config: dict[str, Any] | None = None) -> bool:
|
|
"""
|
|
Check if the model is a Gemma 4 model.
|
|
|
|
Detection priority:
|
|
1. Gemma 4 model_type in config.json
|
|
2. Fallback: model_name contains "gemma-4" or "gemma4" (case-insensitive)
|
|
"""
|
|
if config is not None:
|
|
model_type = config.get("model_type", "")
|
|
# diffusion_gemma shares Gemma 4's wire protocol (channel markers,
|
|
# call:name{...} tool calls), so it uses the same parser/extractor.
|
|
if model_type in {"gemma4", "gemma4_unified", "diffusion_gemma"}:
|
|
logger.debug(f"Gemma 4 model detected via config.model_type: {model_name}")
|
|
return True
|
|
|
|
if model_name:
|
|
name_lower = model_name.lower()
|
|
if "gemma-4" in name_lower or "gemma4" in name_lower:
|
|
logger.debug(f"Gemma 4 model detected via model name pattern: {model_name}")
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def is_qwen3_model(model_name: str) -> bool:
|
|
"""
|
|
Check if the model is a Qwen3 model.
|
|
|
|
Args:
|
|
model_name: The model name or path.
|
|
|
|
Returns:
|
|
True if the model is a Qwen3 model.
|
|
"""
|
|
model_lower = model_name.lower()
|
|
return "qwen3" in model_lower or "Qwen3" in model_name
|
|
|
|
|
|
def _read_json_file(path: Path) -> dict[str, Any] | None:
|
|
try:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
logger.debug("Failed to read %s: %s", path, exc)
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _find_tokenizer_json(
|
|
tokenizer: Any,
|
|
model_path: str | Path | None = None,
|
|
) -> Path | None:
|
|
candidates: list[str | Path] = []
|
|
if model_path:
|
|
candidates.append(model_path)
|
|
|
|
tokenizer_path = getattr(tokenizer, "name_or_path", None)
|
|
if tokenizer_path:
|
|
candidates.append(tokenizer_path)
|
|
|
|
for candidate in candidates:
|
|
candidate_path = Path(candidate).expanduser()
|
|
tokenizer_file = candidate_path / "tokenizer.json"
|
|
if tokenizer_file.exists():
|
|
return tokenizer_file
|
|
|
|
try:
|
|
from huggingface_hub import try_to_load_from_cache
|
|
|
|
cached = try_to_load_from_cache(str(candidate), "tokenizer.json")
|
|
except Exception:
|
|
cached = None
|
|
|
|
if cached and isinstance(cached, str):
|
|
cached_path = Path(cached)
|
|
if cached_path.exists():
|
|
return cached_path
|
|
|
|
return None
|
|
|
|
|
|
@lru_cache(maxsize=128)
|
|
def _detokenizer_factory_from_tokenizer_json(
|
|
tokenizer_file: str,
|
|
) -> Callable[[Any], Any] | None:
|
|
tokenizer_content = _read_json_file(Path(tokenizer_file))
|
|
if not tokenizer_content or "decoder" not in tokenizer_content:
|
|
return None
|
|
|
|
try:
|
|
from mlx_lm.tokenizer_utils import (
|
|
BPEStreamingDetokenizer,
|
|
SPMStreamingDetokenizer,
|
|
_is_bpe_decoder,
|
|
_is_spm_decoder,
|
|
_is_spm_decoder_no_space,
|
|
)
|
|
except ImportError:
|
|
return None
|
|
|
|
decoder = tokenizer_content["decoder"]
|
|
if _is_spm_decoder(decoder):
|
|
return SPMStreamingDetokenizer
|
|
if _is_spm_decoder_no_space(decoder):
|
|
return partial(SPMStreamingDetokenizer, trim_space=False)
|
|
if _is_bpe_decoder(decoder):
|
|
return BPEStreamingDetokenizer
|
|
return None
|
|
|
|
|
|
def _is_unsafe_mlx_vlm_bpe_detokenizer(detokenizer: Any) -> bool:
|
|
detokenizer_type = type(detokenizer)
|
|
return (
|
|
detokenizer_type.__module__ == "mlx_vlm.tokenizer_utils"
|
|
and detokenizer_type.__name__ == "BPEStreamingDetokenizer"
|
|
)
|
|
|
|
|
|
def _create_decoder_aware_detokenizer(
|
|
tokenizer: Any,
|
|
tokenizer_file: Path | None,
|
|
) -> Any | None:
|
|
if tokenizer_file is None:
|
|
return None
|
|
|
|
factory = _detokenizer_factory_from_tokenizer_json(str(tokenizer_file))
|
|
if factory is None:
|
|
return None
|
|
|
|
try:
|
|
return factory(tokenizer)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"Failed to create decoder-aware detokenizer from %s: %s",
|
|
tokenizer_file,
|
|
exc,
|
|
)
|
|
return None
|
|
|
|
|
|
class _CompatNaiveStreamingDetokenizer:
|
|
"""Naive fallback for raw tokenizers that lack mlx-lm's probe APIs."""
|
|
|
|
def __init__(self, tokenizer: Any):
|
|
self._tokenizer = tokenizer
|
|
self._tokenizer.decode([0])
|
|
self.reset()
|
|
|
|
def reset(self) -> None:
|
|
self.offset = 0
|
|
self.tokens = []
|
|
self._text = ""
|
|
self._current_tokens = []
|
|
self._current_text = ""
|
|
|
|
def add_token(self, token: int) -> None:
|
|
self._current_tokens.append(token)
|
|
self.tokens.append(token)
|
|
|
|
def finalize(self) -> None:
|
|
self._text += self._tokenizer.decode(self._current_tokens)
|
|
self._current_tokens = []
|
|
self._current_text = ""
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
if self._current_tokens:
|
|
self._current_text = self._tokenizer.decode(self._current_tokens)
|
|
if self._current_text.endswith("\ufffd") or (
|
|
bool(getattr(self._tokenizer, "clean_up_tokenization_spaces", False))
|
|
and len(self._current_text) > 0
|
|
and self._current_text[-1] == " "
|
|
):
|
|
self._current_text = self._current_text[:-1]
|
|
if self._current_text and self._current_text[-1] == "\n":
|
|
self._text += self._current_text
|
|
self._current_tokens.clear()
|
|
self._current_text = ""
|
|
return self._text + self._current_text
|
|
|
|
@property
|
|
def last_segment(self) -> str:
|
|
text = self.text
|
|
segment = text[self.offset :]
|
|
self.offset = len(text)
|
|
return segment
|
|
|
|
|
|
def create_streaming_detokenizer(
|
|
tokenizer: Any,
|
|
model_path: str | Path | None = None,
|
|
) -> Any | None:
|
|
"""Create a fresh streaming detokenizer for one request.
|
|
|
|
mlx-lm's TokenizerWrapper exposes the correct per-model detokenizer, but
|
|
raw VLM/DFlash tokenizers may not. In that case, mirror mlx-lm's
|
|
tokenizer.json decoder detection before falling back to the naive decoder.
|
|
"""
|
|
has_existing_attr = True
|
|
try:
|
|
detokenizer = tokenizer.detokenizer
|
|
except AttributeError:
|
|
has_existing_attr = False
|
|
detokenizer = None
|
|
except Exception as exc:
|
|
has_existing_attr = False
|
|
detokenizer = None
|
|
logger.debug("Failed to read tokenizer.detokenizer: %s", exc)
|
|
|
|
if detokenizer is not None:
|
|
if _is_unsafe_mlx_vlm_bpe_detokenizer(detokenizer):
|
|
tokenizer_file = _find_tokenizer_json(tokenizer, model_path)
|
|
decoder_aware_detokenizer = _create_decoder_aware_detokenizer(
|
|
tokenizer,
|
|
tokenizer_file,
|
|
)
|
|
if decoder_aware_detokenizer is not None:
|
|
return decoder_aware_detokenizer
|
|
logger.debug(
|
|
"Using existing mlx-vlm BPE detokenizer because no "
|
|
"decoder-aware replacement is available"
|
|
)
|
|
return detokenizer
|
|
|
|
tokenizer_file = _find_tokenizer_json(tokenizer, model_path)
|
|
decoder_aware_detokenizer = _create_decoder_aware_detokenizer(
|
|
tokenizer,
|
|
tokenizer_file,
|
|
)
|
|
if decoder_aware_detokenizer is not None:
|
|
return decoder_aware_detokenizer
|
|
|
|
if has_existing_attr:
|
|
return None
|
|
|
|
try:
|
|
from mlx_lm.tokenizer_utils import NaiveStreamingDetokenizer
|
|
except ImportError:
|
|
return None
|
|
|
|
try:
|
|
return NaiveStreamingDetokenizer(tokenizer)
|
|
except Exception as exc:
|
|
logger.debug("Failed to create naive streaming detokenizer: %s", exc)
|
|
|
|
try:
|
|
return _CompatNaiveStreamingDetokenizer(tokenizer)
|
|
except Exception as compat_exc:
|
|
logger.debug(
|
|
"Failed to create compatibility naive streaming detokenizer: %s",
|
|
compat_exc,
|
|
)
|
|
return None
|
|
|
|
|
|
def _is_lfm2_text_lm(model_name: str) -> bool:
|
|
"""Return True for local LFM2 text causal LM checkpoints."""
|
|
config_path = Path(model_name) / "config.json"
|
|
config = _read_json_file(config_path)
|
|
if config is None:
|
|
return False
|
|
|
|
model_type = str(config.get("model_type") or "").lower().replace("-", "_")
|
|
architectures = [
|
|
str(arch) for arch in config.get("architectures", []) if isinstance(arch, str)
|
|
]
|
|
architectures_lower = [arch.lower() for arch in architectures]
|
|
|
|
if model_type in {"lfm_audio", "lfm2_audio"}:
|
|
return False
|
|
if any(key in config for key in ("audio_config", "tts_config", "stt_config")):
|
|
return False
|
|
if any("audio" in arch for arch in architectures_lower):
|
|
return False
|
|
if not any("forcausallm" in arch for arch in architectures_lower):
|
|
return False
|
|
|
|
return model_type.startswith("lfm2") or any(
|
|
arch.lower().startswith("lfm2") for arch in architectures
|
|
)
|
|
|
|
|
|
def get_tokenizer_config(
|
|
model_name: str,
|
|
trust_remote_code: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Get tokenizer configuration with model-specific fixes.
|
|
|
|
This function centralizes tokenizer configuration to ensure consistent
|
|
behavior across different modules.
|
|
|
|
Args:
|
|
model_name: The model name or path.
|
|
trust_remote_code: Whether to trust remote code.
|
|
|
|
Returns:
|
|
Dictionary of tokenizer configuration options.
|
|
"""
|
|
config: dict[str, Any] = {"trust_remote_code": trust_remote_code}
|
|
|
|
# Apply Qwen3 fix if needed
|
|
if is_qwen3_model(model_name):
|
|
config["eos_token"] = "<|im_end|>"
|
|
logger.debug("Qwen3 detected: setting eos_token to <|im_end|>")
|
|
|
|
if _is_lfm2_text_lm(model_name):
|
|
config.setdefault("tool_parser_type", "pythonic")
|
|
logger.debug("LFM2 text LM detected: setting tool_parser_type to pythonic")
|
|
|
|
return config
|
|
|
|
|
|
def apply_qwen3_fix(
|
|
tokenizer_config: dict[str, Any],
|
|
model_name: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Apply Qwen3 tokenizer fix to an existing config.
|
|
|
|
Qwen3 has a known issue where eos_token changed from <|im_end|> to
|
|
<|endoftext|>, but the chat template still uses <|im_end|>. This
|
|
function applies the fix if needed.
|
|
|
|
Args:
|
|
tokenizer_config: Existing tokenizer configuration dict.
|
|
model_name: The model name or path.
|
|
|
|
Returns:
|
|
Updated tokenizer configuration with Qwen3 fix applied if needed.
|
|
"""
|
|
if is_qwen3_model(model_name):
|
|
tokenizer_config["eos_token"] = "<|im_end|>"
|
|
logger.debug("Qwen3 detected: setting eos_token to <|im_end|>")
|
|
|
|
return tokenizer_config
|