* Studio: GPU memory dropdown — llama.cpp --fit on and manual gpu-layers/cpu-moe * Studio: simplify GPU memory changes (reuse ParamSlider, GPU_LAYERS_ALL, loadedGpuMemoryFields helper) * Studio: GPU picker — choose which GPUs a GGUF model loads on (gpu_ids) * Studio: simplify GPU picker (share /api/system fetch, validate gpu_ids) * Studio: GPU picker review fixes (gate relative indices, no cross-model leak, validate, types) * Studio: group GPU controls under a collapsible GPU section * Studio: GPU feature review fixes (fix fit-ctx test, behavior-test the floor, comment accuracy) * Studio: make GPU a top-level settings section (not nested under Model) * Studio: flatten GPU controls into the Model section, group by GPU/context/generation * Studio: move GPU Memory to the bottom of Model with its dependent controls beneath it * Studio: move GPU Memory below Tensor Parallelism and GPUs below GPU Memory * Studio: tighten GPU Memory and GPU Layers tooltip copy * Studio: fix fit-mode context slider track-click, restore GPU Memory tooltip, shorten fit dropdown label * Studio: GPU Memory tooltip one mode per line, briefer * Studio: note HIP_VISIBLE_DEVICES (ROCm) in the GPUs picker tooltip * Studio: narrow the GPU Memory dropdown to fit the shortened label * Studio: use 'llama.cpp --fit' in the GPU Memory tooltip for consistency * Studio: allow Tensor Parallelism in Manual GPU mode * Studio: graduated MoE-on-CPU offload (--n-cpu-moe) replacing the all-or-nothing toggle * Studio: size the MoE-offload slider for staged (deferred-load) models * Studio: share one GGUF header walk for the context-length and MoE-count readers * Studio: size the GPU Layers slider for staged models (one staged-header read) * Studio: move Tensor Parallelism below the GPUs picker * Studio: GPU split (--tensor-split) per-GPU model share in Manual mode * Studio: tolerate whitespace in GPU split input, move it below GPU Layers * Studio: rename the GPU split control to "Split ratio" * Studio: Split ratio sends explicit even input; fix blank=free-VRAM (not even) copy * Studio: tighten llama.cpp --fit VRAM margin with --fit-target 512 * Studio: GPU memory review fixes (rollback re-baseline, single-GPU TP gate, accurate copy) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: move Split ratio below MoE Layers on CPU * Studio: address PR review (fix GPU-info hydration race, share fit context-length across load paths) * Studio: address codex review (manual single-GPU TP guard, GPU-aware spec defaults in fit/manual, GGUF-only context/preference) * Studio: address codex review round 2 (gpu_present seed, single-GPU tensor-split guard, staged manual-knob reset, strip inherited offload flags) * Studio: address codex review round 3 (strip inherited --n-cpu-moe, CPU-fallback warning in Manual mode) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address codex review round 4 (preserve pinned fit context across a later Apply) * Studio: address codex review round 5 (honor GPU picker for diffusion GGUFs, clear fit pin on cross-model switch) * Studio: preserve the pending GPU Memory mode when staging a model * Studio: pin diffusion GPU device order and reset GPU-memory state for diffusion loads * Studio: address codex review round 6 (fit-Auto rollback context, preserve manual non-tensor split modes, persist GPU mode on load not select) * Studio: persist the applied GPU Memory mode, not the requested one (skip diffusion loads) * Studio: replace Manual-mode split-ratio field with per-GPU layer sliders * Studio: clarify per-GPU layer split hint for tensor-parallel mode * Studio: address codex review round 7 (allow GGUF gpu_ids past the legacy guard, replay GPU-memory fields on respawn) * Studio: address codex review round 8 (size the validate preflight like the load in fit mode, across both load paths) * Studio: skip the training-OOM guard for llama.cpp --fit GGUF loads (they spill to RAM) * Studio: drop the now-redundant compare-path validate sizing (the --fit guard skip makes it moot) * Studio: address codex review round 9 (keep the training guard for fit loads, forward gpu_ids to validate, strip inherited manual tensor-split) * Studio: address codex review round 10 (gate GPU-memory adoption on is_gguf, record manual knobs only in Manual mode) * Studio: handle diffusion GGUFs symmetrically in the GPU Memory controls (preserve the standing mode preference, hide the inapplicable mode/TP controls) * Studio: remember the GPU Memory settings per model * Studio: consolidate --fit mode and Manual mode into a single Manual mode * Studio: preserve the per-GPU layer split across GPU Layers changes * Studio: trim overly long GPU Memory comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address GPU memory config review comments * trim redundant GPU memory tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reconcile manual-mode TP drops with the #6659 drop-site invariants * Preserve quantized KV in manual --fit, charge GGUF companions in full, reconcile GPU pick on load * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clear stale GPU baseline on non-GGUF loads so it can't read as dirty * Fix no-context-shift test for the conditional -c flag * Credit manual GPU-layer offload for cached HF GGUFs * Reset per-model load knobs on GGUF quant switch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Strip inherited tensor-split when manual ratio is cleared * Match auto-load validation to safetensors placement * Reset editable manual knobs after Auto GGUF loads * Record a single device for diffusion GPU picks * Reset per-model GPU knobs before applying saved settings * Address review comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard manual tensor splits and keep remembered context on auto-load * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Snapshot compare knobs, seed splits from free VRAM, flag zero-offload loads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Exempt CPU-only loads from the guard floor and harden compare and reseed paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reach full offload from the layers slider and charge extras drafters in the guard * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Warm the GPU device cache before pick reconciles and disable staged GPU controls * Align the training guard with inherited extras, spec mode, and compare targets * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hide GPUs from companion-less zero-offload loads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Size diffusion picks per device, own manual offload flags, reject XPU picks * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop tensor flags at zero layers and exempt CPU-pinned drafters * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Allowlist the zero-layer tensor parallel drop site * Keep validate and load guards on the same extras and refresh stale baselines * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop mismatched manual tensor splits before launch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate XPU picks on the real backend field and harden split and hydration paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Weight full GPUs as zero, clamp split shares, and refine the zero-layer mask gate * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Carry fit context across mode changes and align drafter and picker gates * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Catch variant switches, uncached diffusion repos, and text-only mmproj skips * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check companions on the first device and size native and remote zero-layer loads * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Replace the training guard's precise VRAM modeling with a conservative bound * Baseline context pins on non-GGUF hydration and reprobe list-seeded staged GGUFs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Size manual splits by their largest share and preserve resolved context from Default * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Default-deny unsized required companions and price KV at the effective cache dtype * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reserve MTP draft KV and MLA target-copy in the training guard * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Size tensor-parallel loads per device and show GPU controls for native GGUFs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Reserve MTP overhead for uncached remote GGUFs and the mmproj runtime factor * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the training-coexistence VRAM estimation this PR added * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Gate remembered load settings to GGUF picks * Lock the remaining load-time controls during a staged load * Clear the stale native-path token on compare loads * Drop a stale guard reference from the zero-offload masking comment * Seed GPU baselines from the rollback response and drop never-emitted offload flags * Match validate's training guard to load and keep the native reload token * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim verbose GPU-memory comments * Thread the variants header walk off the event loop, honor device pins on zero-offload, and hold staged GPU edits * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Honor manual placement and classify pinned zero-offload loads * Close diffusion admission and status hydration gaps * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Check the actual diffusion GPU during training * Align staged baselines and manual reload dedupe * Fix GGUF placement and rollback state * Harden manual GGUF placement boundaries * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Remove unused resolve_tensor_parallel import in llama_cpp.py The name is used only in llama_server_args.py, routes/inference.py, and tests, not in llama_cpp.py; the unused hoisted import trips the import-hoist verifier in the source-lint CI job. * Fix diffusion GPU dedup and training guard for non-numeric device tokens The diffusion runner drives only its single lowest device and the backend records that one device (self._gpu_ids = [sorted(gpu_ids)[0]]), but the reload dedupe compared it against the full requested list, so a multi-GPU pick that resolves to the same device forced a needless reload. Normalize the request the same way for a loaded diffusion model in both _already_in_target_state and the route _request_matches_loaded_settings. The chat-during-training coexistence guard called int() on the single-device token and hard-rejected when it could not parse. A non-numeric token (a CUDA UUID / MIG handle) now sizes against the whole visible pool like the GGUF guard instead of falsely blocking the load, and an empty token (a CPU-only runner such as a CPU diffusion GGUF) is allowed outright since it uses no GPU VRAM. * Tighten comments added by the GPU memory config changes * Harden GGUF placement from independent review: VRAM sizing, diffusion TP reset, tensor_split validation - Training coexistence guard: a single-device runner pinned through an unresolvable UUID/MIG token was sized against the aggregate visible-VRAM pool, so a load could pass on capacity it cannot use and then OOM active training. Size against the worst-case visible device (min free) instead, keeping the guard's documented default-deny contract. The empty-token (CPU-only runner) allow path is unchanged. - Diffusion startup: _start_diffusion_server now resets self._tensor_parallel to False alongside the other placement resets. A prior tensor-parallel chat load (process killed but not fully unload-reset) otherwise left /status misreporting tensor parallelism and made an identical diffusion re-Apply reload against the stale state. - tensor_split: reject negative / non-finite / all-zero splits up front. They were dropped at launch but still compared raw in the reload dedupe, so an identical Apply reloaded indefinitely. - Tests: the shared httpx stub was incomplete and, installed via setdefault before real httpx loaded, broke a combined pytest run (collection errors on httpx.Response). Import the real installed httpx instead. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
416 lines
13 KiB
Python
416 lines
13 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 :mod:`utils.models.gguf_metadata`. Synthesise small GGUF headers
|
|
in tmp dirs so we never depend on real model files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
from pathlib import Path
|
|
from typing import Iterable, Mapping
|
|
|
|
from utils.models.gguf_metadata import (
|
|
is_mmproj_by_metadata,
|
|
pairing_score,
|
|
read_gguf_context_length,
|
|
read_gguf_general_metadata,
|
|
read_gguf_staged_dims,
|
|
read_mmproj_audio_capability,
|
|
)
|
|
|
|
|
|
_GGUF_MAGIC = 0x46554747
|
|
_VTYPE_STRING = 8
|
|
_VTYPE_UINT32 = 4
|
|
_VTYPE_UINT64 = 10
|
|
_VTYPE_ARRAY = 9
|
|
_VTYPE_BOOL = 7
|
|
|
|
|
|
def _enc_string(s: str) -> bytes:
|
|
b = s.encode("utf-8")
|
|
return struct.pack("<Q", len(b)) + b
|
|
|
|
|
|
def _enc_kv_string(key: str, value: str) -> bytes:
|
|
return _enc_string(key) + struct.pack("<I", _VTYPE_STRING) + _enc_string(value)
|
|
|
|
|
|
def _enc_kv_uint32(key: str, value: int) -> bytes:
|
|
return _enc_string(key) + struct.pack("<I", _VTYPE_UINT32) + struct.pack("<I", value)
|
|
|
|
|
|
def _enc_kv_uint64(key: str, value: int) -> bytes:
|
|
return _enc_string(key) + struct.pack("<I", _VTYPE_UINT64) + struct.pack("<Q", value)
|
|
|
|
|
|
def _enc_kv_bool(key: str, value: bool) -> bytes:
|
|
return _enc_string(key) + struct.pack("<I", _VTYPE_BOOL) + struct.pack("<B", 1 if value else 0)
|
|
|
|
|
|
def _enc_kv_string_array(key: str, values: Iterable[str]) -> bytes:
|
|
vals = list(values)
|
|
out = _enc_string(key) + struct.pack("<I", _VTYPE_ARRAY)
|
|
out += struct.pack("<I", _VTYPE_STRING) + struct.pack("<Q", len(vals))
|
|
for v in vals:
|
|
out += _enc_string(v)
|
|
return out
|
|
|
|
|
|
def _write_synthetic_gguf(
|
|
path: Path,
|
|
general_strings: Mapping[str, str],
|
|
*,
|
|
extra_uint32: Mapping[str, int] | None = None,
|
|
extra_uint64: Mapping[str, int] | None = None,
|
|
extra_string_arrays: Mapping[str, Iterable[str]] | None = None,
|
|
extra_bools: Mapping[str, bool] | None = None,
|
|
) -> Path:
|
|
"""Minimal GGUF: header + KV body, no tensors."""
|
|
extra_uint32 = extra_uint32 or {}
|
|
extra_uint64 = extra_uint64 or {}
|
|
extra_string_arrays = extra_string_arrays or {}
|
|
extra_bools = extra_bools or {}
|
|
kv_count = (
|
|
len(general_strings)
|
|
+ len(extra_uint32)
|
|
+ len(extra_uint64)
|
|
+ len(extra_string_arrays)
|
|
+ len(extra_bools)
|
|
)
|
|
body = b""
|
|
for k, v in general_strings.items():
|
|
body += _enc_kv_string(k, v)
|
|
for k, v in extra_uint32.items():
|
|
body += _enc_kv_uint32(k, v)
|
|
for k, v in extra_uint64.items():
|
|
body += _enc_kv_uint64(k, v)
|
|
for k, v in extra_string_arrays.items():
|
|
body += _enc_kv_string_array(k, v)
|
|
for k, v in extra_bools.items():
|
|
body += _enc_kv_bool(k, v)
|
|
header = struct.pack(
|
|
"<IIQQ",
|
|
_GGUF_MAGIC,
|
|
3, # version
|
|
0, # tensor_count
|
|
kv_count,
|
|
)
|
|
path.parent.mkdir(parents = True, exist_ok = True)
|
|
path.write_bytes(header + body)
|
|
return path
|
|
|
|
|
|
# --- read_gguf_general_metadata ----------------------------------------
|
|
|
|
|
|
def test_returns_none_for_missing_file(tmp_path: Path):
|
|
assert read_gguf_general_metadata(str(tmp_path / "nope.gguf")) is None
|
|
|
|
|
|
def test_returns_none_for_non_gguf(tmp_path: Path):
|
|
p = tmp_path / "garbage.gguf"
|
|
p.write_bytes(b"not a gguf file at all, just bytes")
|
|
assert read_gguf_general_metadata(str(p)) is None
|
|
|
|
|
|
def test_context_length_none_for_missing_file(tmp_path: Path):
|
|
assert read_gguf_context_length(str(tmp_path / "nope.gguf")) is None
|
|
|
|
|
|
def test_context_length_none_for_non_gguf(tmp_path: Path):
|
|
p = tmp_path / "garbage.gguf"
|
|
p.write_bytes(b"not a gguf file at all, just bytes")
|
|
assert read_gguf_context_length(str(p)) is None
|
|
|
|
|
|
def test_context_length_read_from_arch_namespaced_key(tmp_path: Path):
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.architecture": "llama"},
|
|
extra_uint32 = {"llama.context_length": 4096, "llama.block_count": 32},
|
|
)
|
|
assert read_gguf_context_length(str(p)) == 4096
|
|
|
|
|
|
def test_context_length_none_when_absent(tmp_path: Path):
|
|
# Architecture present but no <arch>.context_length key.
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.architecture": "llama"},
|
|
extra_uint32 = {"llama.block_count": 32},
|
|
)
|
|
assert read_gguf_context_length(str(p)) is None
|
|
|
|
|
|
def test_context_length_ignores_foreign_arch_key(tmp_path: Path):
|
|
# A context_length under a different arch namespace must not match.
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.architecture": "llama"},
|
|
extra_uint32 = {"qwen2.context_length": 8192},
|
|
)
|
|
assert read_gguf_context_length(str(p)) is None
|
|
|
|
|
|
# --- read_gguf_staged_dims (one pass: context + layer + moe counts) ----
|
|
|
|
|
|
def test_staged_dims_none_for_missing_or_non_gguf(tmp_path: Path):
|
|
assert read_gguf_staged_dims(str(tmp_path / "nope.gguf")) is None
|
|
p = tmp_path / "garbage.gguf"
|
|
p.write_bytes(b"not a gguf at all")
|
|
assert read_gguf_staged_dims(str(p)) is None
|
|
|
|
|
|
def test_staged_dims_moe_with_leading_dense(tmp_path: Path):
|
|
# GLM-4.7-Flash shape: context + total layers + MoE layers in one read.
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "glm.gguf",
|
|
{"general.architecture": "deepseek2"},
|
|
extra_uint32 = {
|
|
"deepseek2.context_length": 202752,
|
|
"deepseek2.block_count": 47,
|
|
"deepseek2.expert_count": 64,
|
|
"deepseek2.leading_dense_block_count": 1,
|
|
},
|
|
)
|
|
assert read_gguf_staged_dims(str(p)) == {
|
|
"context_length": 202752,
|
|
"layer_count": 47,
|
|
"moe_layer_count": 46,
|
|
}
|
|
|
|
|
|
def test_staged_dims_dense_model(tmp_path: Path):
|
|
# Dense: layer_count present, moe_layer_count 0 (slider hidden).
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "dense.gguf",
|
|
{"general.architecture": "qwen3"},
|
|
extra_uint32 = {"qwen3.context_length": 40960, "qwen3.block_count": 36},
|
|
)
|
|
assert read_gguf_staged_dims(str(p)) == {
|
|
"context_length": 40960,
|
|
"layer_count": 36,
|
|
"moe_layer_count": 0,
|
|
}
|
|
|
|
|
|
def test_staged_dims_all_moe_no_leading_dense(tmp_path: Path):
|
|
# Experts present, no leading_dense key -> every block is a MoE layer.
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "moe.gguf",
|
|
{"general.architecture": "qwen35moe"},
|
|
extra_uint32 = {"qwen35moe.block_count": 40, "qwen35moe.expert_count": 256},
|
|
)
|
|
assert read_gguf_staged_dims(str(p)) == {
|
|
"context_length": None,
|
|
"layer_count": 40,
|
|
"moe_layer_count": 40,
|
|
}
|
|
|
|
|
|
def test_staged_dims_uint64_block_count(tmp_path: Path):
|
|
# block_count stored as uint64 (vtype 10) still parses; moe == block_count.
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "moe64.gguf",
|
|
{"general.architecture": "gpt-oss"},
|
|
extra_uint32 = {"gpt-oss.expert_count": 32},
|
|
extra_uint64 = {"gpt-oss.block_count": 24},
|
|
)
|
|
assert read_gguf_staged_dims(str(p)) == {
|
|
"context_length": None,
|
|
"layer_count": 24,
|
|
"moe_layer_count": 24,
|
|
}
|
|
|
|
|
|
def test_context_length_read_from_uint64(tmp_path: Path):
|
|
# Some models store context_length as a uint64 (vtype 10).
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.architecture": "qwen3"},
|
|
extra_uint64 = {"qwen3.context_length": 262144},
|
|
)
|
|
assert read_gguf_context_length(str(p)) == 262144
|
|
|
|
|
|
def test_context_length_zero_treated_as_absent(tmp_path: Path):
|
|
# A zero/garbage ceiling must read as None so the UI can't build a slider
|
|
# with max < min.
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.architecture": "llama"},
|
|
extra_uint32 = {"llama.context_length": 0},
|
|
)
|
|
assert read_gguf_context_length(str(p)) is None
|
|
|
|
|
|
def test_extracts_general_string_fields(tmp_path: Path):
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{
|
|
"general.architecture": "qwen2vl",
|
|
"general.type": "model",
|
|
"general.basename": "Qwen3.5",
|
|
"general.organization": "Qwen",
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
"general.base_model.0.name": "Qwen3.5 9B",
|
|
"general.base_model.0.organization": "Qwen",
|
|
},
|
|
)
|
|
meta = read_gguf_general_metadata(str(p))
|
|
assert meta is not None
|
|
assert meta["general.architecture"] == "qwen2vl"
|
|
assert meta["general.basename"] == "Qwen3.5"
|
|
assert meta["general.base_model.0.repo_url"] == "https://huggingface.co/Qwen/Qwen3.5-9B"
|
|
|
|
|
|
def test_skips_unrelated_fields_without_breaking(tmp_path: Path):
|
|
"""Skip unwanted arrays and uint32s without losing position."""
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.basename": "Foo"},
|
|
extra_uint32 = {"qwen2vl.context_length": 32768},
|
|
extra_string_arrays = {"tokenizer.ggml.tokens": ["a", "bc", "def"]},
|
|
)
|
|
meta = read_gguf_general_metadata(str(p))
|
|
assert meta == {"general.basename": "Foo"}
|
|
|
|
|
|
def test_metadata_is_cached(tmp_path: Path):
|
|
"""Cache invalidates on size change."""
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.basename": "First"},
|
|
)
|
|
first = read_gguf_general_metadata(str(p))
|
|
assert first == {"general.basename": "First"}
|
|
# Change size so the (path, mtime, size) cache key invalidates.
|
|
_write_synthetic_gguf(
|
|
tmp_path / "model.gguf",
|
|
{"general.basename": "Second", "general.organization": "X"},
|
|
)
|
|
second = read_gguf_general_metadata(str(p))
|
|
assert second == {"general.basename": "Second", "general.organization": "X"}
|
|
|
|
|
|
# --- is_mmproj_by_metadata --------------------------------------------
|
|
|
|
|
|
def test_is_mmproj_by_metadata_signals():
|
|
assert is_mmproj_by_metadata({"general.type": "mmproj"}) is True
|
|
assert is_mmproj_by_metadata({"general.type": "MMProj"}) is True
|
|
assert is_mmproj_by_metadata({"general.type": "model"}) is False
|
|
assert is_mmproj_by_metadata({"general.basename": "foo"}) is None
|
|
assert is_mmproj_by_metadata({}) is None
|
|
assert is_mmproj_by_metadata(None) is None
|
|
|
|
|
|
# --- pairing_score -----------------------------------------------------
|
|
|
|
|
|
def test_pairing_score_base_model_url_match():
|
|
weight = {
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
}
|
|
mmproj = {
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
}
|
|
assert pairing_score(weight, mmproj) == 100
|
|
|
|
|
|
def test_pairing_score_base_model_url_mismatch():
|
|
weight = {
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
}
|
|
mmproj = {
|
|
"general.base_model.0.repo_url": "https://huggingface.co/google/gemma-3-9B",
|
|
}
|
|
assert pairing_score(weight, mmproj) == -1
|
|
|
|
|
|
def test_pairing_score_base_model_url_trailing_slash_normalised():
|
|
weight = {
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B/",
|
|
}
|
|
mmproj = {
|
|
"general.base_model.0.repo_url": "https://huggingface.co/Qwen/Qwen3.5-9B",
|
|
}
|
|
assert pairing_score(weight, mmproj) == 100
|
|
|
|
|
|
def test_pairing_score_basename_plus_org_fallback():
|
|
weight = {
|
|
"general.basename": "Nanonets-Ocr-S",
|
|
"general.base_model.0.organization": "Nanonets",
|
|
}
|
|
mmproj = {
|
|
"general.basename": "Nanonets-Ocr-S",
|
|
"general.base_model.0.organization": "Nanonets",
|
|
}
|
|
assert pairing_score(weight, mmproj) == 80
|
|
|
|
|
|
def test_pairing_score_basename_only_fallback():
|
|
assert (
|
|
pairing_score(
|
|
{"general.basename": "Nanonets-Ocr-S"},
|
|
{"general.basename": "Nanonets-Ocr-S"},
|
|
)
|
|
== 60
|
|
)
|
|
|
|
|
|
def test_pairing_score_no_overlap_returns_zero():
|
|
"""One side empty: scorer punts to filename fallback."""
|
|
assert pairing_score({"general.basename": "Foo"}, {}) == 0
|
|
assert pairing_score({}, {"general.basename": "Foo"}) == 0
|
|
assert pairing_score(None, {"general.basename": "Foo"}) == 0
|
|
|
|
|
|
# --- read_mmproj_audio_capability --------------------------------------
|
|
|
|
|
|
def test_mmproj_audio_capability_true(tmp_path: Path):
|
|
"""clip.has_audio_encoder=True (e.g. Gemma 4's gemma4ua projector)."""
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "mmproj.gguf",
|
|
{"general.type": "mmproj"},
|
|
extra_bools = {
|
|
"clip.has_vision_encoder": True,
|
|
"clip.has_audio_encoder": True,
|
|
},
|
|
)
|
|
assert read_mmproj_audio_capability(str(p)) is True
|
|
|
|
|
|
def test_mmproj_audio_capability_false(tmp_path: Path):
|
|
"""Vision-only projector: key present but false."""
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "mmproj.gguf",
|
|
{"general.type": "mmproj"},
|
|
extra_bools = {
|
|
"clip.has_vision_encoder": True,
|
|
"clip.has_audio_encoder": False,
|
|
},
|
|
)
|
|
assert read_mmproj_audio_capability(str(p)) is False
|
|
|
|
|
|
def test_mmproj_audio_capability_absent_returns_none(tmp_path: Path):
|
|
"""Key absent (older/vision-only mmproj): None, not False."""
|
|
p = _write_synthetic_gguf(
|
|
tmp_path / "mmproj.gguf",
|
|
{"general.type": "mmproj"},
|
|
extra_bools = {"clip.has_vision_encoder": True},
|
|
)
|
|
assert read_mmproj_audio_capability(str(p)) is None
|
|
|
|
|
|
def test_mmproj_audio_capability_missing_or_non_gguf(tmp_path: Path):
|
|
assert read_mmproj_audio_capability(str(tmp_path / "nope.gguf")) is None
|
|
junk = tmp_path / "garbage.gguf"
|
|
junk.write_bytes(b"not a gguf header at all")
|
|
assert read_mmproj_audio_capability(str(junk)) is None
|