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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions docs/development-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ symlink redirection. A `status` check then verifies the venv and imports the
required MLX APIs without loading weights. A working installation skips
provisioning entirely: no Python replacement, pip upgrade, or package upgrade.

If the venv is missing, Task creates it with native Python 3.10+ and `venv`.
If the venv is missing, Task creates it with native Python 3.11+ and `venv`
(the minimum required by `mlx-lm` 0.32.2).
If suitable Python is missing, it uses the same Homebrew `python@3` formula
as the repository's macOS package setup. Missing pip is bootstrapped with
`ensurepip`; missing requirements are installed with isolated
Expand Down Expand Up @@ -103,6 +104,14 @@ which supplies an empty closed `<think>` block. The launcher uses temperature
0.7, top-p 0.8, top-k 20, at most 512 reply tokens, and 512-token prefill chunks.
It does not use the unsupported `mlx_lm.chat` flags or a `/nothink` soft switch.

A cached snapshot is reused without network access when its configuration,
tokenizer, chat template, weight index and every indexed shard are present.
Missing files trigger one resumable download into the same external cache,
at the cached revision when its directory was returned by the Hub. Already
cached files are preserved. If the snapshot remains incomplete (for
example while offline), the launcher reports an error rather than loading it.
Malformed metadata and model-loading errors are not treated as cache misses.

Type `q` to quit, `r` to reset, or `h` for help. Ctrl+C (including during
generation) and EOF exit cleanly. Each reply reports token speed and peak MLX
memory; conversation state stays in memory, with no saved transcripts.
Expand Down Expand Up @@ -154,9 +163,16 @@ isolated Chezmoi destination. It covers installed/missing/broken environments
and setup ordering without real provisioning, downloads, or inference:

```sh
timeout 60 ./tests/bash/run-tests.sh --test global-mlx-chat.bats
python3 tests/with-timeout.py 60 ./tests/bash/run-tests.sh --test global-mlx-chat.bats
```

The Python standard-library wrapper works on macOS and Linux without GNU
`timeout`. It preserves the command's exit status (128 + signal for a signal
exit), kills the entire test process group on timeout, and returns 124.
Ctrl+C also cleans up the process group and returns 130. Diagnostics go to
stderr after cleanup; closed output pipes do not change either exit status.
Use 300 instead of 60 for the full `--ci` suite.

## Mise (Tool Version Manager)

Mise manages tool versions defined in
Expand Down
6 changes: 3 additions & 3 deletions home/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ vars:
MLX_VENV: '{{.ROOT_DIR}}/mlx-qwen/.venv'
MLX_VOLUME: /Volumes/External Disk Data
MLX_CACHE_DIR: '{{.MLX_VOLUME}}/AI/huggingface'
MLX_PYTHON_CHECK: 'import platform, sys, venv; assert sys.version_info >= (3, 10) and platform.machine() == "arm64"'
MLX_PYTHON_CHECK: 'import platform, sys, venv; assert sys.version_info >= (3, 11) and platform.machine() == "arm64"'
MLX_VENV_CHECK: '{{.MLX_PYTHON_CHECK}}; assert sys.prefix != sys.base_prefix'
MLX_CHECK_ENV: >-
"{{.MLX_VENV}}/bin/python" "{{.ROOT_DIR}}/.config/mlx/chat.py"
Expand Down Expand Up @@ -71,7 +71,7 @@ tasks:
- sh: >-
(test ! -e "{{.MLX_VENV}}" && test ! -L "{{.MLX_VENV}}") ||
"{{.MLX_VENV}}/bin/python" -c '{{.MLX_VENV_CHECK}}'
msg: 'Existing {{.MLX_VENV}} is not a usable Python 3.10+ arm64 venv. Repair it or move it aside; it will not be deleted automatically.'
msg: 'Existing {{.MLX_VENV}} is not a usable Python 3.11+ arm64 venv. Repair it or move it aside; it will not be deleted automatically.'
status:
- '"{{.MLX_VENV}}/bin/python" -c ''{{.MLX_VENV_CHECK}}'''
cmds:
Expand All @@ -88,7 +88,7 @@ tasks:
internal: true
preconditions:
- sh: python3 -c '{{.MLX_PYTHON_CHECK}}' 2>/dev/null || command -v brew
msg: 'A native Python 3.10+ or Homebrew is required. Install Homebrew using the existing dotfiles setup; this task cannot safely bootstrap Homebrew.'
msg: 'A native Python 3.11+ or Homebrew is required. Install Homebrew using the existing dotfiles setup; this task cannot safely bootstrap Homebrew.'
status:
- |
python3 -c '{{.MLX_PYTHON_CHECK}}' 2>/dev/null ||
Expand Down
39 changes: 35 additions & 4 deletions home/dot_config/mlx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
import argparse
from contextlib import closing
from importlib.metadata import version, PackageNotFoundError
import json
import os
from pathlib import Path
import platform
import sys


