e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
808 lines
27 KiB
Python
808 lines
27 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
|
|
|
|
"""Unit tests for the llama-server pass-through args validator.
|
|
|
|
The validator is the boundary between user CLI/HTTP input and the
|
|
llama-server subprocess. These tests pin denylist behaviour so it doesn't
|
|
regress when new managed flags are added.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Load llama_server_args.py directly to avoid dragging in the full backend
|
|
# chain via core/inference/__init__.py. The validator is dependency-free.
|
|
_LSA_PATH = Path(__file__).resolve().parent.parent / "core" / "inference" / "llama_server_args.py"
|
|
_spec = importlib.util.spec_from_file_location("_lsa_test_only", _LSA_PATH)
|
|
_lsa = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(_lsa)
|
|
is_managed_flag = _lsa.is_managed_flag
|
|
parse_cache_override = _lsa.parse_cache_override
|
|
parse_cache_override_per_axis = _lsa.parse_cache_override_per_axis
|
|
parse_ctx_override = _lsa.parse_ctx_override
|
|
parse_split_mode_override = _lsa.parse_split_mode_override
|
|
resolve_cache_type_kv = _lsa.resolve_cache_type_kv
|
|
resolve_tensor_parallel = _lsa.resolve_tensor_parallel
|
|
strip_shadowing_flags = _lsa.strip_shadowing_flags
|
|
strip_split_mode_only = _lsa.strip_split_mode_only
|
|
extra_args_disable_mmproj = _lsa.extra_args_disable_mmproj
|
|
validate_extra_args = _lsa.validate_extra_args
|
|
|
|
|
|
# ── Pass-through (allowed) ───────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
# Sampling
|
|
["--top-k", "20"],
|
|
["--top-p", "0.9", "--min-p", "0.05"],
|
|
["--seed", "-1"], # negative value, not a flag
|
|
["--temp", "0.0"],
|
|
["--repeat-penalty", "1.05"],
|
|
["--mirostat", "2", "--mirostat-lr", "0.1"],
|
|
["--xtc-probability", "0.05", "--xtc-threshold", "0.1"],
|
|
["--dry-multiplier", "0.5"],
|
|
# Tier-2 knobs that map to LoadRequest fields
|
|
["--cache-type-k", "q8_0"],
|
|
["--cache-type-v", "q8_0"],
|
|
["--chat-template-file", "/tmp/tpl.jinja"],
|
|
["--chat-template-kwargs", '{"reasoning_effort":"high"}'],
|
|
["--spec-type", "ngram-mod"],
|
|
["--spec-default"],
|
|
# MTP path (llama.cpp #22673).
|
|
["--spec-type", "draft-mtp"],
|
|
["--spec-type", "draft-mtp", "--spec-draft-n-max", "6"],
|
|
[
|
|
"--spec-type",
|
|
"ngram-mod,draft-mtp",
|
|
"--spec-draft-n-max",
|
|
"3",
|
|
"--spec-ngram-mod-n-match",
|
|
"24",
|
|
"--spec-ngram-mod-n-min",
|
|
"48",
|
|
"--spec-ngram-mod-n-max",
|
|
"64",
|
|
],
|
|
# Reasoning controls
|
|
["--reasoning-format", "deepseek"],
|
|
["-rea", "auto"],
|
|
# Soft-managed: user flags last-wins over Studio's auto-set version.
|
|
# --parallel / -np / --n-parallel are hard-denied (KV-cache + slot
|
|
# count would desync); use `unsloth studio run --parallel N` instead.
|
|
["-c", "131072"],
|
|
["--ctx-size", "8192"],
|
|
["--flash-attn", "off"],
|
|
["-fa", "on"],
|
|
["--no-context-shift"],
|
|
["--context-shift"],
|
|
["--jinja"],
|
|
["--no-jinja"],
|
|
["-ngl", "-1"],
|
|
["--gpu-layers", "32"],
|
|
["-t", "16"],
|
|
["--threads", "32"],
|
|
["-fit", "off"],
|
|
["--fit", "on"],
|
|
["--fit-ctx", "8192"],
|
|
],
|
|
)
|
|
def test_pass_through_allowed(args):
|
|
assert validate_extra_args(args) == args
|
|
|
|
|
|
def test_none_returns_empty_list():
|
|
assert validate_extra_args(None) == []
|
|
|
|
|
|
def test_empty_list_returns_empty_list():
|
|
assert validate_extra_args([]) == []
|
|
|
|
|
|
def test_value_with_equals_form_passes_through():
|
|
assert validate_extra_args(["--top-k=20"]) == ["--top-k=20"]
|
|
|
|
|
|
def test_non_flag_token_passes_through():
|
|
# Bare positionals are passed through; llama-server can reject them.
|
|
assert validate_extra_args(["foo"]) == ["foo"]
|
|
|
|
|
|
# ── Denylist (rejected) ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"denied",
|
|
[
|
|
# Parallel slots -- owned by the typer --parallel flag.
|
|
"-np",
|
|
"--parallel",
|
|
"--n-parallel",
|
|
# Model identity (every alias; bumping llama.cpp must keep every
|
|
# form rejected, not just the long one).
|
|
"-m",
|
|
"--model",
|
|
"-mu",
|
|
"--model-url",
|
|
"-dr",
|
|
"--docker-repo",
|
|
"-hf",
|
|
"-hfr",
|
|
"--hf-repo",
|
|
"-hff",
|
|
"--hf-file",
|
|
"-hfv",
|
|
"-hfrv",
|
|
"--hf-repo-v",
|
|
"-hffv",
|
|
"--hf-file-v",
|
|
"-hft",
|
|
"--hf-token",
|
|
"-mm",
|
|
"--mmproj",
|
|
"-mmu",
|
|
"--mmproj-url",
|
|
# Networking (Studio binds + proxies)
|
|
"--host",
|
|
"--port",
|
|
"--path",
|
|
"--api-prefix",
|
|
"--reuse-port",
|
|
# Auth / TLS
|
|
"--api-key",
|
|
"--api-key-file",
|
|
"--ssl-key-file",
|
|
"--ssl-cert-file",
|
|
# Single-model server (legacy --webui + current --ui group)
|
|
"--webui",
|
|
"--no-webui",
|
|
"--ui",
|
|
"--no-ui",
|
|
"--ui-config",
|
|
"--ui-config-file",
|
|
"--ui-mcp-proxy",
|
|
"--no-ui-mcp-proxy",
|
|
"--models-dir",
|
|
"--models-preset",
|
|
"--models-max",
|
|
"--models-autoload",
|
|
"--no-models-autoload",
|
|
# Server-mode flips: --embedding / --rerank restrict llama-server to
|
|
# those endpoints and break Studio's chat hop.
|
|
"--embedding",
|
|
"--embeddings",
|
|
"--rerank",
|
|
"--reranking",
|
|
# llama-server's own --tools clashes with Studio's tool policy.
|
|
"--tools",
|
|
],
|
|
)
|
|
def test_denylist_rejects_all_aliases(denied):
|
|
with pytest.raises(ValueError, match = denied):
|
|
validate_extra_args([denied, "value"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,offending",
|
|
[
|
|
# Pass-through --parallel would last-wins-override the real slot
|
|
# count while Studio's KV-cache fit + llama_parallel_slots stay at
|
|
# the typer value -- plan vs. process disagree.
|
|
(["--parallel", "8"], "--parallel"),
|
|
(["--parallel=8"], "--parallel"),
|
|
(["--n-parallel", "16"], "--n-parallel"),
|
|
(["--n-parallel=16"], "--n-parallel"),
|
|
(["-np", "32"], "-np"),
|
|
# Attached short form: Click clusters it CLI-side; HTTP /load with
|
|
# `["-np8"]` must still resolve to managed.
|
|
(["-np8"], "-np"),
|
|
(["-np64"], "-np"),
|
|
# Out-of-range values that would bypass the typer 1..64 guard.
|
|
(["--parallel", "999"], "--parallel"),
|
|
(["-np", "0"], "-np"),
|
|
(["-np999"], "-np"),
|
|
# Signed attached forms; `-np-1` must not slip past.
|
|
(["-np-1"], "-np"),
|
|
(["-np+1"], "-np"),
|
|
],
|
|
)
|
|
def test_parallel_flags_are_managed(args, offending):
|
|
with pytest.raises(ValueError, match = re.escape(offending)):
|
|
validate_extra_args(args)
|
|
|
|
|
|
def test_denylist_rejects_equals_form():
|
|
with pytest.raises(ValueError, match = "--port"):
|
|
validate_extra_args(["--port=9000"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"padded",
|
|
[" --parallel", "--parallel ", "\t--parallel", " -np", "-np \n", "-np\t"],
|
|
)
|
|
def test_denylist_rejects_whitespace_padded_forms(padded):
|
|
# `_flag_name` trims whitespace before lookup; else a trailing space
|
|
# could slip a managed flag past the boundary.
|
|
with pytest.raises(ValueError, match = "parallel|np"):
|
|
validate_extra_args([padded, "8"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"attached",
|
|
["-np8x", "-np-1foo", "-np+1bar", "-np9zzz"],
|
|
)
|
|
def test_denylist_rejects_np_with_digit_prefix_and_junk(attached):
|
|
# Backend `_flag_name` must classify the same forms the CLI rewriter
|
|
# expands, else HTTP /load could smuggle `-np8x` through.
|
|
with pytest.raises(ValueError, match = "np"):
|
|
validate_extra_args([attached])
|
|
|
|
|
|
def test_denylist_rejects_short_form_when_long_is_denied():
|
|
# `-m` is the short form of --model; rejecting only the long form
|
|
# would leave a trivial bypass.
|
|
with pytest.raises(ValueError, match = "-m"):
|
|
validate_extra_args(["-m", "/some/other/path.gguf"])
|
|
|
|
|
|
def test_denylist_message_names_offending_flag():
|
|
with pytest.raises(ValueError) as excinfo:
|
|
validate_extra_args(["--top-k", "20", "--api-key", "secret"])
|
|
assert "--api-key" in str(excinfo.value)
|
|
|
|
|
|
def test_first_denied_flag_short_circuits():
|
|
# Validation stops at the first denied flag; the message names it.
|
|
with pytest.raises(ValueError, match = "--port"):
|
|
validate_extra_args(["--port", "1", "--host", "x"])
|
|
|
|
|
|
# ── Numeric values that look flag-ish ─────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["-1", "-0.5", "-42", "-.5"])
|
|
def test_negative_number_value_is_not_flag(value):
|
|
# `--seed -1`: the -1 is a value, not a flag.
|
|
assert validate_extra_args(["--seed", value]) == ["--seed", value]
|
|
|
|
|
|
# ── is_managed_flag helper ───────────────────────────────────────────
|
|
|
|
|
|
def test_is_managed_flag_true_for_denied():
|
|
assert is_managed_flag("--port") is True
|
|
assert is_managed_flag("--api-key") is True
|
|
assert is_managed_flag("-m") is True
|
|
assert is_managed_flag("--model") is True
|
|
# Parallel slots owned by the typer --parallel flag.
|
|
assert is_managed_flag("--parallel") is True
|
|
assert is_managed_flag("--n-parallel") is True
|
|
assert is_managed_flag("-np") is True
|
|
# Normalised forms must classify like the canonical token so
|
|
# is_managed_flag filtering stays in sync with validate_extra_args.
|
|
assert is_managed_flag("-np8") is True
|
|
assert is_managed_flag("--parallel=8") is True
|
|
assert is_managed_flag("--port=9000") is True
|
|
|
|
|
|
def test_is_managed_flag_false_for_pass_through():
|
|
assert is_managed_flag("--top-k") is False
|
|
assert is_managed_flag("--cache-type-k") is False
|
|
assert is_managed_flag("--chat-template-file") is False
|
|
# Soft-managed flags pass through (last-wins override)
|
|
assert is_managed_flag("-c") is False
|
|
assert is_managed_flag("--ctx-size") is False
|
|
assert is_managed_flag("--flash-attn") is False
|
|
assert is_managed_flag("-ngl") is False
|
|
assert is_managed_flag("--threads") is False
|
|
|
|
|
|
# ── strip_shadowing_flags ─────────────────────────────────────────────
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_context_when_requested():
|
|
out = strip_shadowing_flags(
|
|
["-c", "4096", "--top-k", "20"],
|
|
strip_context = True,
|
|
strip_cache = False,
|
|
strip_spec = False,
|
|
strip_template = False,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_context_when_not_requested():
|
|
out = strip_shadowing_flags(
|
|
["-c", "4096", "--top-k", "20"],
|
|
strip_context = False,
|
|
strip_cache = False,
|
|
strip_spec = False,
|
|
strip_template = False,
|
|
)
|
|
assert out == ["-c", "4096", "--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_chat_template_when_template_disabled():
|
|
# No chat_template_override supplied; inherited
|
|
# --chat-template-file must survive.
|
|
out = strip_shadowing_flags(
|
|
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
|
|
strip_context = True,
|
|
strip_cache = True,
|
|
strip_spec = True,
|
|
strip_template = False,
|
|
)
|
|
assert out == ["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_template_when_requested():
|
|
out = strip_shadowing_flags(
|
|
["--chat-template-file", "/tmp/custom.jinja", "--top-k", "20"],
|
|
strip_template = True,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_cache_when_cache_disabled():
|
|
out = strip_shadowing_flags(
|
|
["--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--top-k", "20"],
|
|
strip_cache = False,
|
|
)
|
|
assert out == ["--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_spec_when_spec_disabled():
|
|
out = strip_shadowing_flags(
|
|
["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"],
|
|
strip_spec = False,
|
|
)
|
|
assert out == ["--spec-type", "ngram-mod", "--draft-min", "48", "--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_mtp_flags_when_requested():
|
|
# MTP / draft-mtp flags must drop when speculative_type re-applies.
|
|
out = strip_shadowing_flags(
|
|
[
|
|
"--spec-type",
|
|
"draft-mtp",
|
|
"--spec-draft-n-max",
|
|
"6",
|
|
"--spec-ngram-mod-n-match",
|
|
"24",
|
|
"--spec-ngram-mod-n-min",
|
|
"48",
|
|
"--spec-ngram-mod-n-max",
|
|
"6",
|
|
"--top-k",
|
|
"20",
|
|
],
|
|
strip_spec = True,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
def test_is_managed_flag_false_for_mtp_pass_through():
|
|
assert is_managed_flag("--spec-draft-n-max") is False
|
|
assert is_managed_flag("--spec-ngram-mod-n-match") is False
|
|
assert is_managed_flag("--spec-ngram-mod-n-min") is False
|
|
assert is_managed_flag("--spec-ngram-mod-n-max") is False
|
|
|
|
|
|
# ── parse_ctx_override ───────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,expected",
|
|
[
|
|
(None, None),
|
|
([], None),
|
|
(["--top-k", "20"], None),
|
|
(["--ctx-size", "128000"], 128000),
|
|
(["--ctx-size=128000"], 128000),
|
|
(["-c", "128000"], 128000),
|
|
(["-c=128000"], 128000),
|
|
(["-c", "4096", "--ctx-size", "128000"], 128000),
|
|
],
|
|
)
|
|
def test_parse_ctx_override(args, expected):
|
|
assert parse_ctx_override(args) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["--ctx-size"],
|
|
["--ctx-size", "--top-k"],
|
|
["--ctx-size", "abc"],
|
|
["--ctx-size=abc"],
|
|
["-c", "-1"],
|
|
],
|
|
)
|
|
def test_parse_ctx_override_rejects_malformed_values(args):
|
|
with pytest.raises(ValueError, match = "ctx-size|'-c'"):
|
|
parse_ctx_override(args)
|
|
|
|
|
|
def test_validate_extra_args_rejects_malformed_ctx_override():
|
|
with pytest.raises(ValueError, match = "ctx-size"):
|
|
validate_extra_args(["--ctx-size", "abc"])
|
|
|
|
|
|
# ── parse_cache_override ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,expected",
|
|
[
|
|
(None, None),
|
|
([], None),
|
|
(["--top-k", "20"], None),
|
|
(["--cache-type-k", "q8_0"], "q8_0"),
|
|
(["-ctk", "q4_0"], "q4_0"),
|
|
(["-ctv", "q4_0"], "q4_0"),
|
|
(["--cache-type-k=q4_0"], "q4_0"),
|
|
(["-ctk", "f16", "-ctk", "q8_0"], "q8_0"),
|
|
],
|
|
)
|
|
def test_parse_cache_override(args, expected):
|
|
assert parse_cache_override(args) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["-ctk"],
|
|
["-ctk", "-c", "4096"],
|
|
],
|
|
)
|
|
def test_parse_cache_override_rejects_malformed_values(args):
|
|
with pytest.raises(ValueError, match = "cache-type|'-ctk'"):
|
|
parse_cache_override(args)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args, expected",
|
|
[
|
|
(["--cache-type-k", "f32", "--cache-type-v", "f16"], ("f32", "f16")),
|
|
(["-ctk", "q8_0", "-ctv", "q4_0"], ("q8_0", "q4_0")),
|
|
(["--cache-type-k=f32"], ("f32", None)),
|
|
(["--cache-type-v", "f16"], (None, "f16")),
|
|
(["-c", "4096"], (None, None)),
|
|
(None, (None, None)),
|
|
# Last-wins is kept per axis.
|
|
(["-ctk", "f16", "-ctk", "f32"], ("f32", None)),
|
|
],
|
|
)
|
|
def test_parse_cache_override_per_axis(args, expected):
|
|
# Unlike parse_cache_override (collapses both axes to one last-wins value),
|
|
# this keeps K and V apart so an asymmetric cache can be budgeted per axis.
|
|
assert parse_cache_override_per_axis(args) == expected
|
|
|
|
|
|
def test_resolve_cache_type_kv_uses_override_when_present():
|
|
assert resolve_cache_type_kv(["--cache-type-k", "q8_0"], "f16") == "q8_0"
|
|
|
|
|
|
def test_resolve_cache_type_kv_uses_fallback_without_override():
|
|
assert resolve_cache_type_kv(["--top-k", "20"], "f16") == "f16"
|
|
|
|
|
|
def test_strip_shadowing_flags_boolean_does_not_consume_next_token():
|
|
# `--spec-default` is boolean; drop just the flag, keep the next token.
|
|
out = strip_shadowing_flags(["--spec-default", "ngram-mod"], strip_spec = True)
|
|
assert out == ["ngram-mod"]
|
|
|
|
|
|
def test_strip_shadowing_flags_jinja_boolean_preserves_positional():
|
|
out = strip_shadowing_flags(["--jinja", "trailing-positional"], strip_template = True)
|
|
assert out == ["trailing-positional"]
|
|
|
|
|
|
def test_strip_shadowing_flags_no_jinja_boolean_preserves_positional():
|
|
out = strip_shadowing_flags(["--no-jinja", "trailing-positional"], strip_template = True)
|
|
assert out == ["trailing-positional"]
|
|
|
|
|
|
def test_strip_shadowing_flags_equals_form_drops_only_the_flag():
|
|
out = strip_shadowing_flags(["--ctx-size=4096", "--seed", "-1"], strip_context = True)
|
|
assert out == ["--seed", "-1"]
|
|
|
|
|
|
def test_strip_shadowing_flags_handles_none_input():
|
|
assert strip_shadowing_flags(None) == []
|
|
|
|
|
|
def test_strip_shadowing_flags_handles_empty_input():
|
|
assert strip_shadowing_flags([]) == []
|
|
|
|
|
|
def test_strip_shadowing_flags_defaults_strip_everything():
|
|
# The route's already-loaded comparator calls with no kwargs to
|
|
# detect ANY shadowing flag in stored extras.
|
|
out = strip_shadowing_flags(
|
|
["-c", "4096", "--cache-type-k", "q8_0", "--spec-default", "--jinja"]
|
|
)
|
|
assert out == []
|
|
|
|
|
|
# ── --split-mode (Tensor Parallelism toggle) ─────────────────────────
|
|
# Soft-shadowed exactly like --cache-type-*: pass-through allowed (keeps
|
|
# the row/none/layer modes the boolean toggle doesn't expose), stripped
|
|
# on inherit, and reconciled back into the round-tripped tensor_parallel
|
|
# state.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["--split-mode", "tensor"],
|
|
["--split-mode", "row"],
|
|
["--split-mode", "none"],
|
|
["--split-mode", "layer"],
|
|
["-sm", "tensor"],
|
|
["--split-mode=row"],
|
|
["-sm=tensor"],
|
|
],
|
|
)
|
|
def test_split_mode_passes_through(args):
|
|
# Not denylisted -- a user keeps row/none/layer via extras.
|
|
assert validate_extra_args(args) == args
|
|
|
|
|
|
def test_split_mode_is_not_managed():
|
|
assert is_managed_flag("--split-mode") is False
|
|
assert is_managed_flag("-sm") is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,expected",
|
|
[
|
|
(None, None),
|
|
([], None),
|
|
(["--top-k", "20"], None),
|
|
(["--split-mode", "tensor"], "tensor"),
|
|
(["--split-mode", "row"], "row"),
|
|
(["-sm", "none"], "none"),
|
|
(["--split-mode=layer"], "layer"),
|
|
(["-sm=tensor"], "tensor"),
|
|
# last-wins when supplied twice
|
|
(["-sm", "row", "--split-mode", "tensor"], "tensor"),
|
|
],
|
|
)
|
|
def test_parse_split_mode_override(args, expected):
|
|
assert parse_split_mode_override(args) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["--split-mode"],
|
|
["-sm"],
|
|
["--split-mode", "-c", "4096"], # next token is a flag, not a value
|
|
],
|
|
)
|
|
def test_parse_split_mode_override_rejects_malformed_values(args):
|
|
with pytest.raises(ValueError, match = "split-mode|'-sm'"):
|
|
parse_split_mode_override(args)
|
|
|
|
|
|
def test_validate_extra_args_rejects_malformed_split_mode():
|
|
# Validation catches a value-less --split-mode at the boundary,
|
|
# mirroring the early --ctx-size / --cache-type checks.
|
|
with pytest.raises(ValueError, match = "split-mode"):
|
|
validate_extra_args(["--split-mode"])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args,fallback,expected",
|
|
[
|
|
# No override -> fall back to the toggle value, both directions.
|
|
(["--top-k", "20"], True, True),
|
|
(["--top-k", "20"], False, False),
|
|
(None, True, True),
|
|
([], False, False),
|
|
# Explicit override wins: tensor -> on, anything else -> off,
|
|
# regardless of the toggle fallback.
|
|
(["--split-mode", "tensor"], False, True),
|
|
(["-sm", "tensor"], False, True),
|
|
(["--split-mode", "row"], True, False),
|
|
(["--split-mode", "none"], True, False),
|
|
(["--split-mode", "layer"], True, False),
|
|
(["--split-mode=tensor"], False, True),
|
|
# Case-insensitive on the mode string.
|
|
(["--split-mode", "TENSOR"], False, True),
|
|
# last-wins across multiple --split-mode flags.
|
|
(["-sm", "tensor", "--split-mode", "row"], True, False),
|
|
],
|
|
)
|
|
def test_resolve_tensor_parallel(args, fallback, expected):
|
|
assert resolve_tensor_parallel(args, fallback) is expected
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_split_mode_when_requested():
|
|
out = strip_shadowing_flags(
|
|
["--split-mode", "row", "--top-k", "20"],
|
|
strip_context = False,
|
|
strip_cache = False,
|
|
strip_spec = False,
|
|
strip_template = False,
|
|
strip_split_mode = True,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
def test_extra_args_disable_mmproj_detects_flag():
|
|
assert extra_args_disable_mmproj(["--no-mmproj"]) is True
|
|
assert extra_args_disable_mmproj(["--threads", "12", "--no-mmproj"]) is True
|
|
assert extra_args_disable_mmproj(["--no-mmproj-auto"]) is True
|
|
|
|
|
|
def test_extra_args_disable_mmproj_false_when_absent():
|
|
assert extra_args_disable_mmproj(None) is False
|
|
assert extra_args_disable_mmproj(["--threads", "12"]) is False
|
|
|
|
|
|
def test_extra_args_disable_mmproj_last_wins():
|
|
assert extra_args_disable_mmproj(["--no-mmproj", "--mmproj-auto"]) is False
|
|
assert extra_args_disable_mmproj(["--mmproj-auto", "--no-mmproj-auto"]) is True
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_model_draft_with_spec():
|
|
# --model-draft (and aliases) are Studio-managed since the separate
|
|
# MTP drafter support: an inherited copy must not last-wins-override
|
|
# the auto-detected drafter.
|
|
out = strip_shadowing_flags(
|
|
["--model-draft", "/old/mtp.gguf", "-md", "/old2.gguf", "--top-k", "20"],
|
|
strip_context = False,
|
|
strip_cache = False,
|
|
strip_spec = True,
|
|
strip_template = False,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"selector",
|
|
[
|
|
["--spec-draft-hf", "org/repo"],
|
|
["-hfd", "org/repo"],
|
|
["-hfrd", "org/repo"],
|
|
["--hf-repo-draft", "org/repo"],
|
|
["--spec-draft-hf=org/repo"],
|
|
],
|
|
)
|
|
def test_strip_shadowing_flags_drops_hf_drafter_selectors_with_spec(selector):
|
|
# HF drafter selectors must reset on inherit like local --model-draft, or a
|
|
# stale inherited HF drafter last-wins over Studio's re-derived spec choice.
|
|
out = strip_shadowing_flags(
|
|
selector + ["--top-k", "20"],
|
|
strip_context = False,
|
|
strip_cache = False,
|
|
strip_spec = True,
|
|
strip_template = False,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_draft_tuning_with_spec():
|
|
# Per-drafter tuning knobs are deliberately preserved: the VRAM budget reads
|
|
# them via the same parsers the child honors (so they stay consistent on
|
|
# inherit), and stripping --spec-draft-ngl would move a CPU drafter to GPU.
|
|
keep = [
|
|
"--spec-draft-type-k",
|
|
"q4_0",
|
|
"--spec-draft-type-v",
|
|
"q4_0",
|
|
"--spec-draft-ngl",
|
|
"0",
|
|
"--spec-draft-device",
|
|
"cpu",
|
|
]
|
|
out = strip_shadowing_flags(
|
|
list(keep),
|
|
strip_context = False,
|
|
strip_cache = False,
|
|
strip_spec = True,
|
|
strip_template = False,
|
|
)
|
|
assert out == keep
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_split_mode_when_not_requested():
|
|
# No tensor_parallel field supplied on the Apply -> an inherited
|
|
# --split-mode survives (mirrors the chat-template keep behavior).
|
|
out = strip_shadowing_flags(
|
|
["--split-mode", "row", "--top-k", "20"],
|
|
strip_context = True,
|
|
strip_cache = True,
|
|
strip_spec = True,
|
|
strip_template = True,
|
|
strip_split_mode = False,
|
|
)
|
|
assert out == ["--split-mode", "row", "--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_split_mode_short_alias_and_equals():
|
|
assert strip_shadowing_flags(["-sm", "tensor", "--top-k", "20"], strip_split_mode = True) == [
|
|
"--top-k",
|
|
"20",
|
|
]
|
|
assert strip_shadowing_flags(["--split-mode=row", "--seed", "-1"], strip_split_mode = True) == [
|
|
"--seed",
|
|
"-1",
|
|
]
|
|
|
|
|
|
def test_strip_shadowing_flags_defaults_strip_split_mode_too():
|
|
# The route's already-loaded comparator (no kwargs) must see a stored
|
|
# --split-mode as a shadowing flag so it forces a reload.
|
|
assert strip_shadowing_flags(["--split-mode", "tensor"]) == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
["--split-mode", "tensor", "-c", "4096"],
|
|
["-sm", "tensor", "-c", "4096"],
|
|
["--split-mode=tensor", "-c", "4096"],
|
|
["-sm=tensor", "-c", "4096"],
|
|
],
|
|
)
|
|
def test_strip_split_mode_only_keeps_other_shadow_flags(args):
|
|
# Every --split-mode form (long/short, space/=) is dropped; -c survives.
|
|
assert strip_split_mode_only(args) == ["-c", "4096"]
|
|
|
|
|
|
def test_strip_split_mode_only_preserves_none_and_empty():
|
|
# None means "inherit"; [] means "explicit empty" -- both must round-trip.
|
|
assert strip_split_mode_only(None) is None
|
|
assert strip_split_mode_only([]) == []
|
|
|
|
|
|
def test_strip_shadowing_flags_drops_tensor_split_with_split_mode():
|
|
# --tensor-split is coupled to the split mode: stripped together so a stale
|
|
# ratio can't override Studio's computed tensor split. Other flags survive.
|
|
out = strip_shadowing_flags(
|
|
["--split-mode", "row", "--tensor-split", "1,1", "--top-k", "20"],
|
|
strip_context = False,
|
|
strip_cache = False,
|
|
strip_spec = False,
|
|
strip_template = False,
|
|
strip_split_mode = True,
|
|
)
|
|
assert out == ["--top-k", "20"]
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_tensor_split_when_not_requested():
|
|
# strip_split_mode=False keeps the whole split group (mode + ratios).
|
|
assert strip_shadowing_flags(
|
|
["--tensor-split", "1,1", "--top-k", "20"], strip_split_mode = False
|
|
) == ["--tensor-split", "1,1", "--top-k", "20"]
|
|
|
|
|
|
def test_strip_split_mode_only_drops_tensor_split_too():
|
|
# Downgrade / layer fallback must drop the coupled --tensor-split (all forms).
|
|
assert strip_split_mode_only(
|
|
["--split-mode", "tensor", "--tensor-split", "1,1", "-c", "4096"]
|
|
) == ["-c", "4096"]
|
|
assert strip_split_mode_only(["-sm=tensor", "-ts=3,1"]) == []
|
|
|
|
|
|
def test_strip_shadowing_flags_keeps_model_draft_without_spec():
|
|
out = strip_shadowing_flags(
|
|
["--model-draft", "/custom/mtp.gguf"],
|
|
strip_context = True,
|
|
strip_cache = False,
|
|
strip_spec = False,
|
|
strip_template = False,
|
|
)
|
|
assert out == ["--model-draft", "/custom/mtp.gguf"]
|