mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* Studio: launch a DFlash drafter automatically Studio has recognised dflash-*.gguf since #7811, but only to hide it from the quant picker. Nothing ever launched it, so a model that ships a DFlash sidecar fell through to no speculative decoding at all. Add DFlash as the third launchable drafter kind beside MTP and DSpark: a _is_dflash_drafter_path predicate, local and Hub discovery, a supports_dflash capability parsed from llama-server --help, and the --model-draft / --spec-type draft-dflash emission. Unlike DSpark it is on under Auto, since the published sidecar is 1.52 GiB and ships in the model's own GGUF repo rather than being an ~11 GB opt-in fetch. DSpark keeps first refusal when a repo somehow ships both, matching llama.cpp's own downloader. Discovery confirms general.architecture = dflash in the header rather than pairing on the filename: the published sidecar is dflash-kquant.gguf, which names no model family, so the DSpark pairing rule would reject the one file this exists to find. The dflash/ directory is still not a drafter marker, and DFlash is still excluded from companion reclaim, both because the name doubles as a family a publisher puts on real weights. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden the DFlash drafter fallback, pairing and dedupe Four fixes from review of the auto-launch path. Strip user-supplied DFlash args on the drafterless retry. The gate that enters the retry counts a DFlash request, but the cleanup only recognised MTP and DSpark. llama.cpp accumulates speculative types, so prepending --spec-default while the DFlash group survived relaunched the drafter that had just failed, and a main model that loads fine without it was lost instead of recovered. Skip a DFlash sidecar that names another weight in the same folder. _drafter_matches_weight is False both for a sidecar naming no family and for one naming a different family, so ranking put them in one bucket and precision could float the foreign one to the top: loading model B beside dflash-model-A-Q8_0.gguf and dflash-kquant.gguf launched model A's drafter. Both files carry a real dflash header, so the architecture check behind the ranking cannot catch it. The decision is made against the weights actually present in the folder rather than by guessing which stems are precision tokens, which keeps the published unpaired sidecar eligible. Stand the Auto DFlash fetch down once DSpark has resolved. DSpark takes first refusal in the promotion, so for a repo shipping both kinds the DFlash sidecar could never launch and the fetch spent bandwidth and cache on a file that would not be used. An explicit dflash request still fetches. Keep Auto deduplicated after a failed DFlash drafter. _speculative_type is reset to "default" by a successful drafterless retry while the launch still records the resolved sidecar, so the next Apply compared the intent's empty MTP path against it and reloaded a healthy server. _spec_drafter_kind survives the fallback and now decides the comparison. test_mtp_drafter_companion.py, test_native_gguf_companion.py, test_llama_cpp_mtp_detection.py and test_resolve_quant_gguf.py: 489 passed, including two new tests for the foreign-sidecar case and for the paired sidecar still winning. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep DFlash discovery, the training guard and the hints in step Discovery now accepts the dflash- prefix only. The shared companion predicates recognise DFlash by that prefix, so a <model>-dflash.gguf accepted by discovery was also a selectable Q8_0 main model in the quant picker, and choosing that variant handed llama-server the drafter as the target. Teaching the predicate the suffix instead would hide a real model whose name merely ends in DFlash, which is the case #7811 exists to protect, so detection gives the form up rather than the picker giving up a model. No published sidecar uses it; the shipped one is dflash-kquant.gguf. The same mismatch exists for MTP on main and is left alone here. The training VRAM guard now sizes a drafter named through llama_extra_args. Discovery never fills gguf_dflash_file for a file outside the model directory, but load_model still passes that path to llama-server, so a load could be admitted beside a training run while nothing was charged for the sidecar it makes resident. The Speculative Decoding hint said Auto picks DSpark or else MTP / ngram and that everything but DSpark leaves output unchanged. Auto now picks DFlash too, and like DSpark it is not bit-identical on quantized targets. The Draft Tokens hint gained the DFlash default, which shares the MTP branch at 2 on GPU and 3 on CPU/Mac. 514 passed across the drafter, companion, detection, quant-resolution and picker suites, including two new tests pinning the suffix form out of discovery and the prefix form still in. * Pair the remote DFlash sidecars with the selected weight detect_dflash_file already refuses a sidecar named after a NEIGHBOURING weight, so a folder holding two families cannot attach a foreign drafter locally. The download picker and the offline cache reuse still ranked every dflash-*.gguf by precision and name alone, never comparing a candidate against the weight being loaded, so in a repo hosting more than one family dflash-model-A-Q8_0.gguf outranked the generic dflash-kquant.gguf and model B downloaded and launched model A's drafter. The pairing rule now lives in one place, dflash_repo_preference_key, built on the same _drafter_names_other_weight predicate the local scan uses: a sidecar naming this weight's family first (most specific stem first, as detect_mtp_file does), then one naming no weight present here, then one naming a neighbour. The last is demoted rather than dropped, so a repo whose only sidecar looks foreign still has a fallback. Deciding against the weights actually present is what keeps the published unpaired sidecar eligible: dflash-kquant.gguf has a precision token for a stem, not a family name, so "the stem is non-empty" cannot stand in for "this names another model". Nothing changes for a repo with one sidecar, and with no weight in hand the order is precision only, as before. Tests cover a multi-family repo picking the generic sidecar, the same repo picking the specific one for its own weight, the shipped Muse-Glimmer layout still resolving, and the cached path agreeing with the download path. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Validate DFlash candidates and size the extras drafter once Three fixes found in review of the DFlash drafter work. detect_dflash_file read a candidate's GGUF header before asking the caller's accept callback about it, so a dflash-*.gguf symlink in a directory reached through a native grant had its out-of-lease target opened before the grant check ran, and no later rejection takes a read back. The loop now resolves the launch path, runs accept, and only then parses the header and applies the architecture check. accept still receives the resolved launch path, and callers that pass no accept see the same candidates in the same order as before. The training admission guard charged the llama_extra_args --model-draft sidecar on top of the local one discovery had already found, so a 1.5 GiB drafter was billed as 3 GiB and the guard could refuse an inference load that fits. The effective draft path is now sized exactly once, with identity taken from the resolved path so a symlink or another spelling of the same file dedupes too. That same charge also satisfied the local-weights early return on its own. Loading a remote GGUF repo has no local main weight, so a local --model-draft made the guard return the drafter alone and skip the listing that prices the target model, which could admit a load that then exhausts VRAM next to a running training job. The local branch now fires only when a local weight is actually present, and the drafter is added to whichever branch produces the estimate, including the remote one. Regression tests for all three. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Validate remote DFlash files by header and stop charging unused DFlash bytes Two fixes to the DFlash sidecar paths. Remote and cached DFlash candidates are now confirmed by their GGUF header, not by their filename. _pick_dflash and _cached_repo_dflash_drafter selected with _is_dflash_drafter_path, a dflash- prefix test, while the local scan in detect_dflash_file also required general.architecture == dflash. A remote repo holding an ordinary weight whose basename starts with dflash- therefore had that full weight downloaded and handed to llama-server as --model-draft, which falls back at startup after the bytes are already spent. The architecture rule moves into is_dflash_architecture in model_config, beside the naming rules and shared by every path, the way dflash_repo_preference_key already is. The header is only readable once the file is on disk, so the download validates after the fetch and falls through to the next candidate instead of returning None; the prefix-only naming rule is unchanged. The training coexistence guard no longer charges DFlash bytes a load under Auto will never fetch. _remote_gguf_companion_bytes added the preferred DSpark and the preferred DFlash sidecar whenever the repo listed both, but the loader stands down on the DFlash fetch once DSpark resolves under Auto, so those bytes are never resident and the guard could 409 a load that fits. The new dspark_first flag mirrors that selection. Where the choice is genuinely unknown the deliberate over-estimate stands, and an explicitly forced DFlash still pays for its sidecar. Regression tests cover the fetch falling through an impostor to the real sidecar, an all-impostor repo recording a permanent absence, the snapshot reuse and offline cache lookups applying the same rule, and the Auto guard charging DSpark only when a repo publishes both kinds. * Gate the DFlash stand-down and the guard's sizing on what the load actually does The Auto DFlash fetch stood down whenever _download_dspark answered with a path, but that call deliberately reports an already-cached DSpark sidecar even on a binary with no usable --spec-type draft-dspark (so the route's reuse check does not reload the same server on every Apply). The promotion refuses such a path, so on a DFlash-capable binary a repo shipping both companions suppressed the DFlash fetch for a sidecar that can never launch and the load ended up with no drafter at all. The capability gate now lives in _dspark_wins_auto, shared by the fetch and the promotion so the two cannot disagree. _remote_gguf_companion_bytes still ranked DFlash candidates with the name-only dflash_preference_key while the loader moved to the family-aware dflash_repo_preference_key, so in a multi-family repo the guard could price a different, smaller sidecar than the one that lands. The selected weight name is threaded down and the guard now sorts with the downloader's key over the neighbouring weights from the same listing. * Apply the load's boundaries to drafter discovery, and size Auto's one drafter ModelConfig.from_identifier ran the local companion scan with no way for the caller to say what was in bounds, so a native-grant load read the header of a dflash-*.gguf symlinked out of the granted directory. The validated rescan on the load route rejected it afterwards, which does not take a read back. The boundary now travels into the scan, for all three drafter kinds, so the two passes cannot disagree about what is in bounds. Remote DFlash discovery matched the basename in any nested directory, but the local contract is root level only: a quants/dflash-*.gguf is an ordinary weight detect_dflash_file would never offer, and the header can only be read once the bytes are here, so the whole weight downloaded before the rejection. Checked through a separate predicate so the prefix-only naming rule the other callers share stays exactly as it is. A split companion is only usable as a whole set, since llama-server resolves the sibling shards from the first one's directory. Fetching just the picked shard left a drafter whose header reads fine and which the server cannot open, so the load fell back to no speculation with nothing to show for the download. The companion download now resolves its shards with the same helper the main-model download uses, and neither reuse path reports a half set as a cache hit. The remote sizing charged the first-ranked DFlash candidate, but a rejected candidate falls through to the next name in the ranking, which can be a larger file; headers are unreadable from a listing, so the bound now covers every candidate the fallback can reach. And under Auto the guard charged the MTP drafter on top of the DFlash sidecar that replaces it. Auto launches exactly one drafter, in a fixed order, so dspark_first now expresses the whole promotion: DSpark alone when the repo publishes one, otherwise the larger of the DFlash bound and the MTP drafter, since every DFlash candidate can still be turned away on its header and the load then keeps the MTP one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Budget a split DFlash sidecar as a set, and reject half a cached one The remote sizing bounded the DFlash fetch with the largest candidate the post-fetch fallback could land on, but each entry is one shard, while _download_companion_gguf fetches the whole shard set the picked file belongs to and llama-server keeps every shard resident. A sidecar published as two 1 GiB shards was budgeted at 1 GiB, and under-charging is the direction that waves a load through and then exhausts VRAM beside a running training job. The candidates are grouped into their sets with _gguf_extra_shards, the same helper the download resolves shards with, and the bound is the largest set total. _cached_repo_dflash_drafter's offline fallback accepted a candidate on is_file plus its header, so a snapshot holding shard 1 alone was handed back as the drafter with no fetch left to complete the set. The header reads fine, then llama-server cannot open the siblings it resolves from that directory and the load falls back to no speculation. Same _drafter_split_is_complete rule the snapshot reuse already applies, and skipped rather than fatal like the header check, since another snapshot may hold the complete copy. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move drafter naming, ranking and DFlash discovery into a drafters package Pure structural move, no behaviour change. The shared primitives, the ranking keys and the DFlash detector were spread through model_config alongside unrelated model handling, and the same rules are reached from four different paths, so they now live in one package. model_config re-exports every moved name, so callers and tests that import them from there keep working. The package deliberately does not import model_config at module import time. The GGUF split and quant naming helpers stay where they are, since non-drafter code shares them, and are imported per call instead. * Give the guard's DFlash bound a name and a home The bound was fifteen lines of generator plus the comment explaining why it is a max over shard sets rather than the best-ranked candidate, inline in the middle of a function that also sizes mmproj, MTP and DSpark. It is pure arithmetic over a listing, so it moves to drafters.budget with the reasoning attached to it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: fix the DFlash lint gate, and carry over what #6747 got right Five changes on top of the DFlash drafter work. Lint gate. The compatibility shim re-exporting the moved drafter helpers from model_config tripped scripts/verify_import_hoist.py, whose __all__ exemption is scoped to package __init__.py and which ships a reexport_in_ordinary_module_is_still_blocked self-test. The shim is gone: the module imports only what it still calls, and every other call site imports from utils.models.drafters directly. dspark_preference_key stays reachable from model_config as a delegating def, because repointing routes/inference.py's pre-existing function-local import is the verifier's TARGET-CHANGED case and its relocation exemption only covers module-level imports. Download plan. preferred_dflash_sibling in hub/utils/gguf_plan.py, and the sidecar as an expected file on every GgufVariantPlan. The sidecar was fetched but the hub manifest never knew about it, so download progress under-counted by ~1.5 GiB. Ranked with dflash_repo_preference_key, so the plan and the loader cannot disagree, and per variant, so a multi-family repo does not hand variant B the drafter named after variant A. Capability-regained retry. A load that stood down because llama-server could not run the drafter told the user to update, then deduped the reload the update was meant to repair. spec_binary_fallback_can_retry re-reads the binary, asking about the capability the drafter kind actually needs rather than the reason code, since every kind records the same binary_no_mtp. Transient fetch retry. _download_companion_gguf gained on_transient_failure, so a listing that never answered or a download that dropped is worth one more Apply. Permanent Hub errors, a full or unwritable cache, offline mode and cancellation are unaffected, and a header rejection still falls through to the next candidate rather than counting as transient. The probe cache key moved from (path, int(mtime)) to (path, st_mtime_ns, st_size), so an update landing in the same second as the probe is not answered with the old build's capabilities. CLI. unsloth_cli/_inference.py passes gguf_dflash_file into the GGUF load, so the managed CLI path engages DFlash instead of silently running without it. No vision gate, now measured rather than argued. Muse-Glimmer-30B UD-Q4_K_XL with mmproj-kquant and dflash-kquant, llama.cpp b10342, one B200, n_max=2, greedy, on a prompt carrying ~545 image tokens: 92.1 to 114.2 tok/s at 0.646 acceptance, greedy output byte-identical to the drafter-free run, no load failure. The comment at the Auto promotion site cited llama.cpp #22673, which is an MTP result, for a DFlash decision; it now cites the measurement. Also fixes test_from_identifier_never_reads_a_sidecar_outside_the_boundary, which patched is_dflash_architecture on the re-exporting module rather than the one detect_dflash_file resolves it in, so its reads == [] assertion held whether or not the lease boundary worked. * Tighten the DFlash comments for PR #8338 * Apply ruff-format kwarg spacing for PR #8338 * Fix the DFlash download plan and two stale-state reloads for PR #8338 Five review items, all reproduced first. The download plan promised the wrong files. A split sidecar contributed only its first shard, so the variant read complete while the loader's completeness check then refused the companion; it now carries the whole shard family. The pairing weight came from the listing's first sibling while plan_from_expected_files keeps the lexicographically first family, so a two-family variant key planned the discarded family's sidecar; both now use the kept family. And a root-level dflash- prefix is one real weights carry, which a listing cannot tell apart from a drafter, so a 54 GB model was planned as a companion to a 15 GB variant; a candidate is now bounded by the weights it would draft for, since a drafter is a few layers of its target and cannot outweigh it. The training coexistence guard charged the Auto DFlash sidecar even when extra args owned --spec-type, which stops the loader's promotion, so a chat load could be refused with 409 for bytes nothing would open. Extra args asking for draft-dflash keep the charge. The diffusion early-return cleared the speculative fallback state but not the DFlash retry flag, and discovery runs before the metadata read that classifies the model, so a transient sidecar failure tore down a healthy diffusion server on every Apply. Each fix has a regression test that fails without it. * Carry the DFlash plan bounds into the runtime paths for PR #8338 Four review items from the second round, each reproduced first. The budget still charged a forced dflash mode when extra args owned --spec-type. _build_speculative_flags returns before any mode branch in that case, so neither the forced mode nor the Auto promotion reaches the sidecar; only extra args asking for draft-dflash themselves still pay. The runtime picker had no size bound, so a root-level ordinary weight carrying the dflash- prefix downloaded in full before its header could be read, which is exactly what the download plan now refuses. It applies the same bound, sized from the repo listing, and an unavailable size leaves the candidate eligible as before. A permanent listing error records no answer at all, so _dflash_sidecar_absent stayed False and the drafter_not_found arm relaunched a healthy drafter-free server on every Apply. DFlash asks through _dflash_retry_needed instead, which is set only for the failures worth another attempt. A listing holding part of a split companion returned its first shard as usable, contradicting the complete-set checks on snapshot and cache reuse and handing llama-server a set it cannot open. The filename carries the set size, so the listing is now checked before the download. Each fix has a regression test that fails without it. * Make the DFlash size and split rules agree across plan, fetch and guard for PR #8338 Six review items from the third round, each reproduced first. Five are places the previous round's rules had not reached. dflash_plan_files now filters candidate families before ranking rather than after, so a half-published split set or an oversized ordinary weight at the top of the order steps aside for a usable sidecar behind it instead of taking the plan down with it. It also applies the split-completeness rule the runtime got last round, since planning a set the listing only half carries reports the download complete and then loses DFlash. The runtime size bound compared the picked shard rather than its whole set, so a split ordinary weight whose halves each sit under the target still downloaded in full. It sums the family now, through a shared helper. The training coexistence guard took the maximum over every root candidate with no size bound at all, charging gigabytes for files the fetch itself refuses. dflash_budget_bytes takes the target size and drops them. The incomplete-split rejection added last round lands after outcome["listed"] is set, so DSpark read a settled answer as retryable and relaunched a healthy server on every Apply. It records absence explicitly. SpeculativeType omitted dflash, so Typer rejected --speculative-type dflash before any of the new loading code ran and the mode was reachable only through Auto. Each fix has a regression test that fails without it. * Price DSpark by shard set and share one split-listing rule for PR #8338 Four review items from the fourth round, two of them under-charges that could admit a load beside a running training job and then exhaust VRAM. The guard priced a remote DSpark sidecar as the single file the ranking picked, while llama-server maps every shard of a split set, so a two-shard sidecar was budgeted at roughly half its resident weight. DSpark candidates are grouped into shard families now and the selected family's total is charged, matching what DFlash already did. Auto granted DSpark first refusal on the strength of the listing alone. Since the fetch now refuses an incomplete split set, the load falls through to DFlash, which can be the larger of the two, and the guard had already returned the DSpark figure. Only a complete set settles it. The runtime DFlash picker filtered incomplete families after ranking rather than before, so a half-published set at the top of the order returned a shard, _download_companion_gguf refused it, and the loop ended instead of reaching the complete sidecar behind it. Extras owning --spec-type with their own --model-draft charged the discovered sidecar as well, though _build_speculative_flags returns before that one is emitted. Only the drafter that launches is charged now. Extras without --spec-type still charge both, since Studio emits its own and which lands is genuinely unknown. The listing completeness rule was about to have three copies, so it moved into utils.models.drafters as split_listing_is_complete and the plan, the fetch and the guard all call it. Each fix has a regression test that fails without it. * Tighten the DFlash review-round comments for PR #8338 Comments and docstrings only, no code change: verified with comment_tools.py check and the prepush gate's comment-only mode. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1463 lines
48 KiB
Python
1463 lines
48 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the `unsloth chat` / `unsloth inference` CLI — fakes only, no model loads."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import json
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
|
|
import typer
|
|
import pytest
|
|
from rich.console import Console
|
|
from typer.testing import CliRunner
|
|
|
|
import unsloth_cli.commands.chat as chatmod
|
|
from unsloth_cli._inference import (
|
|
ChatBackend,
|
|
HttpChatBackend,
|
|
collect_stream,
|
|
mlx_distributed_info,
|
|
mlx_distributed_uses_mpi,
|
|
render_columns,
|
|
visible_text,
|
|
)
|
|
|
|
|
|
class _FakeConfig:
|
|
is_gguf = False
|
|
is_lora = True
|
|
display_name = "fake-model"
|
|
base_model = "fake/base"
|
|
path = None
|
|
|
|
|
|
_EXPECTED_MPI_ENV_PAIRS = [
|
|
("OMPI_COMM_WORLD_RANK", "OMPI_COMM_WORLD_SIZE"),
|
|
("PMI_RANK", "PMI_SIZE"),
|
|
("PMIX_RANK", "PMIX_SIZE"),
|
|
("MPI_RANK", "MPI_WORLD_SIZE"),
|
|
("MV2_COMM_WORLD_RANK", "MV2_COMM_WORLD_SIZE"),
|
|
]
|
|
_IGNORED_DISTRIBUTED_ENV_PAIRS = [("SLURM_PROCID", "SLURM_NTASKS")]
|
|
|
|
|
|
def _chat_app():
|
|
cli = typer.Typer()
|
|
cli.command()(chatmod.chat)
|
|
return cli
|
|
|
|
|
|
def _inference_app():
|
|
from unsloth_cli.commands.inference import inference
|
|
|
|
cli = typer.Typer()
|
|
cli.command()(inference)
|
|
return cli
|
|
|
|
|
|
def _clear_mlx_distributed_env(monkeypatch):
|
|
for name in (
|
|
"MLX_RANK",
|
|
"MLX_HOSTFILE",
|
|
"MLX_WORLD_SIZE",
|
|
"MLX_IBV_DEVICES",
|
|
"MLX_JACCL_COORDINATOR",
|
|
"NCCL_HOST_IP",
|
|
"NCCL_PORT",
|
|
*(rank for rank, _size in _EXPECTED_MPI_ENV_PAIRS + _IGNORED_DISTRIBUTED_ENV_PAIRS),
|
|
*(size for _rank, size in _EXPECTED_MPI_ENV_PAIRS + _IGNORED_DISTRIBUTED_ENV_PAIRS),
|
|
):
|
|
monkeypatch.delenv(name, raising = False)
|
|
|
|
|
|
def _set_mlx_nccl_env(
|
|
monkeypatch,
|
|
*,
|
|
rank: str = "0",
|
|
size: str = "2",
|
|
):
|
|
monkeypatch.setenv("MLX_RANK", rank)
|
|
monkeypatch.setenv("MLX_WORLD_SIZE", size)
|
|
monkeypatch.setenv("NCCL_HOST_IP", "127.0.0.1")
|
|
monkeypatch.setenv("NCCL_PORT", "12345")
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _isolate_mlx_distributed_env(monkeypatch):
|
|
_clear_mlx_distributed_env(monkeypatch)
|
|
monkeypatch.delenv("HF_TOKEN", raising = False)
|
|
|
|
|
|
def test_visible_text_passthrough_when_shown():
|
|
text = "<think>reasoning</think>answer"
|
|
assert visible_text(text, show_thinking = True) == text
|
|
|
|
|
|
def test_visible_text_strips_closed_think_block():
|
|
text = "<think>step 1\nstep 2</think>The answer is 42."
|
|
assert visible_text(text, show_thinking = False) == "The answer is 42."
|
|
|
|
|
|
def test_visible_text_holds_unclosed_think():
|
|
# An open <think> is held back so partial reasoning never leaks mid-stream.
|
|
assert visible_text("<think>still thinking", show_thinking = False) == ""
|
|
assert visible_text("done.<think>more thinking", show_thinking = False) == "done."
|
|
|
|
|
|
def test_visible_text_holds_partial_think_prefix():
|
|
# Streams are cumulative, so the opening tag can arrive as "<", "<thi",
|
|
# then "<think>". Hold possible tag prefixes until they are disambiguated.
|
|
assert visible_text("<", show_thinking = False) == ""
|
|
assert visible_text("<thi", show_thinking = False) == ""
|
|
assert visible_text("done.<thi", show_thinking = False) == "done."
|
|
assert visible_text("2 < 3", show_thinking = False) == "2 < 3"
|
|
|
|
|
|
def _option(command_fn, name):
|
|
return inspect.signature(command_fn).parameters[name].default
|
|
|
|
|
|
def test_inference_think_defaults_off():
|
|
from unsloth_cli.commands.inference import inference
|
|
|
|
opt = _option(inference, "think")
|
|
assert getattr(opt, "default", None) is False
|
|
# typer stores a flag/--no-flag pair as one combined decl.
|
|
assert "--think/--no-think" in (getattr(opt, "param_decls", None) or [])
|
|
|
|
|
|
def test_inference_exposes_gguf_runtime_options():
|
|
from unsloth_cli.commands.inference import inference
|
|
|
|
tensor = _option(inference, "tensor_parallel")
|
|
assert "--tensor-parallel/--no-tensor-parallel" in (getattr(tensor, "param_decls", None) or [])
|
|
|
|
extra = _option(inference, "llama_extra_args")
|
|
assert "--llama-extra-arg" in (getattr(extra, "param_decls", None) or [])
|
|
|
|
spec_type = _option(inference, "speculative_type")
|
|
assert "--speculative-type" in (getattr(spec_type, "param_decls", None) or [])
|
|
draft_n = _option(inference, "spec_draft_n_max")
|
|
assert "--spec-draft-n-max" in (getattr(draft_n, "param_decls", None) or [])
|
|
|
|
|
|
def test_mlx_distributed_info_reads_launch_env(monkeypatch, tmp_path):
|
|
_clear_mlx_distributed_env(monkeypatch)
|
|
assert mlx_distributed_info() == (False, 0, None)
|
|
assert mlx_distributed_uses_mpi() is False
|
|
|
|
monkeypatch.setenv("MLX_RANK", "1")
|
|
monkeypatch.setenv("MLX_WORLD_SIZE", "2")
|
|
assert mlx_distributed_info() == (False, 0, None)
|
|
monkeypatch.setenv("NCCL_HOST_IP", "127.0.0.1")
|
|
monkeypatch.setenv("NCCL_PORT", "12345")
|
|
assert mlx_distributed_info() == (True, 1, 2)
|
|
assert mlx_distributed_uses_mpi() is False
|
|
|
|
_clear_mlx_distributed_env(monkeypatch)
|
|
ring_hostfile = tmp_path / "ring.json"
|
|
ring_hostfile.write_text('[["127.0.0.1:5000"], ["127.0.0.1:5001"]]\n')
|
|
monkeypatch.setenv("MLX_RANK", "0")
|
|
monkeypatch.setenv("MLX_HOSTFILE", str(ring_hostfile))
|
|
assert mlx_distributed_info() == (True, 0, 2)
|
|
assert mlx_distributed_uses_mpi() is False
|
|
|
|
_clear_mlx_distributed_env(monkeypatch)
|
|
monkeypatch.setenv("MLX_RANK", "1")
|
|
monkeypatch.setenv("MLX_IBV_DEVICES", '[["node-a"], ["node-b"]]')
|
|
monkeypatch.setenv("MLX_JACCL_COORDINATOR", "node-a:12345")
|
|
assert mlx_distributed_info() == (True, 1, 2)
|
|
assert mlx_distributed_uses_mpi() is False
|
|
|
|
_clear_mlx_distributed_env(monkeypatch)
|
|
monkeypatch.setenv("OMPI_COMM_WORLD_RANK", "1")
|
|
monkeypatch.setenv("OMPI_COMM_WORLD_SIZE", "2")
|
|
assert mlx_distributed_info() == (True, 1, 2)
|
|
assert mlx_distributed_uses_mpi() is True
|
|
|
|
_clear_mlx_distributed_env(monkeypatch)
|
|
monkeypatch.setenv("MLX_RANK", "bad")
|
|
monkeypatch.setenv("MLX_WORLD_SIZE", "-3")
|
|
assert mlx_distributed_info() == (False, 0, None)
|
|
|
|
|
|
def test_chat_command_is_registered_with_options():
|
|
params = inspect.signature(chatmod.chat).parameters
|
|
assert "model" in params
|
|
|
|
think = _option(chatmod.chat, "think")
|
|
assert "--think/--no-think" in (getattr(think, "param_decls", None) or [])
|
|
|
|
compare = _option(chatmod.chat, "compare")
|
|
assert "--compare/--no-compare" in (getattr(compare, "param_decls", None) or [])
|
|
|
|
verbose = _option(chatmod.chat, "verbose")
|
|
assert {"--verbose", "-v"} <= set(getattr(verbose, "param_decls", None) or [])
|
|
|
|
tensor = _option(chatmod.chat, "tensor_parallel")
|
|
assert "--tensor-parallel/--no-tensor-parallel" in (getattr(tensor, "param_decls", None) or [])
|
|
|
|
extra = _option(chatmod.chat, "llama_extra_args")
|
|
assert "--llama-extra-arg" in (getattr(extra, "param_decls", None) or [])
|
|
|
|
spec_type = _option(chatmod.chat, "speculative_type")
|
|
assert "--speculative-type" in (getattr(spec_type, "param_decls", None) or [])
|
|
draft_n = _option(chatmod.chat, "spec_draft_n_max")
|
|
assert "--spec-draft-n-max" in (getattr(draft_n, "param_decls", None) or [])
|
|
|
|
|
|
class _FakeBackend:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def generate_chat_response(self, **kwargs):
|
|
self.calls.append(("plain", None, kwargs))
|
|
return iter(["hi"])
|
|
|
|
def generate_with_adapter_control(self, *, use_adapter, **kwargs):
|
|
self.calls.append(("adapter", use_adapter, kwargs))
|
|
return iter(["hi"])
|
|
|
|
|
|
_STREAM_KWARGS = dict(
|
|
system_prompt = "",
|
|
temperature = 0.7,
|
|
top_p = 0.9,
|
|
top_k = 40,
|
|
max_new_tokens = 8,
|
|
repetition_penalty = 1.1,
|
|
enable_thinking = False,
|
|
)
|
|
|
|
|
|
def test_chatbackend_routes_compare_to_adapter_control():
|
|
fake = _FakeBackend()
|
|
backend = ChatBackend("unsloth", fake)
|
|
|
|
list(backend.stream([{"role": "user", "content": "x"}], use_adapter = False, **_STREAM_KWARGS))
|
|
list(backend.stream([{"role": "user", "content": "x"}], use_adapter = True, **_STREAM_KWARGS))
|
|
|
|
assert [(path, flag) for path, flag, _ in fake.calls] == [
|
|
("adapter", False),
|
|
("adapter", True),
|
|
]
|
|
|
|
|
|
def test_chatbackend_normal_path_skips_adapter_control():
|
|
fake = _FakeBackend()
|
|
backend = ChatBackend("unsloth", fake)
|
|
|
|
list(backend.stream([{"role": "user", "content": "x"}], **_STREAM_KWARGS))
|
|
|
|
assert fake.calls[0][0] == "plain"
|
|
|
|
|
|
def test_collect_stream_returns_last_cumulative_think_stripped():
|
|
stream = iter(["<think>r</think>hel", "<think>r</think>hello"])
|
|
assert collect_stream(stream, show_thinking = False) == "hello"
|
|
|
|
|
|
def test_render_columns_emits_both_answers_with_separator(capsys):
|
|
render_columns("base", "alpha", "tuned", "beta")
|
|
out = capsys.readouterr().out
|
|
assert "base" in out and "tuned" in out
|
|
assert "alpha" in out and "beta" in out
|
|
assert "│" in out
|
|
|
|
|
|
def test_you_prompt_matches_readline_backend(monkeypatch):
|
|
gnu = types.ModuleType("readline")
|
|
gnu.__doc__ = "Importing this module enables command line editing using GNU readline."
|
|
monkeypatch.setitem(sys.modules, "readline", gnu)
|
|
prompt = chatmod._you_prompt(colors = True)
|
|
assert "You: " in prompt and "\001" in prompt
|
|
|
|
libedit = types.ModuleType("readline")
|
|
libedit.__doc__ = "Importing this module enables command line editing using libedit readline."
|
|
monkeypatch.setitem(sys.modules, "readline", libedit)
|
|
assert chatmod._you_prompt(colors = True) == "\n\x1b[1;36mYou: \x1b[0m"
|
|
assert chatmod._you_prompt(colors = False) == "\nYou: "
|
|
|
|
# Windows: no readline module at all; the console's own line editing
|
|
# handles backspace, so plain ANSI color (no markers) is safe.
|
|
monkeypatch.setitem(sys.modules, "readline", None)
|
|
assert chatmod._you_prompt(colors = True) == "\n\x1b[1;36mYou: \x1b[0m"
|
|
assert chatmod._you_prompt(colors = False) == "\nYou: "
|
|
|
|
|
|
def test_chat_registered_on_app():
|
|
from unsloth_cli import app
|
|
|
|
# cmd.name is None until typer resolves it from the callback name.
|
|
names = {(cmd.name or cmd.callback.__name__) for cmd in app.registered_commands}
|
|
assert "chat" in names
|
|
|
|
|
|
def test_chat_exits_cleanly_on_slash_exit(monkeypatch):
|
|
closed = []
|
|
|
|
class _FakeChatBackend:
|
|
def stream(self, *a, **k):
|
|
return iter(["hello"])
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: _FakeChatBackend())
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
monkeypatch.setattr(chatmod, "connect_studio_server", lambda *a, **k: None)
|
|
|
|
runner = CliRunner()
|
|
for args in (["fake-model"], ["fake-model", "--compare"]):
|
|
closed.clear()
|
|
result = runner.invoke(_chat_app(), args, input = "hi\n/exit\n")
|
|
assert result.exit_code == 0, result.output
|
|
assert closed == [True]
|
|
assert "Bye." in result.output
|
|
# The prompt must go through input() (readline-safe), not a print.
|
|
assert "You: " in result.output
|
|
assert "You: You:" not in result.output
|
|
|
|
|
|
def test_pick_trained_model_lists_and_selects(monkeypatch):
|
|
fake_models = types.ModuleType("utils.models")
|
|
fake_models.scan_trained_models = lambda: [
|
|
("run-new", "outputs/run-new", "lora"),
|
|
("run-old", "outputs/run-old", "merged"),
|
|
]
|
|
monkeypatch.setitem(sys.modules, "utils.models", fake_models)
|
|
|
|
monkeypatch.setattr("builtins.input", lambda prompt = "": "2")
|
|
assert chatmod._pick_trained_model(Console()) == "outputs/run-old"
|
|
|
|
monkeypatch.setattr("builtins.input", lambda prompt = "": "")
|
|
assert chatmod._pick_trained_model(Console()) == "outputs/run-new"
|
|
|
|
|
|
def test_chat_no_arg_chats_with_picked_trained_model(monkeypatch):
|
|
class _FakeChatBackend:
|
|
def stream(self, *a, **k):
|
|
return iter(["hello"])
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
resolved = []
|
|
monkeypatch.setattr(chatmod, "_pick_trained_model", lambda console: "outputs/run-42")
|
|
monkeypatch.setattr(
|
|
chatmod,
|
|
"resolve_model_config",
|
|
lambda model, **k: (resolved.append(model), _FakeConfig())[1],
|
|
)
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: _FakeChatBackend())
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
monkeypatch.setattr(chatmod, "connect_studio_server", lambda *a, **k: None)
|
|
|
|
result = CliRunner().invoke(_chat_app(), [], input = "/exit\n")
|
|
assert result.exit_code == 0, result.output
|
|
assert resolved == ["outputs/run-42"]
|
|
|
|
|
|
def test_find_studio_server_none_when_not_running(monkeypatch):
|
|
import urllib.request
|
|
|
|
from unsloth_cli import _inference
|
|
|
|
def refuse(*a, **k):
|
|
raise OSError("connection refused")
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", refuse)
|
|
assert _inference.find_studio_server() is None
|
|
|
|
|
|
def test_find_studio_server_prefers_ipv4_loopback_for_localhost(monkeypatch):
|
|
# localhost resolving ::1-first must not hide an Unsloth bound to 127.0.0.1:
|
|
# discovery tries each loopback address and returns the one that answers.
|
|
import socket
|
|
import urllib.request
|
|
|
|
from unsloth_cli import _inference
|
|
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_URL", "http://localhost:8888")
|
|
monkeypatch.setattr(
|
|
socket,
|
|
"getaddrinfo",
|
|
lambda *a, **k: [
|
|
(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("::1", 8888, 0, 0)),
|
|
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 8888)),
|
|
],
|
|
)
|
|
|
|
class _OK:
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def only_ipv4(request, *a, **k):
|
|
if "127.0.0.1" not in request.full_url:
|
|
raise OSError("connection refused")
|
|
return _OK()
|
|
|
|
monkeypatch.setattr(urllib.request, "urlopen", only_ipv4)
|
|
assert _inference.find_studio_server() == "http://127.0.0.1:8888"
|
|
|
|
|
|
class _FakeSSEResponse:
|
|
def __init__(self, lines):
|
|
self._lines = lines
|
|
|
|
def __iter__(self):
|
|
return iter(self._lines)
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
|
|
def test_http_backend_streams_cumulative_text(monkeypatch):
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
response = _FakeSSEResponse(
|
|
[
|
|
b'data: {"choices":[{"delta":{"content":"He"}}]}\n',
|
|
b"\n",
|
|
b'data: {"choices":[{"delta":{"content":"llo"}}]}\n',
|
|
b"data: [DONE]\n",
|
|
]
|
|
)
|
|
monkeypatch.setattr(backend, "_request", lambda *a, **k: response)
|
|
|
|
out = list(backend.stream([{"role": "user", "content": "hi"}], **_STREAM_KWARGS))
|
|
assert out == ["He", "Hello"]
|
|
|
|
|
|
class _FakeLoadResponse:
|
|
"""A /api/inference/load reply that records whether its body was drained.
|
|
|
|
Closing a padded body at the headers resumes before the load finishes and discards
|
|
a late failure, so the fake must distinguish read() from close().
|
|
"""
|
|
|
|
def __init__(self, body: bytes = b'{"status": "loaded"}') -> None:
|
|
self._body = body
|
|
self.reads = 0
|
|
self.closed = False
|
|
|
|
def read(self) -> bytes:
|
|
self.reads += 1
|
|
return self._body
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def test_http_backend_load_forwards_gguf_runtime_options(monkeypatch):
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
requests = []
|
|
|
|
def fake_request(
|
|
method,
|
|
path,
|
|
payload = None,
|
|
timeout = None,
|
|
):
|
|
requests.append((method, path, payload, timeout))
|
|
return _FakeLoadResponse()
|
|
|
|
monkeypatch.setattr(backend, "_request", fake_request)
|
|
|
|
backend.ensure_loaded(
|
|
"org/model-GGUF",
|
|
hf_token = "hf_x",
|
|
max_seq_length = 8192,
|
|
load_in_4bit = False,
|
|
tensor_parallel = True,
|
|
speculative_type = "dspark",
|
|
spec_draft_n_max = 3,
|
|
llama_extra_args = ["--top-k", "20"],
|
|
)
|
|
|
|
assert requests == [
|
|
(
|
|
"POST",
|
|
"/api/inference/load",
|
|
{
|
|
"model_path": "org/model-GGUF",
|
|
"hf_token": "hf_x",
|
|
"max_seq_length": 8192,
|
|
"load_in_4bit": False,
|
|
"tensor_parallel": True,
|
|
"speculative_type": "dspark",
|
|
"spec_draft_n_max": 3,
|
|
"llama_extra_args": ["--top-k", "20"],
|
|
},
|
|
None,
|
|
)
|
|
]
|
|
|
|
|
|
def test_http_backend_load_sends_explicit_false_tensor_parallel(monkeypatch):
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
requests = []
|
|
|
|
monkeypatch.setattr(
|
|
backend,
|
|
"_request",
|
|
lambda method, path, payload = None, timeout = None: (
|
|
requests.append((method, path, payload, timeout)),
|
|
_FakeLoadResponse(),
|
|
)[1],
|
|
)
|
|
|
|
backend.ensure_loaded(
|
|
"org/model-GGUF",
|
|
hf_token = None,
|
|
max_seq_length = 4096,
|
|
load_in_4bit = True,
|
|
tensor_parallel = False,
|
|
)
|
|
|
|
assert requests[0][2]["tensor_parallel"] is False
|
|
|
|
|
|
# ── A load slower than the proxy timer (see routes/inference.py _tunnel_safe_json) ──
|
|
|
|
|
|
def test_http_backend_load_drains_the_padded_body(monkeypatch):
|
|
"""Closing at the headers would start generating while the model is still loading."""
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
# What a padded slow load looks like on the wire: spaces, then the payload.
|
|
response = _FakeLoadResponse(b' {"status": "loaded"}')
|
|
monkeypatch.setattr(backend, "_request", lambda *a, **k: response)
|
|
|
|
backend.ensure_loaded(
|
|
"org/model-GGUF",
|
|
hf_token = None,
|
|
max_seq_length = 4096,
|
|
load_in_4bit = True,
|
|
)
|
|
|
|
assert response.reads == 1, "the padded body must be drained, not closed at the headers"
|
|
assert response.closed
|
|
|
|
|
|
def test_http_backend_load_fails_on_a_deferred_error(monkeypatch, capsys):
|
|
"""A failure found after the 200 committed rides in the body; a 200 is not success."""
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
response = _FakeLoadResponse(
|
|
json.dumps(
|
|
{"_deferred_error": {"status_code": 507, "detail": "CUDA out of memory"}}
|
|
).encode()
|
|
)
|
|
monkeypatch.setattr(backend, "_request", lambda *a, **k: response)
|
|
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
backend.ensure_loaded(
|
|
"org/model-GGUF",
|
|
hf_token = None,
|
|
max_seq_length = 4096,
|
|
load_in_4bit = True,
|
|
)
|
|
|
|
# Same exit code as an early HTTP failure: ensure_loaded's except block is reused.
|
|
assert excinfo.value.exit_code == 1
|
|
err = capsys.readouterr().err
|
|
assert "Model load failed" in err
|
|
assert "507" in err and "CUDA out of memory" in err
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("body", "what"),
|
|
[
|
|
(b"", "an empty body"),
|
|
(b" ", "pad bytes only"),
|
|
(b' {"status": "loa', "a payload cut in half"),
|
|
(b"null", "a literal null"),
|
|
(b"{}", "an empty object"),
|
|
],
|
|
)
|
|
def test_http_backend_load_rejects_a_truncated_padded_body(monkeypatch, capsys, body, what):
|
|
"""A proxy that gives up mid-pad leaves a 200 the padded route never finished.
|
|
|
|
Measured: one byte at t=90s then silence is killed ~125s later and the client sees
|
|
a 200 with an EMPTY body. Accepting it reports an unfinished load as done.
|
|
"""
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
response = _FakeLoadResponse(body)
|
|
monkeypatch.setattr(backend, "_request", lambda *a, **k: response)
|
|
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
backend.ensure_loaded(
|
|
"org/model-GGUF",
|
|
hf_token = None,
|
|
max_seq_length = 4096,
|
|
load_in_4bit = True,
|
|
)
|
|
|
|
assert excinfo.value.exit_code == 1, what
|
|
err = capsys.readouterr().err
|
|
assert "Model load failed" in err
|
|
assert "did not report completion" in err
|
|
# Still drained, so the load is not abandoned at the headers.
|
|
assert response.reads == 1 and response.closed
|
|
|
|
|
|
def test_padded_body_helper_passes_a_real_payload_through():
|
|
from unsloth_cli._inference import require_completed_padded_body
|
|
|
|
body = {"status": "loaded", "model": "org/model-GGUF"}
|
|
assert require_completed_padded_body("http://x/api/inference/load", body) is body
|
|
assert require_completed_padded_body("http://x", {"status": "unloaded"}) == {
|
|
"status": "unloaded"
|
|
}
|
|
|
|
|
|
def test_padded_body_helper_names_the_route_and_the_recovery():
|
|
from unsloth_cli._inference import require_completed_padded_body
|
|
url = "http://x/api/inference/load"
|
|
for body in (None, {}, [], "", 0, "loaded"):
|
|
with pytest.raises(RuntimeError) as excinfo:
|
|
require_completed_padded_body(url, body)
|
|
message = str(excinfo.value)
|
|
assert message.startswith(f"{url} did not report completion")
|
|
assert "Check the model's status" in message
|
|
|
|
|
|
def test_deferred_error_helper_passes_a_normal_body_through():
|
|
from unsloth_cli._inference import raise_for_deferred_error
|
|
|
|
body = {"status": "loaded", "model": "org/model-GGUF"}
|
|
assert raise_for_deferred_error("http://x/api/inference/load", body) is body
|
|
# Not a dict, and a look-alike that is not the documented shape, both pass.
|
|
assert raise_for_deferred_error("http://x", [1, 2]) == [1, 2]
|
|
assert raise_for_deferred_error("http://x", {"_deferred_error": None}) == {
|
|
"_deferred_error": None
|
|
}
|
|
|
|
|
|
def test_deferred_error_helper_reads_like_a_real_error_response():
|
|
"""Callers recover the detail with exc.read(), exactly as for a real 5xx."""
|
|
import urllib.error
|
|
|
|
from unsloth_cli._inference import raise_for_deferred_error
|
|
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
raise_for_deferred_error(
|
|
"http://x/api/inference/load",
|
|
{"_deferred_error": {"status_code": 500, "detail": "llama-server died"}},
|
|
)
|
|
exc = excinfo.value
|
|
assert exc.code == 500
|
|
assert json.loads(exc.read().decode()) == {"detail": "llama-server died"}
|
|
assert "llama-server died" in str(exc)
|
|
|
|
|
|
def test_deferred_error_helper_defaults_a_missing_status():
|
|
import urllib.error
|
|
|
|
from unsloth_cli._inference import raise_for_deferred_error
|
|
|
|
with pytest.raises(urllib.error.HTTPError) as excinfo:
|
|
raise_for_deferred_error("http://x", {"_deferred_error": {}})
|
|
assert excinfo.value.code == 500
|
|
|
|
|
|
def _stub_studio_gguf_load(monkeypatch):
|
|
"""Stand in for the studio backend `_load_gguf_backend` imports in-venv, and
|
|
return the list the intents it builds land in."""
|
|
import unsloth_cli._inference as inference
|
|
|
|
calls = []
|
|
|
|
class _FakeLlamaCppBackend:
|
|
def load_model(self, intent):
|
|
calls.append(intent)
|
|
return True
|
|
|
|
fake_llama_cpp = types.ModuleType("core.inference.llama_cpp")
|
|
fake_llama_cpp.GgufLoadIntent = lambda **kwargs: SimpleNamespace(**kwargs)
|
|
fake_llama_cpp.LlamaCppBackend = _FakeLlamaCppBackend
|
|
fake_args = types.ModuleType("core.inference.llama_server_args")
|
|
fake_args.validate_extra_args = lambda args: list(args or [])
|
|
fake_tensor_fallback = types.ModuleType("core.inference.tensor_fallback")
|
|
|
|
async def _passthrough(
|
|
attempt_load,
|
|
*,
|
|
requested_tensor,
|
|
extra_args,
|
|
label = "",
|
|
cancelled = None,
|
|
):
|
|
return await attempt_load(requested_tensor, extra_args)
|
|
|
|
fake_tensor_fallback.load_with_tensor_fallback = _passthrough
|
|
|
|
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
|
|
monkeypatch.setitem(sys.modules, "core.inference", types.ModuleType("core.inference"))
|
|
monkeypatch.setitem(sys.modules, "core.inference.llama_cpp", fake_llama_cpp)
|
|
monkeypatch.setitem(sys.modules, "core.inference.llama_server_args", fake_args)
|
|
monkeypatch.setitem(sys.modules, "core.inference.tensor_fallback", fake_tensor_fallback)
|
|
monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
|
|
return calls
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "expected_source"),
|
|
[
|
|
(
|
|
{"gguf_hf_repo": "org/model-GGUF"},
|
|
{"hf_repo": "org/model-GGUF", "hf_token": "hf_x"},
|
|
),
|
|
(
|
|
{
|
|
"gguf_hf_repo": None,
|
|
"gguf_file": "/models/model.gguf",
|
|
"gguf_mmproj_file": "/models/mmproj.gguf",
|
|
"gguf_mtp_file": "/models/mtp.gguf",
|
|
"gguf_dspark_file": "/models/dspark-model.gguf",
|
|
"gguf_dflash_file": "/models/dflash-kquant.gguf",
|
|
},
|
|
{
|
|
"gguf_path": "/models/model.gguf",
|
|
"mmproj_path": "/models/mmproj.gguf",
|
|
"mtp_draft_path": "/models/mtp.gguf",
|
|
"dspark_draft_path": "/models/dspark-model.gguf",
|
|
"dflash_draft_path": "/models/dflash-kquant.gguf",
|
|
},
|
|
),
|
|
],
|
|
ids = ("hugging-face", "local"),
|
|
)
|
|
def test_load_gguf_backend_forwards_source_and_runtime_options(
|
|
monkeypatch, source, expected_source
|
|
):
|
|
import unsloth_cli._inference as inference
|
|
|
|
calls = _stub_studio_gguf_load(monkeypatch)
|
|
|
|
config = SimpleNamespace(
|
|
gguf_variant = "Q4_K_M",
|
|
identifier = "org/model-GGUF",
|
|
is_vision = False,
|
|
**source,
|
|
)
|
|
|
|
backend = inference._load_gguf_backend(
|
|
config,
|
|
hf_token = "hf_x",
|
|
max_seq_length = 8192,
|
|
tensor_parallel = True,
|
|
speculative_type = "dspark",
|
|
spec_draft_n_max = 3,
|
|
llama_extra_args = ["--top-k", "20"],
|
|
)
|
|
|
|
assert isinstance(backend, ChatBackend)
|
|
assert [vars(intent) for intent in calls] == [
|
|
{
|
|
"hf_variant": "Q4_K_M",
|
|
"model_identifier": "org/model-GGUF",
|
|
"is_vision": False,
|
|
"n_ctx": 8192,
|
|
"speculative_type": "dspark",
|
|
"spec_draft_n_max": 3,
|
|
"tensor_parallel": True,
|
|
"extra_args": ["--top-k", "20"],
|
|
**expected_source,
|
|
}
|
|
]
|
|
|
|
|
|
def test_load_gguf_backend_hands_a_local_dflash_sidecar_to_the_load(monkeypatch):
|
|
"""The managed CLI resolves the sidecar next to a local weight exactly as Studio
|
|
does, and dropping it here is silent: the load simply comes up with no drafter and
|
|
nothing says the sidecar sitting beside the model was ever found."""
|
|
import unsloth_cli._inference as inference
|
|
|
|
calls = _stub_studio_gguf_load(monkeypatch)
|
|
config = SimpleNamespace(
|
|
gguf_variant = "Q4_K_M",
|
|
identifier = "org/model-GGUF",
|
|
is_vision = False,
|
|
gguf_hf_repo = None,
|
|
gguf_file = "/models/model.gguf",
|
|
gguf_mmproj_file = None,
|
|
gguf_mtp_file = None,
|
|
gguf_dspark_file = None,
|
|
gguf_dflash_file = "/models/dflash-kquant.gguf",
|
|
)
|
|
|
|
inference._load_gguf_backend(config, hf_token = None, max_seq_length = 8192)
|
|
|
|
assert [intent.dflash_draft_path for intent in calls] == ["/models/dflash-kquant.gguf"]
|
|
|
|
|
|
def test_load_gguf_backend_exits_cleanly_on_invalid_extra_args(monkeypatch):
|
|
import unsloth_cli._inference as inference
|
|
|
|
fake_llama_cpp = types.ModuleType("core.inference.llama_cpp")
|
|
fake_llama_cpp.GgufLoadIntent = object
|
|
fake_llama_cpp.LlamaCppBackend = object
|
|
fake_args = types.ModuleType("core.inference.llama_server_args")
|
|
|
|
def _raise(_args):
|
|
raise ValueError("llama-server flag '--model' is managed by Unsloth Studio")
|
|
|
|
fake_args.validate_extra_args = _raise
|
|
fake_tensor_fallback = types.ModuleType("core.inference.tensor_fallback")
|
|
fake_tensor_fallback.load_with_tensor_fallback = None
|
|
|
|
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
|
|
monkeypatch.setitem(sys.modules, "core.inference", types.ModuleType("core.inference"))
|
|
monkeypatch.setitem(sys.modules, "core.inference.llama_cpp", fake_llama_cpp)
|
|
monkeypatch.setitem(sys.modules, "core.inference.llama_server_args", fake_args)
|
|
monkeypatch.setitem(sys.modules, "core.inference.tensor_fallback", fake_tensor_fallback)
|
|
monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
|
|
|
|
config = SimpleNamespace(
|
|
gguf_variant = "Q4_K_M",
|
|
identifier = "org/model-GGUF",
|
|
is_vision = False,
|
|
gguf_hf_repo = "org/model-GGUF",
|
|
)
|
|
|
|
with pytest.raises(typer.Exit) as excinfo:
|
|
inference._load_gguf_backend(
|
|
config,
|
|
hf_token = "hf_x",
|
|
max_seq_length = 8192,
|
|
llama_extra_args = ["--model"],
|
|
)
|
|
|
|
assert excinfo.value.exit_code == 1
|
|
|
|
|
|
def test_load_gguf_backend_uses_tensor_fallback(monkeypatch):
|
|
import unsloth_cli._inference as inference
|
|
|
|
calls = []
|
|
fallback_calls = []
|
|
|
|
class _FakeLlamaCppBackend:
|
|
def load_model(self, intent):
|
|
calls.append(intent)
|
|
return intent.tensor_parallel is False
|
|
|
|
fake_llama_cpp = types.ModuleType("core.inference.llama_cpp")
|
|
fake_llama_cpp.GgufLoadIntent = lambda **kwargs: SimpleNamespace(**kwargs)
|
|
fake_llama_cpp.LlamaCppBackend = _FakeLlamaCppBackend
|
|
fake_args = types.ModuleType("core.inference.llama_server_args")
|
|
fake_args.validate_extra_args = lambda args: list(args or [])
|
|
fake_tensor_fallback = types.ModuleType("core.inference.tensor_fallback")
|
|
|
|
async def _fallback(
|
|
attempt_load,
|
|
*,
|
|
requested_tensor,
|
|
extra_args,
|
|
label = "",
|
|
cancelled = None,
|
|
):
|
|
fallback_calls.append((requested_tensor, extra_args, label))
|
|
ok = await attempt_load(requested_tensor, extra_args)
|
|
if ok:
|
|
return True
|
|
return await attempt_load(False, ["--split-mode", "layer"])
|
|
|
|
fake_tensor_fallback.load_with_tensor_fallback = _fallback
|
|
|
|
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
|
|
monkeypatch.setitem(sys.modules, "core.inference", types.ModuleType("core.inference"))
|
|
monkeypatch.setitem(sys.modules, "core.inference.llama_cpp", fake_llama_cpp)
|
|
monkeypatch.setitem(sys.modules, "core.inference.llama_server_args", fake_args)
|
|
monkeypatch.setitem(sys.modules, "core.inference.tensor_fallback", fake_tensor_fallback)
|
|
monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
|
|
|
|
config = SimpleNamespace(
|
|
gguf_variant = "Q4_K_M",
|
|
identifier = "org/model-GGUF",
|
|
is_vision = False,
|
|
gguf_hf_repo = "org/model-GGUF",
|
|
)
|
|
|
|
backend = inference._load_gguf_backend(
|
|
config,
|
|
hf_token = "hf_x",
|
|
max_seq_length = 8192,
|
|
tensor_parallel = True,
|
|
)
|
|
|
|
assert isinstance(backend, ChatBackend)
|
|
assert fallback_calls == [(True, [], "org/model-GGUF")]
|
|
assert [intent.tensor_parallel for intent in calls] == [True, False]
|
|
assert calls[1].extra_args == ["--split-mode", "layer"]
|
|
|
|
|
|
def test_http_backend_merges_emoji_split_across_deltas(monkeypatch):
|
|
backend = HttpChatBackend("http://localhost:8888", "token")
|
|
response = _FakeSSEResponse(
|
|
[
|
|
b'data: {"choices":[{"delta":{"content":"hi "}}]}\n',
|
|
b'data: {"choices":[{"delta":{"content":"\\ud83d"}}]}\n',
|
|
b'data: {"choices":[{"delta":{"content":"\\ude0a"}}]}\n',
|
|
b"data: [DONE]\n",
|
|
]
|
|
)
|
|
monkeypatch.setattr(backend, "_request", lambda *a, **k: response)
|
|
|
|
out = list(backend.stream([{"role": "user", "content": "hi"}], **_STREAM_KWARGS))
|
|
# The lone high surrogate is held back, then merged with its other half.
|
|
assert out == ["hi ", "hi ", "hi 😊"]
|
|
|
|
|
|
def test_chat_prefers_running_studio_server(monkeypatch):
|
|
closed = []
|
|
|
|
class _FakeHttpBackend:
|
|
def stream(self, *a, **k):
|
|
return iter(["hello"])
|
|
|
|
def close(self):
|
|
closed.append("http")
|
|
|
|
local_loads = []
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(chatmod, "connect_studio_server", lambda *a, **k: _FakeHttpBackend())
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: local_loads.append(1))
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
|
|
result = CliRunner().invoke(_chat_app(), ["fake-model"], input = "hi\n/exit\n")
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert local_loads == []
|
|
assert "stays warm" in result.output
|
|
assert closed == ["http"]
|
|
|
|
|
|
def test_chat_forwards_gguf_runtime_options_to_loader(monkeypatch):
|
|
loads = []
|
|
|
|
class _FakeHttpBackend:
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(
|
|
chatmod,
|
|
"connect_studio_server",
|
|
lambda model, **kwargs: (loads.append((model, kwargs)), _FakeHttpBackend())[1],
|
|
)
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: None)
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
|
|
result = CliRunner().invoke(
|
|
_chat_app(),
|
|
[
|
|
"fake-model",
|
|
"--tensor-parallel",
|
|
"--speculative-type",
|
|
"dspark",
|
|
"--spec-draft-n-max",
|
|
"3",
|
|
"--llama-extra-arg=--top-k",
|
|
"--llama-extra-arg",
|
|
"20",
|
|
],
|
|
input = "/exit\n",
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert loads == [
|
|
(
|
|
"fake-model",
|
|
{
|
|
"hf_token": None,
|
|
"max_seq_length": 4096,
|
|
"load_in_4bit": True,
|
|
"tensor_parallel": True,
|
|
"speculative_type": "dspark",
|
|
"spec_draft_n_max": 3,
|
|
"llama_extra_args": ["--top-k", "20"],
|
|
},
|
|
)
|
|
]
|
|
|
|
|
|
def test_inference_forwards_gguf_runtime_options_to_loader(monkeypatch):
|
|
from unsloth_cli.commands import inference as infermod
|
|
|
|
loads, streams, closed = [], [], []
|
|
|
|
class _FakeBackend:
|
|
def stream(self, messages, **kwargs):
|
|
streams.append((messages, kwargs))
|
|
return iter(["answer"])
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(
|
|
infermod,
|
|
"connect_studio_server",
|
|
lambda model, **kwargs: (loads.append((model, kwargs)), _FakeBackend())[1],
|
|
)
|
|
monkeypatch.setattr(infermod, "load_chat_backend", lambda *a, **k: None)
|
|
|
|
result = CliRunner().invoke(
|
|
_inference_app(),
|
|
[
|
|
"fake-model",
|
|
"hello",
|
|
"--tensor-parallel",
|
|
"--speculative-type",
|
|
"dspark",
|
|
"--spec-draft-n-max",
|
|
"3",
|
|
"--llama-extra-arg=--top-k",
|
|
"--llama-extra-arg",
|
|
"20",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert loads == [
|
|
(
|
|
"fake-model",
|
|
{
|
|
"hf_token": None,
|
|
"max_seq_length": 2048,
|
|
"load_in_4bit": True,
|
|
"tensor_parallel": True,
|
|
"speculative_type": "dspark",
|
|
"spec_draft_n_max": 3,
|
|
"llama_extra_args": ["--top-k", "20"],
|
|
},
|
|
)
|
|
]
|
|
assert streams[0][0] == [{"role": "user", "content": "hello"}]
|
|
assert closed == [True]
|
|
|
|
|
|
def test_chat_server_mode_compare_loads_base_locally(monkeypatch):
|
|
streamed, closed, base_loads = [], [], []
|
|
|
|
class _FakeHttpBackend:
|
|
def stream(self, *a, **k):
|
|
streamed.append("tuned")
|
|
return iter(["tuned-answer"])
|
|
|
|
def close(self):
|
|
closed.append("http")
|
|
|
|
class _FakeBaseBackend:
|
|
def stream(self, *a, **k):
|
|
streamed.append("base")
|
|
return iter(["base-answer"])
|
|
|
|
def close(self):
|
|
closed.append("base")
|
|
|
|
def fake_local_load(model, **kwargs):
|
|
base_loads.append((model, kwargs.get("fresh_backend", False)))
|
|
return _FakeBaseBackend()
|
|
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(chatmod, "connect_studio_server", lambda *a, **k: _FakeHttpBackend())
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", fake_local_load)
|
|
|
|
result = CliRunner().invoke(_chat_app(), ["tuned-run"], input = "/compare\nhi\n/exit\n")
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "(compare on)" in result.output
|
|
assert base_loads == [("fake/base", True)]
|
|
assert streamed == ["base", "tuned"]
|
|
assert set(closed) == {"http", "base"}
|
|
|
|
|
|
def test_chat_compare_on_mlx_loads_base_model_side_by_side(monkeypatch):
|
|
loads, streamed, closed = [], [], []
|
|
|
|
class _FakeLocalBackend:
|
|
def __init__(self, role):
|
|
self.role = role
|
|
|
|
def stream(self, *a, **k):
|
|
streamed.append((self.role, k.get("use_adapter")))
|
|
return iter([f"{self.role}-answer"])
|
|
|
|
def close(self):
|
|
closed.append(self.role)
|
|
|
|
def fake_load(model, **kwargs):
|
|
fresh = kwargs.get("fresh_backend", False)
|
|
loads.append((model, fresh))
|
|
return _FakeLocalBackend("base" if fresh else "tuned")
|
|
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", fake_load)
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: True)
|
|
monkeypatch.setattr(chatmod, "connect_studio_server", lambda *a, **k: None)
|
|
|
|
result = CliRunner().invoke(_chat_app(), ["tuned-run", "--compare"], input = "hi\n/exit\n")
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert loads == [("tuned-run", False), ("fake/base", True)]
|
|
assert ("base", None) in streamed and ("tuned", None) in streamed
|
|
assert set(closed) == {"tuned", "base"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("chunk_kind", "expected_exit"),
|
|
[
|
|
("answer", 0),
|
|
("model_text_error", 0),
|
|
("real_error", 1),
|
|
],
|
|
)
|
|
def test_inference_local_handles_stream(monkeypatch, chunk_kind, expected_exit):
|
|
from unsloth_cli.commands import inference as infermod
|
|
from unsloth_cli._inference import ensure_studio_backend_path
|
|
|
|
ensure_studio_backend_path()
|
|
from core.inference.orchestrator import GenStreamError
|
|
|
|
chunks = {
|
|
"answer": ["answer"],
|
|
"model_text_error": ["Error: printed by the model, not a backend failure"],
|
|
"real_error": [GenStreamError("Error: generation failed")],
|
|
}[chunk_kind]
|
|
closed = []
|
|
|
|
class _FakeBackend:
|
|
def stream(self, messages, **kwargs):
|
|
return iter(chunks)
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(
|
|
infermod,
|
|
"connect_studio_server",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("server disabled")),
|
|
)
|
|
monkeypatch.setattr(infermod, "load_chat_backend", lambda *a, **k: _FakeBackend())
|
|
|
|
result = CliRunner().invoke(
|
|
_inference_app(),
|
|
["fake-model", "hello", "--no-server"],
|
|
)
|
|
|
|
assert result.exit_code == expected_exit, result.output
|
|
assert closed == [True]
|
|
if chunk_kind == "real_error":
|
|
assert result.stdout == "Assistant:\n"
|
|
assert result.stderr == "Error: generation failed\n"
|
|
else:
|
|
assert chunks[0] in result.output
|
|
|
|
|
|
@pytest.mark.parametrize("chunk_kind", ["answer", "model_text_error", "real_error"])
|
|
def test_chat_local_handles_stream(monkeypatch, chunk_kind):
|
|
from unsloth_cli._inference import ensure_studio_backend_path
|
|
|
|
ensure_studio_backend_path()
|
|
from core.inference.orchestrator import GenStreamError
|
|
|
|
first_chunk = {
|
|
"answer": "answer",
|
|
"model_text_error": "Error: printed by the model, not a backend failure",
|
|
"real_error": GenStreamError("Error: generation failed"),
|
|
}[chunk_kind]
|
|
calls, closed = [], []
|
|
|
|
class _FakeChatBackend:
|
|
def stream(self, messages, **kwargs):
|
|
calls.append([dict(message) for message in messages])
|
|
return iter([first_chunk if len(calls) == 1 else "second answer"])
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(chatmod, "connect_studio_server", lambda *a, **k: None)
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: _FakeChatBackend())
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
|
|
result = CliRunner().invoke(
|
|
_chat_app(),
|
|
["fake-model"],
|
|
input = "first\nsecond\n/exit\n",
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert closed == [True]
|
|
if chunk_kind == "real_error":
|
|
assert calls[1] == [{"role": "user", "content": "second"}]
|
|
assert "(error: generation failed)" in result.output
|
|
assert "Error: generation failed" not in result.output
|
|
else:
|
|
assert calls[1] == [
|
|
{"role": "user", "content": "first"},
|
|
{"role": "assistant", "content": first_chunk},
|
|
{"role": "user", "content": "second"},
|
|
]
|
|
assert first_chunk in result.output
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("chunk_kind", "expected_exit"),
|
|
[
|
|
("answer", 0),
|
|
("model_text_error", 0),
|
|
("real_error", 1),
|
|
],
|
|
)
|
|
def test_inference_under_mlx_launch_handles_stream(monkeypatch, chunk_kind, expected_exit):
|
|
from unsloth_cli.commands import inference as infermod
|
|
from unsloth_cli._inference import ensure_studio_backend_path
|
|
|
|
ensure_studio_backend_path()
|
|
from core.inference.orchestrator import GenStreamError
|
|
|
|
if chunk_kind == "answer":
|
|
chunks = ["answer"]
|
|
elif chunk_kind == "model_text_error":
|
|
# Model output whose visible text starts with "Error:" must not abort.
|
|
chunks = ["Error: printed by the model, not a backend failure"]
|
|
else:
|
|
chunks = [GenStreamError("Error: generation failed")]
|
|
|
|
loads, closed = [], []
|
|
|
|
class _FakeBackend:
|
|
def stream(self, messages, **kwargs):
|
|
return iter(chunks)
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
_set_mlx_nccl_env(monkeypatch, rank = "0")
|
|
monkeypatch.setattr(
|
|
infermod,
|
|
"connect_studio_server",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("server disabled")),
|
|
)
|
|
monkeypatch.setattr(
|
|
infermod,
|
|
"load_chat_backend",
|
|
lambda model, **kwargs: (loads.append((model, kwargs)), _FakeBackend())[1],
|
|
)
|
|
|
|
result = CliRunner().invoke(
|
|
_inference_app(),
|
|
["fake-model", "hello", "--tensor-parallel"],
|
|
)
|
|
|
|
assert result.exit_code == expected_exit, result.output
|
|
assert loads[0][1]["tensor_parallel"] is True
|
|
if chunk_kind == "real_error":
|
|
assert "generation failed" in result.output
|
|
|
|
|
|
def test_chat_under_mlx_launch_nonzero_rank_drains_stdin(monkeypatch):
|
|
drains, closed = [], []
|
|
turns = iter(
|
|
[
|
|
{"type": "turn", "text": "hi"},
|
|
{"type": "turn", "text": "/exit"},
|
|
]
|
|
)
|
|
|
|
class _FakeChatBackend:
|
|
def share_distributed_object(
|
|
self,
|
|
obj,
|
|
*,
|
|
timeout = 300.0,
|
|
):
|
|
assert obj is None
|
|
return next(turns)
|
|
|
|
def stream(self, messages, **kwargs):
|
|
return iter(["hidden"])
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
_set_mlx_nccl_env(monkeypatch, rank = "1")
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(
|
|
chatmod,
|
|
"connect_studio_server",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("server disabled")),
|
|
)
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: _FakeChatBackend())
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
monkeypatch.setattr(chatmod, "_drain_available_stdin", lambda: drains.append(True))
|
|
|
|
result = CliRunner().invoke(_chat_app(), ["fake-model"], input = "hi\n/exit\n")
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Chatting with" not in result.output
|
|
assert drains == [True, True]
|
|
assert closed == [True]
|
|
|
|
|
|
def test_chat_under_mlx_launch_rank0_bypasses_studio_and_prints(monkeypatch):
|
|
loads, shares, closed = [], [], []
|
|
|
|
class _FakeChatBackend:
|
|
def share_distributed_object(
|
|
self,
|
|
obj,
|
|
*,
|
|
timeout = 300.0,
|
|
):
|
|
shares.append((obj, timeout))
|
|
return obj
|
|
|
|
def stream(self, messages, **kwargs):
|
|
return iter(["hello"])
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
_set_mlx_nccl_env(monkeypatch, rank = "0")
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(
|
|
chatmod,
|
|
"connect_studio_server",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("server disabled")),
|
|
)
|
|
monkeypatch.setattr(
|
|
chatmod,
|
|
"load_chat_backend",
|
|
lambda model, **kwargs: (loads.append((model, kwargs)), _FakeChatBackend())[1],
|
|
)
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
|
|
result = CliRunner().invoke(
|
|
_chat_app(),
|
|
["fake-model", "--tensor-parallel"],
|
|
input = "hi\n/exit\n",
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Chatting with fake-model" in result.output
|
|
assert "hello" in result.output
|
|
assert loads and loads[0][0] == "fake-model"
|
|
assert loads[0][1]["tensor_parallel"] is True
|
|
assert shares == [
|
|
({"type": "turn", "text": "hi"}, None),
|
|
({"type": "turn", "text": "/exit"}, None),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("stream_error", "expected_exit"),
|
|
[("exception", 1), ("chunk", 1), ("model_text", 0)],
|
|
)
|
|
def test_chat_under_mlx_launch_exits_on_generation_error(monkeypatch, stream_error, expected_exit):
|
|
from unsloth_cli._inference import ensure_studio_backend_path
|
|
|
|
ensure_studio_backend_path()
|
|
from core.inference.orchestrator import GenStreamError
|
|
|
|
closed = []
|
|
|
|
class _FakeChatBackend:
|
|
def share_distributed_object(
|
|
self,
|
|
obj,
|
|
*,
|
|
timeout = 300.0,
|
|
):
|
|
return obj
|
|
|
|
def stream(self, messages, **kwargs):
|
|
if stream_error == "exception":
|
|
raise RuntimeError("generation failed")
|
|
if stream_error == "model_text":
|
|
# Plain model text starting with "Error:" must not abort the run.
|
|
return iter(["Error: printed by the model"])
|
|
return iter([GenStreamError("Error: generation failed")])
|
|
|
|
def close(self):
|
|
closed.append(True)
|
|
|
|
_set_mlx_nccl_env(monkeypatch, rank = "0")
|
|
monkeypatch.setattr(chatmod, "resolve_model_config", lambda *a, **k: _FakeConfig())
|
|
monkeypatch.setattr(
|
|
chatmod,
|
|
"connect_studio_server",
|
|
lambda *_a, **_k: (_ for _ in ()).throw(AssertionError("server disabled")),
|
|
)
|
|
monkeypatch.setattr(chatmod, "load_chat_backend", lambda *a, **k: _FakeChatBackend())
|
|
monkeypatch.setattr(chatmod, "_compare_needs_second_model", lambda: False)
|
|
|
|
result = CliRunner().invoke(_chat_app(), ["fake-model"], input = "hi\n/exit\n")
|
|
|
|
assert result.exit_code == expected_exit
|
|
if expected_exit:
|
|
assert "generation failed" in result.output
|
|
assert closed == [True]
|
|
|
|
|
|
def test_load_chat_backend_forwards_mlx_distributed_options(monkeypatch):
|
|
import unsloth_cli._inference as inference
|
|
|
|
calls = []
|
|
|
|
class _FakeBackend:
|
|
def load_model(self, **kwargs):
|
|
calls.append(kwargs)
|
|
return True
|
|
|
|
class _FakeModelConfig:
|
|
is_gguf = False
|
|
|
|
@classmethod
|
|
def from_identifier(cls, **_kwargs):
|
|
return cls()
|
|
|
|
fake_backend = _FakeBackend()
|
|
fake_inference = types.ModuleType("core.inference")
|
|
fake_inference.get_inference_backend = lambda: fake_backend
|
|
fake_utils = types.ModuleType("utils")
|
|
fake_utils.__path__ = []
|
|
fake_models = types.ModuleType("utils.models")
|
|
fake_models.ModelConfig = _FakeModelConfig
|
|
|
|
_set_mlx_nccl_env(monkeypatch, rank = "0")
|
|
monkeypatch.setitem(sys.modules, "core", types.ModuleType("core"))
|
|
monkeypatch.setitem(sys.modules, "core.inference", fake_inference)
|
|
monkeypatch.setitem(sys.modules, "utils", fake_utils)
|
|
monkeypatch.setitem(sys.modules, "utils.models", fake_models)
|
|
monkeypatch.setattr(inference, "ensure_studio_backend_path", lambda: None)
|
|
|
|
inference.load_chat_backend(
|
|
"fake-model",
|
|
hf_token = None,
|
|
max_seq_length = 2048,
|
|
load_in_4bit = True,
|
|
tensor_parallel = True,
|
|
)
|
|
|
|
assert calls[0]["tensor_parallel"] is True
|
|
assert calls[0]["mlx_distributed"] is True
|