MODEL = "mlx-community/Qwen3.5-9B-4bit"
REQUIRED_FILES = (
"config.json", "tokenizer.json", "tokenizer_config.json",
"chat_template.jinja", "model.safetensors.index.json",
)
CONTEXT_TOKENS = 8192
REPLY_TOKENS = 512
HELP = "q: quit | r: reset conversation | h: help"
Expand Down Expand Up @@ -47,8 +52,8 @@ def configure_cache(cache_dir, volume):


def check_environment():
if sys.version_info < (3, 10) or sys.prefix == sys.base_prefix:
raise ValueError("A Python 3.10+ virtual environment is required.")
if sys.version_info < (3, 11) or sys.prefix == sys.base_prefix:
raise ValueError("A Python 3.11+ virtual environment is required.")
import mlx.core
from mlx_lm import load, stream_generate
from mlx_lm.models.cache import make_prompt_cache
Expand All @@ -59,6 +64,19 @@ def check_environment():
raise ValueError("mlx-lm 0.32.2 or newer is required for this launcher.")


def snapshot_is_complete(snapshot):
"""Check Qwen3.5's required files, not the integrity of their contents."""
root = Path(snapshot)
# Require the index even if a shard exists: a partial download may omit it.
if not all((root / name).is_file() for name in REQUIRED_FILES):
return False
with (root / "model.safetensors.index.json").open() as index:
shards = set(json.load(index)["weight_map"].values())
if not shards:
raise ValueError("Cached model weight index contains no shards.")
return all((root / shard).is_file() for shard in shards)


def chat(model, tokenizer):
import mlx.core as mx
from mlx_lm import stream_generate
Expand Down Expand Up @@ -182,15 +200,28 @@ def main():
from mlx_lm import load
except ImportError as error:
parser.exit(1, f"Cannot import mlx-lm from this Python environment: {error}\n")
revision = None
allow_patterns = [*REQUIRED_FILES, "model*.safetensors"]
try:
snapshot = snapshot_download(
MODEL, cache_dir=str(root / "hub"), local_files_only=True
MODEL, cache_dir=str(root / "hub"), local_files_only=True,
allow_patterns=allow_patterns,
)
if not snapshot_is_complete(snapshot):
# Hub cache snapshot directories are named by their commit hash.
revision = Path(snapshot).name
raise LocalEntryNotFoundError("Cached Qwen3.5 snapshot is incomplete.")
except LocalEntryNotFoundError:
print(f"Downloading {MODEL} to {root / 'hub'} (external SSD)...", flush=True)
snapshot = snapshot_download(
MODEL, cache_dir=str(root / "hub"), local_files_only=False
MODEL, cache_dir=str(root / "hub"), local_files_only=False,
allow_patterns=allow_patterns, revision=revision,
)
if not snapshot_is_complete(snapshot):
raise FileNotFoundError(
"Qwen3.5 snapshot is still incomplete after download. "
"Check connectivity and retry; the external cache has been preserved."
)

