Files
unslothai--unsloth/studio/backend/tests/test_native_context_length.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

566 lines
21 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 native_context_length feature (PR #4746).
Verifies the `native_context_length` property on LlamaCppBackend and the
matching Pydantic fields. The raw GGUF `_context_length` must never be
overwritten by VRAM-capping logic.
Needs no GPU, network, or libraries beyond pytest and pydantic.
"""
import io
import json
import struct
import sys
import types as _types
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Stub heavy / unavailable deps before importing the module under test.
# Same pattern as test_kv_cache_estimation.py.
# ---------------------------------------------------------------------------
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# loggers
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
# structlog
_structlog_stub = _types.ModuleType("structlog")
sys.modules.setdefault("structlog", _structlog_stub)
# httpx -- stub only names referenced at import / class-definition time
_httpx_stub = _types.ModuleType("httpx")
for _exc_name in (
"ConnectError",
"TimeoutException",
"ReadTimeout",
"ReadError",
"RemoteProtocolError",
"CloseError",
):
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
class _FakeTimeout:
def __init__(self, *a, **kw):
pass
_httpx_stub.Timeout = _FakeTimeout
_httpx_stub.Client = type(
"Client",
(),
{
"__init__": lambda self, **kw: None,
"__enter__": lambda self: self,
"__exit__": lambda self, *a: None,
},
)
sys.modules.setdefault("httpx", _httpx_stub)
from core.inference.llama_cpp import LlamaCppBackend
from models.inference import LoadResponse, InferenceStatusResponse
# ── Helpers ──────────────────────────────────────────────────────────
def _write_kv(buf: io.BytesIO, key: str, value, vtype: int) -> None:
"""Append a single GGUF KV pair to *buf*."""
key_bytes = key.encode("utf-8")
buf.write(struct.pack("<Q", len(key_bytes)))
buf.write(key_bytes)
buf.write(struct.pack("<I", vtype))
if vtype == 4: # UINT32
buf.write(struct.pack("<I", value))
elif vtype == 10: # UINT64
buf.write(struct.pack("<Q", value))
elif vtype == 8: # STRING
val_bytes = value.encode("utf-8")
buf.write(struct.pack("<Q", len(val_bytes)))
buf.write(val_bytes)
else:
raise ValueError(f"Unsupported vtype in test helper: {vtype}")
def make_gguf(
tmp_path: Path,
arch: str,
kvs: list,
*,
arch_first: bool = True,
filename: str = "test.gguf",
) -> str:
"""Create a minimal valid GGUF v3 binary in *tmp_path*."""
buf = io.BytesIO()
buf.write(struct.pack("<I", 0x46554747)) # GGUF magic
buf.write(struct.pack("<I", 3)) # version 3
buf.write(struct.pack("<Q", 0)) # tensor count = 0
ordered = []
arch_entry = ("general.architecture", arch, 8)
if arch_first:
ordered.append(arch_entry)
for suffix, val, vt in kvs:
ordered.append((f"{arch}.{suffix}", val, vt))
if not arch_first:
ordered.append(arch_entry)
buf.write(struct.pack("<Q", len(ordered)))
for key, val, vt in ordered:
_write_kv(buf, key, val, vt)
path = tmp_path / filename
path.write_bytes(buf.getvalue())
return str(path)
@pytest.fixture
def backend():
"""Create a fresh LlamaCppBackend with side effects disabled."""
with patch.object(LlamaCppBackend, "_kill_orphaned_servers"):
with patch("atexit.register"):
return LlamaCppBackend()
# =====================================================================
# A. TestNativeContextLengthProperty -- the new property
# =====================================================================
class TestNativeContextLengthProperty:
"""Tests the new `native_context_length` property on LlamaCppBackend."""
def test_none_on_fresh_backend(self, backend):
"""Returns None when no model loaded."""
assert backend.native_context_length is None
def test_returns_raw_gguf_value(self, backend):
"""Directly returns _context_length when set."""
backend._context_length = 131072
assert backend.native_context_length == 131072
def test_not_capped_by_effective(self, backend):
"""native_context_length ignores _effective_context_length."""
backend._context_length = 131072
backend._effective_context_length = 32768
assert backend.native_context_length == 131072
def test_not_capped_by_max(self, backend):
"""native_context_length ignores _max_context_length."""
backend._context_length = 131072
backend._max_context_length = 65536
assert backend.native_context_length == 131072
def test_none_after_unload(self, backend):
"""After unload_model(), returns None."""
backend._context_length = 131072
assert backend.native_context_length == 131072
backend.unload_model()
assert backend.native_context_length is None
def test_after_gguf_parse(self, tmp_path, backend):
"""Synthetic GGUF with context_length=16384 populates the property."""
path = make_gguf(
tmp_path,
"llama",
[("context_length", 16384, 4)],
)
backend._read_gguf_metadata(path)
assert backend.native_context_length == 16384
def test_resets_between_parses(self, tmp_path, backend):
"""Second GGUF without context_length resets native to None."""
path_a = make_gguf(
tmp_path,
"llama",
[("context_length", 16384, 4)],
filename = "a.gguf",
)
backend._read_gguf_metadata(path_a)
assert backend.native_context_length == 16384
path_b = make_gguf(
tmp_path,
"gpt2",
[("block_count", 12, 4)],
filename = "b.gguf",
)
backend._read_gguf_metadata(path_b)
assert backend.native_context_length is None
# =====================================================================
# B. TestContextValueSeparation -- core invariant
# =====================================================================
class TestContextValueSeparation:
"""_context_length is never overwritten by VRAM logic."""
def test_preserved_after_effective_set(self, backend):
"""Setting _effective_context_length does not change _context_length."""
backend._context_length = 131072
backend._effective_context_length = 32768
assert backend._context_length == 131072
assert backend.native_context_length == 131072
def test_ordering_when_capped(self, backend):
"""native >= max >= effective holds when VRAM-capped."""
backend._context_length = 131072
backend._max_context_length = 65536
backend._effective_context_length = 32768
assert backend.native_context_length >= backend.max_context_length
assert backend.max_context_length >= backend.context_length
def test_all_equal_when_uncapped(self, backend):
"""All three equal when no VRAM constraint."""
backend._context_length = 8192
# No effective/max set -- properties fall back to _context_length.
assert backend.native_context_length == 8192
assert backend.max_context_length == 8192
assert backend.context_length == 8192
def test_fit_context_does_not_modify(self, backend):
"""_fit_context_to_vram() does not touch _context_length."""
backend._context_length = 131072
backend._n_layers = 32
backend._n_kv_heads = 8
backend._n_heads = 32
backend._embedding_length = 4096
original = backend._context_length
# Tiny VRAM budget forces capping.
result = backend._fit_context_to_vram(
requested_ctx = 131072,
available_mib = 512, # very small
model_size_bytes = 0,
)
# Returns the capped value without modifying _context_length.
assert backend._context_length == original
assert backend.native_context_length == original
# Capped value must be <= requested.
assert result <= 131072
def test_native_gt_context_when_capped(self, backend):
"""native_context_length > context_length after VRAM capping."""
backend._context_length = 131072
backend._effective_context_length = 16384
assert backend.native_context_length > backend.context_length
# =====================================================================
# C. TestPydanticModels -- LoadResponse & InferenceStatusResponse
# =====================================================================
class TestPydanticModels:
"""Tests native_context_length field on Pydantic models."""
def test_load_response_has_field(self):
"""Field exists in LoadResponse.model_fields."""
assert "native_context_length" in LoadResponse.model_fields
assert "context_length" in LoadResponse.model_fields
def test_load_response_defaults_none(self):
"""Omitting native_context_length defaults to None."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
)
assert resp.native_context_length is None
def test_load_response_accepts_int(self):
"""native_context_length=131072 stores correctly."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
native_context_length = 131072,
)
assert resp.native_context_length == 131072
def test_load_response_json_null(self):
"""None serializes to JSON null."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
)
data = json.loads(resp.model_dump_json())
assert data["native_context_length"] is None
def test_load_response_json_int(self):
"""131072 serializes to JSON number."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
native_context_length = 131072,
)
data = json.loads(resp.model_dump_json())
assert data["native_context_length"] == 131072
def test_status_response_has_field(self):
"""Field exists in InferenceStatusResponse.model_fields."""
assert "native_context_length" in InferenceStatusResponse.model_fields
assert "context_length" in InferenceStatusResponse.model_fields
def test_status_response_has_chat_template_field(self):
"""Status includes chat_template so the UI can rehydrate after refresh."""
assert "chat_template" in InferenceStatusResponse.model_fields
def test_status_response_defaults_none(self):
"""Omitting native_context_length defaults to None."""
resp = InferenceStatusResponse()
assert resp.native_context_length is None
def test_status_response_chat_template_roundtrip(self):
"""chat_template serializes and validates as part of status."""
resp = InferenceStatusResponse(chat_template = "{{ messages }}")
roundtripped = InferenceStatusResponse.model_validate_json(resp.model_dump_json())
assert roundtripped.chat_template == "{{ messages }}"
def test_roundtrip_preserves_value(self):
"""model_validate_json(model_dump_json()) round-trips."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
native_context_length = 131072,
)
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
assert roundtripped.native_context_length == 131072
def test_context_length_roundtrip(self):
"""Runtime context_length serializes for non-GGUF/hub models."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
context_length = 8192,
)
roundtripped = LoadResponse.model_validate_json(resp.model_dump_json())
assert roundtripped.context_length == 8192
# =====================================================================
# D. TestRouteCompleteness -- source-level verification
# =====================================================================
class TestRouteCompleteness:
"""All response construction sites in routes/inference.py include native_context_length."""
@pytest.fixture(autouse = True)
def _load_source(self):
"""Read routes/inference.py source once."""
routes_path = Path(__file__).resolve().parent.parent / "routes" / "inference.py"
self._source = routes_path.read_text()
def _find_construction_blocks(self, class_name: str) -> list[str]:
"""Extract all code blocks that construct a given response class."""
blocks = []
idx = 0
while True:
start = self._source.find(f"{class_name}(", idx)
if start == -1:
break
# Find the matching closing paren via a depth counter.
depth = 0
end = start
for i, ch in enumerate(self._source[start:], start):
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
end = i + 1
break
blocks.append(self._source[start:end])
idx = end
return blocks
def test_gguf_load_responses_have_field(self):
"""Every GGUF LoadResponse (is_gguf = True) includes native_context_length."""
blocks = self._find_construction_blocks("LoadResponse")
gguf_blocks = [b for b in blocks if "is_gguf = True" in b or "is_gguf=True" in b]
assert (
len(gguf_blocks) >= 2
), f"Expected at least 2 GGUF LoadResponse blocks, found {len(gguf_blocks)}"
for i, block in enumerate(gguf_blocks):
assert (
"native_context_length" in block
), f"GGUF LoadResponse block #{i} missing native_context_length:\n{block[:200]}"
def test_non_gguf_load_responses_omit_field(self):
"""Non-GGUF LoadResponse blocks do not set native_context_length (defaults to None)."""
blocks = self._find_construction_blocks("LoadResponse")
non_gguf = [b for b in blocks if "is_gguf = True" not in b and "is_gguf=True" not in b]
# Non-GGUF paths shouldn't reference native_context_length
# (Pydantic defaults it to None, so omitting it is correct).
for block in non_gguf:
assert (
"native_context_length" not in block
), f"Non-GGUF LoadResponse should not set native_context_length:\n{block[:200]}"
def test_non_gguf_load_responses_set_runtime_context_length(self):
"""Non-GGUF LoadResponse blocks report runtime context_length."""
blocks = self._find_construction_blocks("LoadResponse")
non_gguf = [b for b in blocks if "is_gguf = True" not in b and "is_gguf=True" not in b]
assert non_gguf, "Expected at least one non-GGUF LoadResponse block"
for block in non_gguf:
assert (
"context_length" in block
), f"Non-GGUF LoadResponse should set context_length:\n{block[:200]}"
def test_status_path(self):
"""InferenceStatusResponse construction with llama_backend has the field."""
blocks = self._find_construction_blocks("InferenceStatusResponse")
found = False
for block in blocks:
if "llama_backend" in block and "native_context_length" in block:
found = True
break
assert (
found
), "No InferenceStatusResponse block with llama_backend has native_context_length"
def test_non_gguf_status_path_reports_runtime_context_length(self):
"""Non-GGUF InferenceStatusResponse reports context_length from model_info."""
blocks = self._find_construction_blocks("InferenceStatusResponse")
found = False
for block in blocks:
if "is_gguf = False" in block and "context_length" in block:
found = True
break
assert found, "No non-GGUF InferenceStatusResponse block with context_length"
def test_openai_models_listing_reports_context_length(self):
"""/v1/models includes context_length when the backend knows it."""
assert 'entry["context_length"]' in self._source
assert 'model_info.get("context_length")' in self._source
# =====================================================================
# E. TestEdgeCases
# =====================================================================
class TestNativeContextEdgeCases:
"""Edge cases for native_context_length."""
def test_context_length_zero(self, tmp_path, backend):
"""GGUF context_length=0 returns 0, not None."""
path = make_gguf(tmp_path, "llama", [("context_length", 0, 4)])
backend._read_gguf_metadata(path)
assert backend.native_context_length == 0
def test_context_length_uint32_max(self, tmp_path, backend):
"""2^32 - 1 survives without truncation."""
val = 2**32 - 1
path = make_gguf(tmp_path, "llama", [("context_length", val, 4)])
backend._read_gguf_metadata(path)
assert backend.native_context_length == val
def test_context_length_uint64(self, tmp_path, backend):
"""UINT64 type context_length parsed correctly."""
val = 2**33 # exceeds UINT32 range
path = make_gguf(tmp_path, "llama", [("context_length", val, 10)])
backend._read_gguf_metadata(path)
assert backend.native_context_length == val
def test_no_context_length_in_gguf(self, tmp_path, backend):
"""GGUF without context_length key yields None."""
path = make_gguf(tmp_path, "llama", [("block_count", 32, 4)])
backend._read_gguf_metadata(path)
assert backend.native_context_length is None
def test_native_equals_context_when_uncapped(self, backend):
"""Both equal when no VRAM cap applied."""
backend._context_length = 8192
assert backend.native_context_length == backend.context_length
def test_native_survives_parse_then_cap(self, tmp_path, backend):
"""Parse then set effective cap: native unchanged."""
path = make_gguf(
tmp_path,
"llama",
[
("context_length", 131072, 4),
("block_count", 32, 4),
("attention.head_count", 32, 4),
("attention.head_count_kv", 8, 4),
("embedding_length", 4096, 4),
],
)
backend._read_gguf_metadata(path)
assert backend.native_context_length == 131072
# Simulate VRAM capping via effective and max.
backend._effective_context_length = 16384
backend._max_context_length = 32768
assert backend.native_context_length == 131072
# =====================================================================
# F. TestCrossPlatform -- binary I/O and serialization
# =====================================================================
class TestCrossPlatform:
"""Binary I/O and serialization correctness across platforms."""
def test_le_uint32_context_length(self, tmp_path, backend):
"""Little-endian UINT32 parsed correctly."""
path = make_gguf(tmp_path, "llama", [("context_length", 16384, 4)])
backend._read_gguf_metadata(path)
assert backend.native_context_length == 16384
def test_le_uint64_context_length(self, tmp_path, backend):
"""Little-endian UINT64 parsed correctly."""
path = make_gguf(tmp_path, "llama", [("context_length", 16384, 10)])
backend._read_gguf_metadata(path)
assert backend.native_context_length == 16384
def test_gguf_magic_le_byte_order(self, tmp_path):
"""Magic 0x46554747 matches GGUF spec (little-endian 'GGUF')."""
path = tmp_path / "magic_check.gguf"
buf = io.BytesIO()
buf.write(struct.pack("<I", 0x46554747))
raw = buf.getvalue()
# 'G' = 0x47, 'G' = 0x47, 'U' = 0x55, 'F' = 0x46
assert raw == b"GGUF"
def test_json_serialization_deterministic(self):
"""model_dump_json() is consistent across calls."""
resp = LoadResponse(
status = "loaded",
model = "test",
display_name = "Test",
inference = {},
native_context_length = 131072,
)
json1 = resp.model_dump_json()
json2 = resp.model_dump_json()
assert json1 == json2
assert '"native_context_length":131072' in json1