print(f"Loading {MODEL} from the external cache...", flush=True)
model, tokenizer = load(
Expand Down
61 changes: 55 additions & 6 deletions tests/bash/global-mlx-chat.bats
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ setup() {
MLX_TEST_VOLUME="$(cd "$BATS_TEST_TMPDIR/External Disk Data" && pwd -P)"
CACHE_DIR="$MLX_TEST_VOLUME/AI/huggingface"
export MOCK_OS=Darwin MOCK_ARCH=arm64 MOCK_MOUNTED=1 MOCK_BASE_PYTHON=1
export MOCK_BASE_PYTHON_VERSION=3.14 MLX_TEST_REAL_PYTHON
MLX_TEST_REAL_PYTHON="$(tool_binary python3)" || skip "Python 3 not installed"
export PYTHON_CALLS="$BATS_TEST_TMPDIR/python-calls"
export MLX_TEST_ACTIONS="$BATS_TEST_TMPDIR/actions"
export MLX_TEST_STATE="$BATS_TEST_TMPDIR/state"
Expand Down Expand Up @@ -72,6 +74,7 @@ case "$1" in
-c)
case "$0" in "$MLX_TEST_BREW"/*) exit 0 ;; esac
[ "$MOCK_BASE_PYTHON" -eq 1 ] || exit 1
"$MLX_TEST_REAL_PYTHON" -c 'import platform,sys; sys.version_info=tuple(map(int,sys.argv[1].split("."))); platform.machine=lambda: "arm64"; exec(sys.argv[2])' "$MOCK_BASE_PYTHON_VERSION" "$2"
;;
-m)
[ "$2" = venv ] || exit 1
Expand Down Expand Up @@ -119,10 +122,27 @@ global_task() {

@test "global-mlx-chat: Python behavior is validated without MLX or model weights" {
command -v python3 >/dev/null || skip "Python 3 not installed"
run python3 -B -m unittest discover -s "$REPO_ROOT/tests/python" -p test_mlx_chat.py
run python3 -B -m unittest discover -s "$REPO_ROOT/tests/python" -p 'test_*.py'
[ "$status" -eq 0 ]
}

@test "global-mlx-chat: unavailable Python skips instead of failing setup" {
local bats_bin tool tool_path bin
bats_bin="$(tool_binary bats)"
bin="$BATS_TEST_TMPDIR/without-python"
mkdir -p "$bin"
for tool in bash basename cat chmod cp cut date dirname env find grep head ln mkdir mktemp mv \
nl paste readlink realpath rm rmdir sed sleep sort tail touch tr uname uniq wc; do
tool_path="$(command -v "$tool")" || continue
ln -s "$tool_path" "$bin/$tool"
done
[ ! -e "$bin/python3" ]
run env PATH="$bin" "$bats_bin" --tap \
--filter "^global-mlx-chat: Python behavior is validated" "$BATS_TEST_FILENAME"
[ "$status" -eq 0 ]
[[ "$output" == *"# skip Python 3 not installed"* ]]
}

@test "global-mlx-chat: global task works outside the repository with spaces in paths" {
command -v task >/dev/null || skip "Task not installed"
run global_task llm:chat
Expand Down Expand Up @@ -266,7 +286,8 @@ EOF
grep -F -- "--isolated --require-virtualenv" "$MLX_TEST_ACTIONS"
grep -F -- "--cache-dir $CACHE_DIR/pip install mlx-lm>=0.32.2" "$MLX_TEST_ACTIONS"
grep -F -- "tmp: $CACHE_DIR/tmp" "$MLX_TEST_ACTIONS"
! grep -F -- "--upgrade" "$MLX_TEST_ACTIONS"
run grep -F -- "--upgrade" "$MLX_TEST_ACTIONS"
[ "$status" -eq 1 ]

: >"$MLX_TEST_ACTIONS"
run global_task llm:chat
Expand All @@ -283,8 +304,10 @@ chat" ]
run global_task llm:chat
[ "$status" -eq 0 ]
[[ "$(cat "$MLX_TEST_ACTIONS")" == *check-pip*ensurepip*pip:*check-env*chat ]]
! grep -F -- "create-venv" "$MLX_TEST_ACTIONS"
! grep -F -- "install-python" "$MLX_TEST_ACTIONS"
run grep -F -- "create-venv" "$MLX_TEST_ACTIONS"
[ "$status" -eq 1 ]
run grep -F -- "install-python" "$MLX_TEST_ACTIONS"
[ "$status" -eq 1 ]
}

@test "global-mlx-chat: unusable packages after pip fail the post-install check" {
Expand Down Expand Up @@ -318,6 +341,30 @@ chat" ]
[[ "$(cat "$MLX_TEST_ACTIONS")" == mount*install-python*create-venv*pip:*chat ]]
}

@test "global-mlx-chat: Python 3.10 falls back to Homebrew before creating a venv" {
command -v task >/dev/null || skip "Task not installed"
rm "$TEST_HOME/mlx-qwen/.venv/bin/python"
rmdir "$TEST_HOME/mlx-qwen/.venv/bin" "$TEST_HOME/mlx-qwen/.venv"
printf 'missing\n' >"$MLX_TEST_STATE"
export MOCK_BASE_PYTHON_VERSION=3.10
run global_task llm:chat
[ "$status" -eq 0 ]
[[ "$(cat "$MLX_TEST_ACTIONS")" == mount*install-python*create-venv*pip:*chat ]]
}

@test "global-mlx-chat: Python 3.11 is accepted without installing Python" {
command -v task >/dev/null || skip "Task not installed"
rm "$TEST_HOME/mlx-qwen/.venv/bin/python"
rmdir "$TEST_HOME/mlx-qwen/.venv/bin" "$TEST_HOME/mlx-qwen/.venv"
printf 'missing\n' >"$MLX_TEST_STATE"
export MOCK_BASE_PYTHON_VERSION=3.11
run global_task llm:chat
[ "$status" -eq 0 ]
grep -F -- "create-venv" "$MLX_TEST_ACTIONS"
run grep -F -- "install-python" "$MLX_TEST_ACTIONS"
[ "$status" -eq 1 ]
}

@test "global-mlx-chat: absent Python and Homebrew are reported without unsafe bootstrap" {
command -v task >/dev/null || skip "Task not installed"
rm "$TEST_HOME/mlx-qwen/.venv/bin/python" "$BATS_TEST_TMPDIR/bin/brew"
Expand All @@ -327,7 +374,8 @@ chat" ]
run global_task llm:chat
[ "$status" -ne 0 ]
[[ "$output" == *"cannot safely bootstrap Homebrew"* ]]
! grep -F -- "install-python" "$MLX_TEST_ACTIONS"
run grep -F -- "install-python" "$MLX_TEST_ACTIONS"
[ "$status" -eq 1 ]
[ ! -e "$PYTHON_CALLS" ]
}

Expand All @@ -342,7 +390,8 @@ chat" ]
run global_task llm:chat
[ "$status" -eq 0 ]
grep -F -- "create-venv" "$MLX_TEST_ACTIONS"
! grep -F -- "install-python" "$MLX_TEST_ACTIONS"
run grep -F -- "install-python" "$MLX_TEST_ACTIONS"
[ "$status" -eq 1 ]
}

@test "global-mlx-chat: cache outside the external volume is rejected before setup" {
Expand Down
Loading
Loading