From 0c9c4f20aa6f9ddd62e1dc21e72c0d93a2fde43b Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Wed, 29 Jul 2026 08:39:55 +0000 Subject: [PATCH] Consolidate the tests without changing what they prove Test-only. No file under studio/frontend/src or the backend's routes, core, hub, utils or storage is touched. Backend: the override PUT was spelled out at 29 sites as a two-call expression with a local import each time, and 39 tests mocked the store by hand when a fixture does it. A `_put` helper and an `override_store` fixture take both. -186 lines, 273 tests still pass, and the assert count is unchanged at 624. Frontend: eight test files become five plus a shared kit holding the bundler-resolver registration, the localStorage fake and the chat-runtime store fakes that three files had each written out. The resident-status pair merge into one file, and the three identity/storage files into another. 62 tests, 139 assertions, both unchanged. Prose: multi-line docstrings and comment blocks in the test suites keep their opening statement, the rest being recoverable from history. That is most of the remaining reduction, because these tests are close to one line per assertion already. Two consolidations were measured and rejected rather than shipped. A table-driven form of the source-contract tests generates 930 lines to replace 773, since a row costs what an assert line costs. Parametrising the backend key-folding and carry-over families saves nothing once the helper above removes their boilerplate: what is left is the per-case reason, not repetition. Every mutation these tests were written to catch still reddens: reverting new-traffic.ts, adopt-inference-status.ts, settings.py and hub-page.tsx to their pre-fix parents each fails the expected tests. --- .../tests/test_openai_auto_download.py | 27 +- .../backend/tests/test_openai_auto_switch.py | 562 +++++------------ studio/frontend/tests/helpers/kit.ts | 131 ++++ .../tests/hub-resident-status-refresh.test.ts | 171 ----- ...us.test.ts => hub-resident-status.test.ts} | 235 ++++--- studio/frontend/tests/model-identity.test.ts | 344 +++++++++++ .../tests/model-settings-identity.test.ts | 176 ------ .../per-model-config-storage-identity.test.ts | 112 ---- .../frontend/tests/quant-suffix-split.test.ts | 83 --- tests/studio/test_model_picker_contracts.py | 584 ++++++------------ 10 files changed, 985 insertions(+), 1440 deletions(-) create mode 100644 studio/frontend/tests/helpers/kit.ts delete mode 100644 studio/frontend/tests/hub-resident-status-refresh.test.ts rename studio/frontend/tests/{hub-adopt-inference-status.test.ts => hub-resident-status.test.ts} (51%) create mode 100644 studio/frontend/tests/model-identity.test.ts delete mode 100644 studio/frontend/tests/model-settings-identity.test.ts delete mode 100644 studio/frontend/tests/per-model-config-storage-identity.test.ts delete mode 100644 studio/frontend/tests/quant-suffix-split.test.ts diff --git a/studio/backend/tests/test_openai_auto_download.py b/studio/backend/tests/test_openai_auto_download.py index 027df84e36..9653a5842d 100644 --- a/studio/backend/tests/test_openai_auto_download.py +++ b/studio/backend/tests/test_openai_auto_download.py @@ -112,8 +112,7 @@ def _hub_error(error_type, status_code: int, message: str): def test_the_hub_error_helper_carries_a_status_on_both_majors(): # CI runs huggingface_hub 1.x and this box 0.x, and each takes only one of the - # constructor shapes. A helper that silently dropped the response would make an - # error-mapping test pass here and fail there. + # constructor shapes. from hub.utils.hf_errors import hf_error_status class _Legacy(Exception): @@ -607,8 +606,7 @@ def test_a_hanging_auth_check_falls_through_to_the_download(hub, monkeypatch): def test_a_companion_only_repo_is_not_held_at_busy(hub): # mmproj and MTP files are companions, not quants, so such a repo is non-servable - # and falls through to the resident model. The busy probe accepted any .gguf, which - # stranded that ordinary traffic behind an unrelated multi-hour download. + # and falls through to the resident model. assert _run("unsloth/x-GGUF:UD-Q4_K_XL").code == "model_downloading" gb = 1024**3 hub["info"] = _Info([_Sibling("mmproj-F16.gguf", gb), _Sibling("mtp-model.gguf", gb)]) @@ -1399,8 +1397,7 @@ def test_a_cold_scan_that_never_finishes_says_so_instead_of_guessing(monkeypatch def test_a_refusal_is_never_swallowed_by_the_cannot_verify_handler(monkeypatch): # The checks run inside a broad `except Exception` that turns a failure to decide - # into a fallthrough. An HTTPException there is a decision, but was logged as a - # failure and answered by the resident model. + # into a fallthrough. loaded = _Loaded("unsloth/A-GGUF", "UD-Q4_K_XL") monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: loaded) monkeypatch.setattr( @@ -1466,8 +1463,7 @@ def test_a_stale_index_is_refreshed_so_a_hub_download_becomes_visible(monkeypatc def test_an_id_v1_models_advertised_is_refused_before_the_resolver_warms(monkeypatch): - # /v1/models can advertise an unloaded local GGUF while the resolver index is cold. A bare - # id has no quant to refuse on, so without that evidence the resident model would answer. + # /v1/models can advertise an unloaded local GGUF while the resolver index is cold. from core.inference import local_model_resolver as resolver monkeypatch.setattr(resolver, "_scan", (0.0, {})) @@ -1522,8 +1518,7 @@ def test_an_advertised_alias_for_the_resident_weights_is_still_served(monkeypatc def test_a_rejected_token_says_so_instead_of_asking_for_a_retry(hub): - # Hugging Face 401s an expired X-Unsloth-HF-Token. Only 403/404 were handled, so it - # fell through to a 503 telling the caller to retry something that cannot work. + # Hugging Face 401s an expired X-Unsloth-HF-Token. from huggingface_hub.utils import HfHubHTTPError hub["raise"] = _hub_error(HfHubHTTPError, 401, "unauthorized") @@ -1598,8 +1593,7 @@ def test_a_quant_request_is_not_satisfied_by_transformers_weights(monkeypatch): def test_a_timed_out_download_keeps_the_slot_while_it_is_still_running(monkeypatch): - # The watch window only bounds progress reporting. Releasing on the clock while - # the worker is alive would admit a second multi-GB download beside it. + # The watch window only bounds progress reporting. monkeypatch.setattr(auto_dl, "_MAX_WATCH_S", 0.0) monkeypatch.setattr(auto_dl, "_WATCH_POLL_S", 0.001) monkeypatch.setattr(auto_dl, "_TIMED_OUT_POLL_S", 0.001) @@ -1691,8 +1685,7 @@ def test_a_remote_tag_that_names_no_quant_picks_the_preferred_one(hub): def test_a_generic_gguf_advertises_the_label_the_worker_resolves(hub): # With no recognized quant token the extractors part ways: one takes the last - # hyphenated segment, the plan and worker key the whole stem. Dispatching ours - # made the worker exit with "No GGUF shards matching variant". + # hyphenated segment, the plan and worker key the whole stem. from hub.utils.gguf import extract_quant_label as canonical from hub.utils.gguf_plan import build_gguf_variant_plans @@ -1780,8 +1773,7 @@ def test_a_non_quant_tag_does_not_tear_down_a_serving_quant(monkeypatch): def test_the_trust_probe_never_falls_back_to_the_server_identity(hub, monkeypatch): # huggingface_hub treats None as "use the cached login", so only an explicit False - # is anonymous. This probe passed None, so a caller-named repo was read with the - # server's identity. + # is anonymous. seen: list = [] def _probe(model_name, hf_token = None): @@ -1851,8 +1843,7 @@ def test_the_retry_after_a_failure_is_told_instead_of_restarting_it(hub, monkeyp def test_a_completed_download_does_not_restage_the_scan_it_just_warmed(monkeypatch): - # finalize_worker_exit invalidates and warms. A second invalidation here marks - # that fresh scan stale and pushes a synchronous rescan onto the client's retry. + # finalize_worker_exit invalidates and warms. import inspect src = inspect.getsource(auto_dl._watch) diff --git a/studio/backend/tests/test_openai_auto_switch.py b/studio/backend/tests/test_openai_auto_switch.py index 5585ce62e0..4776c00b6f 100644 --- a/studio/backend/tests/test_openai_auto_switch.py +++ b/studio/backend/tests/test_openai_auto_switch.py @@ -248,8 +248,7 @@ def test_same_repo_same_variant_does_not_reload(monkeypatch): def test_responses_endpoint_wires_auto_switch_before_dispatch(): # The /v1/responses endpoint must invoke the auto-switch hook before either - # dispatcher so streaming requests switch too. Asserted on the source, which - # is immune to test-ordering effects on the shared inference module. + # dispatcher so streaming requests switch too. import inspect src = inspect.getsource(inference_route.openai_responses) @@ -281,9 +280,7 @@ def test_count_tokens_endpoint_wires_auto_switch_before_loaded_check(): def test_openai_compat_routes_bound_to_handlers_with_auth(): # Inserting a helper between a @router.post decorator and its handler silently # rebinds the route to the helper and drops its auth dependency (this happened to - # /messages/count_tokens). The source-inspection tests above miss it because they - # call the handler directly. Lock the path -> (handler, auth) mapping at the route - # level so any decorator/handler split is caught. + # /messages/count_tokens). expected = { ("POST", "/chat/completions"): "openai_chat_completions", ("POST", "/completions"): "openai_completions", @@ -342,10 +339,8 @@ def test_local_gguf_entry_filters_non_gguf_and_recurses(tmp_path): def test_local_gguf_entry_rejects_standalone_mmproj(tmp_path): - # Codex P2: _scan_models_dir's standalone-.gguf pass emits an entry for a - # bare mmproj projector (it only filters mmproj inside directory scans). A - # projector is not a servable model, so the resolver must reject it or - # /v1/models advertises it and a switch could load it over the real weights. + # Codex P2: _scan_models_dir's standalone-.gguf pass emits an entry for a bare + # mmproj projector (it only filters mmproj inside directory scans). from types import SimpleNamespace proj = tmp_path / "mmproj-F16.gguf" @@ -385,9 +380,8 @@ def test_resolver_matches_and_splits_variant(monkeypatch): def test_resolver_failsafe_on_internal_error(monkeypatch): - # Resolution is best-effort: any internal failure must fall through to None - # so the request still serves the loaded model instead of 500-ing. The hook - # calls resolve_local_gguf without its own guard, so the guard lives here. + # Resolution is best-effort: any internal failure must fall through to None so the + # request still serves the loaded model instead of 500-ing. def boom(): raise RuntimeError("scan blew up") @@ -444,8 +438,7 @@ def test_describe_local_miss_is_failsafe(monkeypatch): def test_resolver_exact_id_with_colon_wins(monkeypatch): - # A local id that itself contains a colon (e.g. a Windows path) must match - # exactly rather than being split at the drive-letter colon. + # A local id that itself contains a colon (e.g. win = r"C:\models\foo.gguf" monkeypatch.setattr(resolver, "_build_index", lambda: {win.lower(): _entry(win)}) resolver._scan = (0.0, {}) @@ -731,6 +724,21 @@ def _mock_override_store(monkeypatch): return store + +@pytest.fixture +def override_store(monkeypatch): + """The in-memory override store, for a test that needs nothing else mocked.""" + _mock_override_store(monkeypatch) + + +def _put(model_id, **fields): + """One override PUT through the route, spelled the way the UI sends it.""" + import routes.settings as settings_route + + return settings_route.update_openai_auto_switch_override( + settings_route.ModelOverridePayload(model_id = model_id, **fields), "tester", + ) + def test_model_override_roundtrip(monkeypatch): _mock_override_store(monkeypatch) @@ -1069,9 +1077,7 @@ def test_embeddings_malformed_body_503_not_500_when_unloaded(monkeypatch): def test_non_string_model_falls_through_without_error(monkeypatch): - # A non-string model (e.g. {"model": 123} on a raw-body endpoint) must be - # treated as absent, never raising in the membership checks, even when a stash - # exists from idle-unload. + # A non-string model (e.g. from core.inference import llama_keepwarm as kw backend = _FakeBackend(None) @@ -1230,8 +1236,7 @@ def _json_body_request(payload): def test_completions_list_body_is_400_not_500(monkeypatch): - # A valid JSON non-dict body (e.g. a list) on a loaded backend is a clean 400, - # not a 500 from body.get(...). + # A valid JSON non-dict body (e.g. from fastapi import HTTPException backend = _FakeBackend("unsloth/A-GGUF") # loaded @@ -1427,8 +1432,7 @@ def _revision_pair(root, complete: bool): def test_sibling_revision_resolves_to_its_own_weights(tmp_path): # /v1/models advertises only the snapshot dir name, so a durable pin holds one - # revision hash. A newer snapshot must not strand it, and the old revision must - # resolve to ITS OWN directory rather than be redirected onto the newest. + # revision hash. old, new = _revision_pair(tmp_path, complete = True) found = dict(resolver._sibling_revision_entries(str(new), "org/Repo")) @@ -1464,9 +1468,8 @@ def test_sibling_revisions_skip_plain_repo_ids(): def test_already_loaded_by_repo_id_is_not_reswapped(monkeypatch): - # A model loaded normally has model_identifier == repo id, but the resolver - # returns the concrete load path. A request for that repo must count as already - # serving (no reload, no 409) even with another inference active. + # A model loaded normally has model_identifier == repo id, but the resolver returns + # the concrete load path. from core.inference import llama_keepwarm as kw backend = _FakeBackend("org/Repo-GGUF", hf_variant = "Q4_K_M") @@ -1526,10 +1529,7 @@ def test_already_serving_by_path_records_advertised_alias(monkeypatch): def test_streaming_responses_uses_advertised_id_helper(): # Codex P2: streamed /v1/responses envelopes must derive the model id from # _llama_public_model_id (which prefers _openai_advertised_id), not the raw - # model_identifier. After an auto-switch to a cached HF GGUF the identifier is - # the snapshot path while the repo id lives in _openai_advertised_id, so the raw - # form would stream a snapshot basename while /v1/models, chat, and non-streaming - # responses report the repo id. + # model_identifier. import inspect src = inspect.getsource(inference_route._responses_stream) @@ -1538,9 +1538,8 @@ def test_streaming_responses_uses_advertised_id_helper(): def test_concurrent_same_target_requests_load_once(monkeypatch): - # Two concurrent requests for the same unloaded model must load once, not each - # 409 the other. Simulate the second request already waiting (registered) while - # the first runs the hook with _inflight counting both. + # Two concurrent requests for the same unloaded model must load once, not each 409 + # the other. from core.inference import llama_keepwarm as kw backend = _FakeBackend("org/A-GGUF") @@ -1595,9 +1594,7 @@ def test_v1_models_advertises_repo_id_not_load_path(monkeypatch): def test_idle_alias_reload_preserves_override_via_advertised_id(monkeypatch): - # The idle stash carries (load_path, quant, advertised_id). An alias reload must - # look up the override by the advertised repo id, not the concrete load path, - # so the user's saved launch flags survive the unload/reload. + # The idle stash carries (load_path, quant, advertised_id). from core.inference import llama_keepwarm as kw backend = _FakeBackend(None) # idle-unload emptied the slot @@ -1624,9 +1621,8 @@ def test_load_route_holds_lifecycle_gate(monkeypatch): def test_model_replacements_recheck_sidecar_swap_before_either_backend_is_unloaded(): - # Both replacement directions drain, then recheck whether a sidecar install reserved the - # gate meanwhile. That recheck is the last thing that can reject the load, so the - # destructive cancel must follow it. Exact-model reuse exits earlier and never waits. + # Both replacement directions drain, then recheck whether a sidecar install reserved + # the gate meanwhile. import inspect src = inspect.getsource(inference_route._load_model_impl) @@ -1719,9 +1715,8 @@ def test_pending_same_target_request_does_not_block_swap(monkeypatch): def test_swap_waits_until_concurrent_request_finishes_resolving(monkeypatch): - # The real middleware counts a concurrent same-model request as in-flight - # before it resolves and registers a target waiter. Treat it as active until - # its target is known, then recognize it as another queued switch request. + # The real middleware counts a concurrent same-model request as in-flight before it + # resolves and registers a target waiter. from core.inference import llama_keepwarm as kw backend = _FakeBackend("org/A-GGUF") @@ -1786,7 +1781,6 @@ def test_manual_unload_interrupts_even_while_inference_active(monkeypatch): def test_auto_switch_waits_when_unsloth_stream_active(monkeypatch): # The GGUF slot is empty but an Unsloth model is streaming (counted in-flight). - # The replacement waits for it just as it does for a GGUF generation. from core.inference import llama_keepwarm as kw backend = _FakeBackend(None) # no GGUF loaded @@ -2071,8 +2065,7 @@ def test_index_advertises_alias_not_filesystem_path(tmp_path, monkeypatch): def test_build_index_survives_a_failing_scanner(tmp_path, monkeypatch): - # gemini: one bad scanner (e.g. a permission error on ./models) must drop only - # that source, not abort the whole index and lose what the others found. + # gemini: one bad scanner (e.g. from types import SimpleNamespace import routes.models as models_route import utils.paths as paths @@ -2103,9 +2096,8 @@ def test_build_index_survives_a_failing_scanner(tmp_path, monkeypatch): def test_info_has_local_gguf_reads_files_not_model_format(tmp_path): - # Codex: HF-cache GGUF snapshots leave model_format unset, so /v1/models must - # decide GGUF-ness from the on-disk files. A standalone .gguf (no model_format) - # is servable; a safetensors-only dir is not. + # Codex: HF-cache GGUF snapshots leave model_format unset, so /v1/models must decide + # GGUF-ness from the on-disk files. from types import SimpleNamespace gguf = tmp_path / "model-Q4_K_M.gguf" @@ -2187,10 +2179,8 @@ def test_retrieve_model_tolerates_non_string_id(monkeypatch): def test_retrieve_model_resolves_raw_path_to_advertised_id(monkeypatch): - # Codex P2: a client caching the legacy absolute .gguf path must still retrieve - # a loaded auto-switch model. Its /v1/models entry is keyed by the advertised - # repo id (identifier = snapshot path), so the raw-path fallback must map the raw - # id to that advertised id, not public_model_id(path), or a loaded model 404s. + # Codex P2: a client caching the legacy absolute .gguf path must still retrieve a + # loaded auto-switch model. from types import SimpleNamespace raw_path = "/cache/models--org--B-GGUF/snapshots/abc/model.gguf" @@ -2237,8 +2227,7 @@ def test_chat_streaming_n_gt_1_rejected_before_switch(monkeypatch): def test_resolver_cache_stamped_after_slow_build(monkeypatch): - # Codex P2: the cache must be stamped AFTER _build_index. A scan slower than the - # TTL would otherwise store an already-expired cache and rebuild every request. + # Codex P2: the cache must be stamped AFTER _build_index. import core.inference.local_model_resolver as r clock = {"t": 1000.0} @@ -2372,8 +2361,7 @@ def test_embeddings_missing_input_rejected_before_idle_reload(monkeypatch): def test_messages_does_not_503_before_reload_hook_when_idle_on(monkeypatch): # #3: /v1/messages 503'd before the reload hook when auto-switch was off, so a - # standalone idle TTL could never restore the freed model. The early 503 now - # defers to any automatic-load trigger, so the reload hook runs. + # standalone idle TTL could never restore the freed model. backend = _FakeBackend(None) rec = _LoadRecorder(backend) _wire(monkeypatch, enabled = False, resolves_to = None, backend = backend, recorder = rec) @@ -2401,8 +2389,7 @@ def test_messages_503_gated_on_automatic_load_predicate(): def test_raw_body_without_model_reloads_freed_model(monkeypatch): # #6: a raw completions/embeddings body that omits `model` passed None, which - # skipped the idle-stash reload and 503'd. A non-empty sentinel now lets the - # reload run while still resolving as unknown. + # skipped the idle-stash reload and 503'd. backend = _FakeBackend(None) rec = _LoadRecorder(backend) _wire(monkeypatch, enabled = False, resolves_to = None, backend = backend, recorder = rec) @@ -2457,8 +2444,7 @@ def test_audio_generate_does_not_reload_on_invalid_request(monkeypatch): def test_preview_scope_disables_auto_switch(monkeypatch): # #7: the public preview route delegates to the chat handler; a caller-supplied - # model must not switch away from the pinned checkpoint. The scope opt-out flag - # makes the hook a no-op. + # model must not switch away from the pinned checkpoint. backend = _FakeBackend("org/A-GGUF") rec = _LoadRecorder(backend) _wire( @@ -2533,9 +2519,8 @@ def test_note_start_does_not_reset_idle_timer(): def test_omitted_model_does_not_resolve_to_a_named_gguf(monkeypatch): - # Codex P2: a raw-body request that omits `model` must never run the resolver, - # so a downloaded GGUF literally named "default" can't be switched to. The - # resolver here would switch to B if it ran; it must not. + # Codex P2: a raw-body request that omits `model` must never run the resolver, so a + # downloaded GGUF literally named "default" can't be switched to. backend = _FakeBackend("org/A-GGUF") # a model is already loaded rec = _LoadRecorder(backend) _wire( @@ -2853,9 +2838,8 @@ def test_chat_confirm_with_bypass_permissions_reaches_hook(monkeypatch): def test_chat_audio_input_guards_target_before_switch(monkeypatch): # Codex P2: a chat request carrying audio_base64 must guard the target before the - # switch -- audio rides the same companion mmproj as vision -- so a text-only - # target can't be loaded and evict the working audio model. Assert the handler - # flags require_vision so the hook's multimodal probe runs. + # switch -- audio rides the same companion mmproj as vision -- so a text-only target + # can't be loaded and evict the working audio model. class _Reached(Exception): pass @@ -2881,8 +2865,7 @@ def test_chat_audio_input_guards_target_before_switch(monkeypatch): def test_completions_rejects_object_prompt_before_switch(monkeypatch): # Codex P2: an object prompt like {"prompt": {}} is a deterministic client error - # (only a string or array is valid). It must 400 before the switch so a bad shape - # can't load the named GGUF only to be rejected by llama-server after eviction. + # (only a string or array is valid). from fastapi import HTTPException backend = _FakeBackend("org/A-GGUF") @@ -3089,8 +3072,7 @@ def test_anthropic_request_has_image_helper(): def test_responses_and_anthropic_wire_require_vision_from_images(): # P2: the modality guard must fire on /v1/responses and /v1/messages too, so an - # image request can't evict a vision model for a text-only target. Lock the wiring - # at the source: each hook derives require_vision from the request's images. + # image request can't evict a vision model for a text-only target. import inspect responses_src = inspect.getsource(inference_route.openai_responses) @@ -3155,11 +3137,7 @@ def test_count_tokens_forwards_vision_guard_to_switch(monkeypatch): def test_audio_generate_is_reload_only(monkeypatch): - # Codex P2: /audio/generate must not switch to a client-named GGUF. A local - # GGUF's audio-input capability is not a cheap pre-load probe (the mmproj signal - # can't tell an audio projector from a vision one), so resolving the client model - # could evict the working audio model for a target that then fails the audio - # check. Only the idle-stash restore runs: the hook gets the reload-only sentinel. + # Codex P2: /audio/generate must not switch to a client-named GGUF. from models.inference import ChatCompletionRequest class _Reached(Exception): @@ -3188,8 +3166,7 @@ def test_audio_generate_is_reload_only(monkeypatch): def test_note_model_unloaded_clears_reload_stash(monkeypatch): # Codex P2: a deliberate unload must drop the idle reload stash so the next /v1 - # request can't resurrect the just-unloaded model. (The idle loop unloads via the - # backend directly, so clearing on the route never fights keep-warm.) + # request can't resurrect the just-unloaded model. import core.inference.llama_keepwarm as kw kw._set_last_unloaded(("org/A-GGUF", "Q4_K_M")) @@ -3285,9 +3262,8 @@ def test_lifecycle_gate_serializes_across_loops(): def test_auto_switch_serializes_across_event_loops(monkeypatch): - # Codex P2: the per-loop asyncio lock can't serialize two swaps on different - # event loops in one process. The process-wide gate must, so the two slow loads - # never overlap on the single model slot. + # Codex P2: the per-loop asyncio lock can't serialize two swaps on different event + # loops in one process. import threading backend = _FakeBackend("org/A-GGUF") @@ -3339,10 +3315,8 @@ def test_auto_switch_serializes_across_event_loops(monkeypatch): def test_acquire_swap_gate_is_cancellation_safe(): - # A waiter cancelled while waiting for the gate (client disconnect mid-swap) - # must not leak it: after the holder releases, a fresh acquire still succeeds. - # The to_thread(acquire) approach would leak here -- its worker thread keeps - # acquiring after cancel, so the gate is taken but never released. + # A waiter cancelled while waiting for the gate (client disconnect mid-swap) must + # not leak it: after the holder releases, a fresh acquire still succeeds. async def main(): await inference_route._acquire_swap_gate() # this loop holds the gate try: @@ -3365,9 +3339,8 @@ def test_acquire_swap_gate_is_cancellation_safe(): def test_no_model_loaded_detail_appends_hint_only_when_off(monkeypatch): - # The "no model loaded" errors point at the opt-in auto-switch toggle so a - # request naming a listed-but-unloaded model is self-explanatory -- but only - # when it's off. With it on the name simply didn't resolve, so no hint. + # The "no model loaded" errors point at the opt-in auto-switch toggle so a request + # naming a listed-but-unloaded model is self-explanatory -- but only when it's off. base = "No GGUF model loaded. Load a GGUF model first." monkeypatch.setattr(settings, "get_openai_auto_switch_enabled", lambda: False) @@ -3408,8 +3381,7 @@ def _run_responses_stream_no_model( def test_responses_stream_hint_matches_toggle_regardless_of_active_model(monkeypatch): - # The hint attaches whenever the toggle is off, whatever is active. With it on the name - # resolved to nothing local, so 404 rather than 400. + # The hint attaches whenever the toggle is off, whatever is active. off_status, hinted = _run_responses_stream_no_model( monkeypatch, enabled = False, active_model_name = None ) @@ -4259,10 +4231,7 @@ def test_model_override_load_kwargs_gates_gpu_placement_on_gguf(): def test_saved_parallel_slots_reach_an_api_load(monkeypatch): - # Parallel decode slots are a per-model setting the picker sends on every GGUF - # load. Without them here an API auto-switch of the same model silently falls - # back to the server-wide --parallel default, and llama_extra_args cannot stand - # in for it: --parallel is on the managed denylist. + # Parallel decode slots are a per-model setting the picker sends on every GGUF load. backend = _FakeBackend(None) rec = _LoadRecorder(backend) _wire( @@ -4292,45 +4261,28 @@ def test_parallel_slots_are_stored_and_gated_on_gguf(): LoadRequest(model_path = "unsloth/B-GGUF", **gguf) -def test_override_route_persists_parallel_slots(monkeypatch): +def test_override_route_persists_parallel_slots(override_store): # The mirror the picker writes has to carry the field, or a config whose only # change is the slot count saves as an empty entry. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF:Q4_K_M", n_parallel = 8), - "tester", - ) + resp = _put("unsloth/B-GGUF:Q4_K_M", n_parallel = 8) assert resp.overrides["unsloth/B-GGUF:Q4_K_M"] == {"n_parallel": 8} -def test_eviction_cleanup_clears_mirrored_fields_but_keeps_launch_flags(monkeypatch): - # Dropping a local entry to stay inside the browser's storage budget is not the - # user forgetting the model, so the cleanup sends remove=false with no fields. - # That must stop the mirrored settings applying without taking launch flags the - # settings API set and the settings page can neither show nor restore. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_eviction_cleanup_clears_mirrored_fields_but_keeps_launch_flags(override_store): + # Dropping a local entry to stay inside the browser's storage budget is not the user + # forgetting the model, so the cleanup sends remove=false with no fields. settings.set_model_override( "unsloth/B-GGUF:Q4_K_M", llama_extra_args = ["--flash-attn"], custom_context_length = 32768, kv_cache_dtype = "q8_0", ) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF:Q4_K_M", remove = False), - "tester", - ) + resp = _put("unsloth/B-GGUF:Q4_K_M", remove = False) assert resp.overrides["unsloth/B-GGUF:Q4_K_M"] == {"llama_extra_args": ["--flash-attn"]} # Nothing server-owned left, so the row goes rather than lingering empty. settings.set_model_override("unsloth/C-GGUF:Q4_K_M", custom_context_length = 32768) - gone = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/C-GGUF:Q4_K_M", remove = False), - "tester", - ) + gone = _put("unsloth/C-GGUF:Q4_K_M", remove = False) assert "unsloth/C-GGUF:Q4_K_M" not in gone.overrides @@ -4375,32 +4327,17 @@ def test_auto_switch_falls_back_to_bare_repo_override(monkeypatch): assert rec.calls[0].max_seq_length == 1024 -def test_override_route_preserves_launch_flags_across_a_settings_only_update(monkeypatch): +def test_override_route_preserves_launch_flags_across_a_settings_only_update(override_store): # The settings page has no control for llama_extra_args, so it omits the field. - # Omitted must mean "leave it alone", or every UI save wipes flags set elsewhere. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/B-GGUF", llama_extra_args = ["--flash-attn"] - ), - "tester", - ) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF", max_seq_length = 4096), - "tester", - ) + _put("unsloth/B-GGUF", llama_extra_args = ["--flash-attn"]) + resp = _put("unsloth/B-GGUF", max_seq_length = 4096) entry = resp.overrides["unsloth/B-GGUF"] assert entry["llama_extra_args"] == ["--flash-attn"] assert entry["max_seq_length"] == 4096 # An explicit empty list is the UI's "forget this model", and with no other # fields left it removes the entry outright. - gone = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF", llama_extra_args = []), - "tester", - ) + gone = _put("unsloth/B-GGUF", llama_extra_args = []) assert "unsloth/B-GGUF" not in gone.overrides @@ -4447,51 +4384,29 @@ def test_path_qualified_override_beats_repo_qualified(monkeypatch): assert rec.calls[0].max_seq_length == 1024 -def test_first_quant_save_keeps_legacy_bare_repo_launch_flags(monkeypatch): - # Flags predating per-quant settings live under the bare repo id. The first save - # writes repo:QUANT, which auto-switch prefers, so the flags must come with it - # or they are silently disabled with no UI able to restore them. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_first_quant_save_keeps_legacy_bare_repo_launch_flags(override_store): + # Flags predating per-quant settings live under the bare repo id. settings.set_model_override("unsloth/B-GGUF", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF:Q4_K_M", max_seq_length = 4096), - "tester", - ) + resp = _put("unsloth/B-GGUF:Q4_K_M", max_seq_length = 4096) entry = resp.overrides["unsloth/B-GGUF:Q4_K_M"] assert entry["max_seq_length"] == 4096 assert entry["llama_extra_args"] == ["--flash-attn"] -def test_bare_repo_carry_over_does_not_split_a_windows_path(monkeypatch): +def test_bare_repo_carry_over_does_not_split_a_windows_path(override_store): # The colon in "C:\models\x.gguf" is not a variant separator: splitting naively # looks up "C" and could graft another model's flags on. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("C", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = r"C:\models\x.gguf", max_seq_length = 4096), - "tester", - ) + resp = _put(r"C:\models\x.gguf", max_seq_length = 4096) assert "llama_extra_args" not in resp.overrides[r"C:\models\x.gguf"] -def test_windows_path_with_quant_still_carries_over(monkeypatch): - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_windows_path_with_quant_still_carries_over(override_store): settings.set_model_override(r"C:\models\x.gguf", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = r"C:\models\x.gguf:Q4_K_M", max_seq_length = 4096 - ), - "tester", - ) + resp = _put(r"C:\models\x.gguf:Q4_K_M", max_seq_length = 4096) assert resp.overrides[r"C:\models\x.gguf:Q4_K_M"]["llama_extra_args"] == ["--flash-attn"] @@ -4584,84 +4499,53 @@ def test_vulkan_probe_without_a_binary_does_not_block_the_load(monkeypatch): assert asyncio.run(inference_route._override_gpu_ids_still_resolve([0])) is True -def test_default_save_preserves_flags_instead_of_removing(monkeypatch): +def test_default_save_preserves_flags_instead_of_removing(override_store): # "Remember for this model" with all-default values sends no fields, which is # shape-identical to a removal; guessing wrong wipes unrecoverable launch flags. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("unsloth/B-GGUF", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF", remove = False), - "tester", - ) + resp = _put("unsloth/B-GGUF", remove = False) assert resp.overrides["unsloth/B-GGUF"]["llama_extra_args"] == ["--flash-attn"] -def test_explicit_remove_still_clears_everything(monkeypatch): - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_explicit_remove_still_clears_everything(override_store): settings.set_model_override( "unsloth/B-GGUF", llama_extra_args = ["--flash-attn"], max_seq_length = 4096 ) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/B-GGUF", remove = True, llama_extra_args = [] - ), - "tester", - ) + resp = _put("unsloth/B-GGUF", remove = True, llama_extra_args = []) assert "unsloth/B-GGUF" not in resp.overrides -def test_bare_payload_without_remove_flag_still_removes(monkeypatch): +def test_bare_payload_without_remove_flag_still_removes(override_store): # The original contract, kept for any caller that predates the flag. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("unsloth/B-GGUF", max_seq_length = 4096) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF"), "tester" - ) + resp = _put("unsloth/B-GGUF") assert "unsloth/B-GGUF" not in resp.overrides -def test_remove_false_with_real_fields_saves_normally(monkeypatch): - import routes.settings as settings_route - - _mock_override_store(monkeypatch) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/B-GGUF", remove = False, max_seq_length = 8192 - ), - "tester", - ) +def test_remove_false_with_real_fields_saves_normally(override_store): + resp = _put("unsloth/B-GGUF", remove = False, max_seq_length = 8192) assert resp.overrides["unsloth/B-GGUF"]["max_seq_length"] == 8192 -def test_override_lookup_falls_back_to_case_insensitive(monkeypatch): - # The browser lowercases ids, so the backfill writes - # "unsloth/qwen3-8b-gguf:q4_k_m" while the resolver asks for the repo's real - # casing. Without this fallback every migrated entry is invisible. - _mock_override_store(monkeypatch) +def test_override_lookup_falls_back_to_case_insensitive(override_store): + # The browser lowercases ids, so the backfill writes "unsloth/qwen3-8b-gguf:q4_k_m" + # while the resolver asks for the repo's real casing. settings.set_model_override("unsloth/qwen3-8b-gguf:q4_k_m", max_seq_length = 8192) got = settings.get_model_override("unsloth/Qwen3-8B-GGUF:Q4_K_M") assert got["max_seq_length"] == 8192 -def test_exact_override_match_beats_a_case_variant(monkeypatch): - _mock_override_store(monkeypatch) +def test_exact_override_match_beats_a_case_variant(override_store): settings.set_model_override("/models/foo.gguf", max_seq_length = 1024) settings.set_model_override("/models/Foo.gguf", max_seq_length = 8192) assert settings.get_model_override("/models/Foo.gguf")["max_seq_length"] == 8192 assert settings.get_model_override("/models/foo.gguf")["max_seq_length"] == 1024 -def test_ambiguous_case_fallback_matches_nothing(monkeypatch): +def test_ambiguous_case_fallback_matches_nothing(override_store): # Two POSIX paths differing only in case are two files, so guessing between # them applies one model's settings to another. - _mock_override_store(monkeypatch) settings.set_model_override("/models/foo.gguf", max_seq_length = 1024) settings.set_model_override("/models/FOO.gguf", max_seq_length = 8192) assert settings.get_model_override("/models/Foo.gguf") == {} @@ -4684,158 +4568,105 @@ def test_request_used_api_key_distinguishes_key_from_session(): assert inference_route._request_used_api_key(object()) is False -def test_case_fallback_never_applies_to_a_posix_path(monkeypatch): +def test_case_fallback_never_applies_to_a_posix_path(override_store): # Two files differing only in case are two models on Linux, so a near miss must # load defaults rather than another model's context and GPU pin. - _mock_override_store(monkeypatch) settings.set_model_override("/models/foo.gguf", max_seq_length = 8192, gpu_ids = [1]) assert settings.get_model_override("/models/Foo.gguf") == {} assert settings.get_model_override("/models/foo.gguf")["max_seq_length"] == 8192 -def test_case_fallback_does_apply_to_a_windows_path(monkeypatch): - # NTFS is case-insensitive, so these name one file and the browser folds drive - # paths before storing. Treating them as two would strand every migrated Windows - # entry: the opposite of the POSIX rule, for the opposite reason. The separator - # is interchangeable there too. - _mock_override_store(monkeypatch) +def test_case_fallback_does_apply_to_a_windows_path(override_store): + # NTFS is case-insensitive, so these name one file and the browser folds drive paths + # before storing. settings.set_model_override(r"c:\models\foo.gguf", max_seq_length = 8192) assert settings.get_model_override(r"C:\models\FOO.gguf")["max_seq_length"] == 8192 assert settings.get_model_override("C:/Models/Foo.gguf")["max_seq_length"] == 8192 -def test_case_fallback_applies_to_unc_and_wsl_drive_paths(monkeypatch): - _mock_override_store(monkeypatch) +def test_case_fallback_applies_to_unc_and_wsl_drive_paths(override_store): settings.set_model_override(r"\\server\share\foo.gguf", max_seq_length = 4096) settings.set_model_override("/mnt/c/models/bar.gguf", max_seq_length = 2048) assert settings.get_model_override(r"\\Server\Share\FOO.gguf")["max_seq_length"] == 4096 assert settings.get_model_override("/mnt/C/Models/Bar.gguf")["max_seq_length"] == 2048 -def test_a_plain_posix_path_under_mnt_stays_case_sensitive(monkeypatch): +def test_a_plain_posix_path_under_mnt_stays_case_sensitive(override_store): # Only /mnt/ is a WSL drive mount; /mnt/data is an ordinary Linux mount # point and stays case-sensitive. - _mock_override_store(monkeypatch) settings.set_model_override("/mnt/data/models/foo.gguf", max_seq_length = 8192) assert settings.get_model_override("/mnt/data/models/Foo.gguf") == {} -def test_an_ambiguous_windows_case_fallback_still_matches_nothing(monkeypatch): +def test_an_ambiguous_windows_case_fallback_still_matches_nothing(override_store): # Two stored keys folding to one has no single answer, so the load takes # defaults rather than guessing. - _mock_override_store(monkeypatch) settings.set_model_override(r"c:\models\foo.gguf", max_seq_length = 1024) settings.set_model_override("C:/models/FOO.gguf", max_seq_length = 8192) assert settings.get_model_override(r"C:\Models\Foo.gguf") == {} -def test_case_fallback_still_covers_repo_ids(monkeypatch): +def test_case_fallback_still_covers_repo_ids(override_store): # The migration case this fallback exists for. - _mock_override_store(monkeypatch) settings.set_model_override("unsloth/qwen3-8b-gguf:q4_k_m", max_seq_length = 8192) assert settings.get_model_override("unsloth/Qwen3-8B-GGUF:Q4_K_M")["max_seq_length"] == 8192 -def test_explicit_remove_is_not_blocked_by_stale_invalid_flags(monkeypatch): +def test_explicit_remove_is_not_blocked_by_stale_invalid_flags(override_store): # remove is the operation discriminator, so a rejected launch flag must not turn - # "forget this model" into a 400 that leaves the override in place. Nothing is - # stored on this path, so there is nothing to validate. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) + # "forget this model" into a 400 that leaves the override in place. settings.set_model_override("unsloth/B-GGUF", max_seq_length = 4096) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/B-GGUF", remove = True, llama_extra_args = ["--port", "1234"] - ), - "tester", - ) + resp = _put("unsloth/B-GGUF", remove = True, llama_extra_args = ["--port", "1234"]) assert "unsloth/B-GGUF" not in resp.overrides -def test_explicit_remove_wins_over_config_fields_in_the_same_payload(monkeypatch): +def test_explicit_remove_wins_over_config_fields_in_the_same_payload(override_store): # remove is the operation discriminator, so a stale field alongside it must not # turn "forget this model" into an update. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("unsloth/B-GGUF", max_seq_length = 4096) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/B-GGUF", remove = True, max_seq_length = 8192, tensor_parallel = True - ), - "tester", - ) + resp = _put("unsloth/B-GGUF", remove = True, max_seq_length = 8192, tensor_parallel = True) assert "unsloth/B-GGUF" not in resp.overrides -def test_posix_colon_in_a_path_is_not_treated_as_a_quant(monkeypatch): +def test_posix_colon_in_a_path_is_not_treated_as_a_quant(override_store): # "/models/foo:bar.gguf" is one POSIX filename, not repo + quant; splitting it # grafts /models/foo's launch flags onto a different model. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("/models/foo", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "/models/foo:bar.gguf", max_seq_length = 4096), - "tester", - ) + resp = _put("/models/foo:bar.gguf", max_seq_length = 4096) assert "llama_extra_args" not in resp.overrides["/models/foo:bar.gguf"] -def test_unknown_quant_label_on_a_gguf_still_carries_flags_over(monkeypatch): - # A .gguf with no recognizable quant token is labelled by its stem, so the UI - # saves under "/models/custom.gguf:custom". Refusing that suffix dropped the bare - # entry's legacy flags on the first save, and auto-switch prefers the qualified - # entry, so nothing was left to restore them. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_unknown_quant_label_on_a_gguf_still_carries_flags_over(override_store): + # A .gguf with no recognizable quant token is labelled by its stem, so the UI saves + # under "/models/custom.gguf:custom". settings.set_model_override("/models/custom.gguf", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "/models/custom.gguf:custom", max_seq_length = 4096 - ), - "tester", - ) + resp = _put("/models/custom.gguf:custom", max_seq_length = 4096) assert resp.overrides["/models/custom.gguf:custom"]["llama_extra_args"] == ["--flash-attn"] -def test_bpw_qualified_variants_still_carry_flags_over(monkeypatch): - # utils/models/model_config.py keeps a bits-per-weight modifier on the label to - # keep two files at the same base quant distinct, and that form reaches the - # override keys. The known-quant pattern rejects it, so the bare entry was missed - # and the first qualified save dropped its launch flags. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_bpw_qualified_variants_still_carry_flags_over(override_store): + # utils/models/model_config.py keeps a bits-per-weight modifier on the label to keep + # two files at the same base quant distinct, and that form reaches the override + # keys. settings.set_model_override("unsloth/Repo-GGUF", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/Repo-GGUF:IQ4_XS-3.53bpw", max_seq_length = 4096 - ), - "tester", - ) + resp = _put("unsloth/Repo-GGUF:IQ4_XS-3.53bpw", max_seq_length = 4096) assert resp.overrides["unsloth/Repo-GGUF:IQ4_XS-3.53bpw"]["llama_extra_args"] == [ "--flash-attn" ] -def test_a_posix_path_variant_folds_while_the_path_does_not(monkeypatch): +def test_a_posix_path_variant_folds_while_the_path_does_not(override_store): # The browser lowercases the quant but keeps POSIX path casing, so the migrated # "/models/Foo:q4_k_m" must answer the scanner's "/models/Foo:Q4_K_M" while the # path itself stays case-sensitive. - _mock_override_store(monkeypatch) settings.set_model_override("/models/Foo:q4_k_m", max_seq_length = 8192) assert settings.get_model_override("/models/Foo:Q4_K_M")["max_seq_length"] == 8192 assert settings.get_model_override("/models/foo:Q4_K_M") == {} -def test_an_unknown_gguf_label_is_reachable_in_either_casing(monkeypatch): +def test_an_unknown_gguf_label_is_reachable_in_either_casing(override_store): # A .gguf with no recognizable quant token is labelled by its stem, and v2 storage - # lowercases that label while the scanner keeps the filename casing. Folding only - # recognized labels stranded exactly the files that need the fallback. - _mock_override_store(monkeypatch) + # lowercases that label while the scanner keeps the filename casing. settings.set_model_override("/models/CustomModel.gguf:custommodel", max_seq_length = 8192) got = settings.get_model_override("/models/CustomModel.gguf:CustomModel") assert got["max_seq_length"] == 8192 @@ -4843,55 +4674,32 @@ def test_an_unknown_gguf_label_is_reachable_in_either_casing(monkeypatch): assert settings.get_model_override("/models/custommodel.gguf:CustomModel") == {} -def test_a_posix_colon_filename_is_not_folded_as_a_variant(monkeypatch): +def test_a_posix_colon_filename_is_not_folded_as_a_variant(override_store): # "/models/foo:Bar.gguf" is one filename, not path + quant, so folding its tail # would reach a different file's settings. - _mock_override_store(monkeypatch) settings.set_model_override("/models/foo:bar.gguf", max_seq_length = 8192) assert settings.get_model_override("/models/foo:Bar.gguf") == {} -def test_a_suffix_the_scanner_would_not_derive_carries_nothing_over(monkeypatch): +def test_a_suffix_the_scanner_would_not_derive_carries_nothing_over(override_store): # Only the scanner's exact label is accepted, so an unrelated colon suffix cannot # reach another model's flags. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("/models/custom.gguf", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "/models/custom.gguf:something-else", max_seq_length = 4096 - ), - "tester", - ) + resp = _put("/models/custom.gguf:something-else", max_seq_length = 4096) assert "llama_extra_args" not in resp.overrides["/models/custom.gguf:something-else"] -def test_unknown_quant_label_carries_over_for_a_windows_path(monkeypatch): +def test_unknown_quant_label_carries_over_for_a_windows_path(override_store): # Written on Windows but read back on a backend where a backslash is an ordinary # filename character. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override(r"C:\models\custom.gguf", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = r"C:\models\custom.gguf:custom", max_seq_length = 4096 - ), - "tester", - ) + resp = _put(r"C:\models\custom.gguf:custom", max_seq_length = 4096) assert resp.overrides[r"C:\models\custom.gguf:custom"]["llama_extra_args"] == ["--flash-attn"] -def test_real_quant_suffix_on_a_path_still_carries_flags_over(monkeypatch): - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_real_quant_suffix_on_a_path_still_carries_flags_over(override_store): settings.set_model_override("/models/x.gguf", llama_extra_args = ["--flash-attn"]) - resp = settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "/models/x.gguf:Q4_K_M", max_seq_length = 4096), - "tester", - ) + resp = _put("/models/x.gguf:Q4_K_M", max_seq_length = 4096) assert resp.overrides["/models/x.gguf:Q4_K_M"]["llama_extra_args"] == ["--flash-attn"] @@ -4970,114 +4778,65 @@ def test_a_non_gpu_load_failure_is_not_retried(monkeypatch): assert calls["n"] == 1 -def test_removal_clears_the_entry_a_load_would_actually_resolve(monkeypatch): +def test_removal_clears_the_entry_a_load_would_actually_resolve(override_store): # The browser normalizes casing before storing, so a forget can carry a different # casing; removing only the literal key leaves an entry loads still resolve to. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("unsloth/B-GGUF:Q4_K_M", max_seq_length = 8192) assert settings.get_model_override("unsloth/b-gguf:q4_k_m")["max_seq_length"] == 8192 - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/b-gguf:q4_k_m", remove = True), - "tester", - ) + _put("unsloth/b-gguf:q4_k_m", remove = True) assert settings.get_model_overrides() == {} assert settings.get_model_override("unsloth/B-GGUF:Q4_K_M") == {} -def test_save_updates_the_existing_case_variant_instead_of_forking_it(monkeypatch): +def test_save_updates_the_existing_case_variant_instead_of_forking_it(override_store): # The backfill stores lowercase keys while a later UI save carries the catalog's - # casing. Writing that literally leaves two keys for one model, which makes any - # third casing ambiguous and silently loses every setting on the API path. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) + # casing. settings.set_model_override("unsloth/b-gguf:q4_k_m", max_seq_length = 8192) - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF:Q4_K_M", max_seq_length = 4096), - "tester", - ) + _put("unsloth/B-GGUF:Q4_K_M", max_seq_length = 4096) assert list(settings.get_model_overrides()) == ["unsloth/b-gguf:q4_k_m"] assert settings.get_model_override("Unsloth/B-GGUF:Q4_K_M")["max_seq_length"] == 4096 -def test_removal_of_a_path_still_only_touches_the_exact_key(monkeypatch): - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_removal_of_a_path_still_only_touches_the_exact_key(override_store): settings.set_model_override("/models/foo.gguf", max_seq_length = 8192) - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "/models/Foo.gguf", remove = True), - "tester", - ) + _put("/models/Foo.gguf", remove = True) # A different file must survive its neighbour being forgotten. assert settings.get_model_override("/models/foo.gguf")["max_seq_length"] == 8192 -def test_forget_clears_the_filename_derived_key_a_load_still_reads(monkeypatch): - # The picker keyed a standalone .gguf by the quant label from its filename - # before it settled on the bare path, and the backfill carries those entries - # over from an upgraded browser. Forget sends the bare path, which the - # resolver cannot fold onto the suffixed key, while the loader reads that - # suffixed key after the bare one misses: the settings leave the UI and go - # on being applied to every API load, unreachable. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_forget_clears_the_filename_derived_key_a_load_still_reads(override_store): + # The picker keyed a standalone .gguf by the quant label from its filename before it + # settled on the bare path, and the backfill carries those entries over from an + # upgraded browser. settings.set_model_override( "/models/Qwen3-8B-Q4_K_M.gguf:q4_k_m", max_seq_length = 8192, ) - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "/models/Qwen3-8B-Q4_K_M.gguf", - remove = True, - ), - "tester", - ) + _put("/models/Qwen3-8B-Q4_K_M.gguf", remove = True) assert settings.get_model_override("/models/Qwen3-8B-Q4_K_M.gguf:Q4_K_M") == {} assert settings.get_model_overrides() == {} -def test_forget_leaves_another_file_own_derived_key_alone(monkeypatch): +def test_forget_leaves_another_file_own_derived_key_alone(override_store): # The derived key is built from the forgotten file's own path and its own # label, so a neighbour that happens to share a quant keeps its settings. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) settings.set_model_override("/models/Other-Q4_K_M.gguf:q4_k_m", max_seq_length = 4096) - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "/models/Qwen3-8B-Q4_K_M.gguf", - remove = True, - ), - "tester", - ) + _put("/models/Qwen3-8B-Q4_K_M.gguf", remove = True) assert settings.get_model_override("/models/Other-Q4_K_M.gguf:Q4_K_M")["max_seq_length"] == 4096 -def test_forget_of_a_repo_quant_key_derives_nothing(monkeypatch): - # Only a bare .gguf path derives a label. An id that already names a quant is - # the entry the caller meant, and a repo id never gets a filename read out of - # it, so neither reaches the extra removal. - import routes.settings as settings_route - - _mock_override_store(monkeypatch) +def test_forget_of_a_repo_quant_key_derives_nothing(override_store): + # Only a bare .gguf path derives a label. settings.set_model_override("unsloth/b-gguf:q4_k_m", max_seq_length = 8192) - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF", remove = True), - "tester", - ) + _put("unsloth/B-GGUF", remove = True) assert settings.get_model_override("unsloth/b-gguf:q4_k_m")["max_seq_length"] == 8192 def test_a_tag_that_names_no_quant_resolves_to_the_repo(monkeypatch): # A downloaded but unloaded GGUF asked for as org/model:latest missed the resolver, # so the switch could not load it (404ing on a quant that was never a quant with - # auto-download on, refusing with it off). A real quant that is not on disk must - # still miss, or a swap would serve the wrong weights under the right name. + # auto-download on, refusing with it off). from core.inference.local_model_resolver import _LocalGgufEntry import time @@ -5102,7 +4861,6 @@ def test_a_tag_that_names_no_quant_resolves_to_the_repo(monkeypatch): def test_any_finished_download_drops_the_resolver_cache(monkeypatch): # Only the API auto-download watcher invalidated, so a GGUF fetched in the Hub UI # stayed absent to the cache-only request path and the resident model answered. - # Every worker exits through here. import logging from hub.services import download_lifecycle @@ -5150,8 +4908,7 @@ def test_any_finished_download_drops_the_resolver_cache(monkeypatch): def test_invalidating_keeps_the_entries_it_already_had(monkeypatch): # The request path reads this cache without scanning, so emptying it leaves no - # evidence until the rebuild lands. Only a completed download invalidates, and - # that only adds, so the entries stay true. + # evidence until the rebuild lands. import time entry = resolver._LocalGgufEntry("org/old", "/srv/models/org--old", ("Q4_K_M",)) @@ -5167,8 +4924,7 @@ def test_invalidating_keeps_the_entries_it_already_had(monkeypatch): def test_a_bare_local_id_takes_the_quant_a_plain_load_would(monkeypatch, tmp_path): # list_local_gguf_variants orders by descending size, so the head is the biggest - # quant. Resolving a bare id to that could evict a working model and then OOM on an - # F16 next to a fitting Q4, and /v1/models advertised the same head for pinning. + # quant. from core.inference.local_model_resolver import _local_gguf_entry for name, size in (("model-F16.gguf", 900), ("model-Q4_K_M.gguf", 100)): @@ -5237,9 +4993,7 @@ def test_a_just_downloaded_model_is_evidence_before_the_scan_indexes_it(monkeypa def test_a_finished_dataset_is_not_recorded_as_a_local_model(monkeypatch): - # finalize_worker_exit is shared with dataset downloads. Noting one as a local model - # would refuse a bare /v1 request naming that id instead of letting a foreign id - # fall through, and would kick off a multi-directory scan for nothing. + # finalize_worker_exit is shared with dataset downloads. import logging import time @@ -5331,14 +5085,13 @@ def test_abs_path_ids_are_recognised_in_either_platform_spelling(): assert resolver._is_abs_path_id(repo_id) is False, repo_id -def test_fill_absent_fields_put_never_replaces_a_newer_server_value(monkeypatch): +def test_fill_absent_fields_put_never_replaces_a_newer_server_value(override_store): """The one-time localStorage backfill reads the override map once and then writes each model in turn, so a save by another tab during that pass was overwritten by this browser's older copy. fill_absent_fields writes only what the entry lacks, so every value already on the server wins.""" import routes.settings as settings_route - _mock_override_store(monkeypatch) # The other tab's save lands first. newer = settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF", max_seq_length = 8192) @@ -5409,12 +5162,11 @@ def test_fill_absent_fields_carries_the_browser_only_settings_into_a_legacy_entr assert "kv_cache_dtype" not in resp2.overrides["unsloth/B-GGUF:Q4_K_M"] -def test_fill_absent_fields_matches_a_legacy_casing_and_never_deletes(monkeypatch): +def test_fill_absent_fields_matches_a_legacy_casing_and_never_deletes(override_store): """The stored key can carry the casing an older install typed, and it must not be duplicated or emptied by a fill for the folded spelling.""" import routes.settings as settings_route - _mock_override_store(monkeypatch) stored = settings_route.ModelOverridePayload( model_id = "Unsloth/B-GGUF:Q4_K_M", max_seq_length = 8192 @@ -5437,22 +5189,16 @@ def test_fill_absent_fields_matches_a_legacy_casing_and_never_deletes(monkeypatc # A fill that is also a delete has no meaning. with pytest.raises(HTTPException) as excinfo: - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "Unsloth/B-GGUF:Q4_K_M", remove = True, fill_absent_fields = True - ), - "tester", - ) + _put("Unsloth/B-GGUF:Q4_K_M", remove = True, fill_absent_fields = True) assert excinfo.value.status_code == 400 -def test_fill_absent_fields_does_not_break_the_empty_payload_removal(monkeypatch): +def test_fill_absent_fields_does_not_break_the_empty_payload_removal(override_store): """fill_absent_fields is a write mode, not a saved field: leaving it in the dumped payload would make every request look non-empty and silently retire the legacy "a payload carrying only model_id forgets this model" contract.""" import routes.settings as settings_route - _mock_override_store(monkeypatch) stored = settings_route.ModelOverridePayload(model_id = "unsloth/B-GGUF", max_seq_length = 4096) settings_route.update_openai_auto_switch_override(stored, "tester") @@ -5516,7 +5262,6 @@ def test_gpu_ids_dedupe_is_not_a_scan_of_the_list_being_built(): elapsed = time.perf_counter() - started assert len(normalized["gpu_ids"]) == MAX_GPU_ID + 1 # The scan version took ~1s for this input on a dev box; a linear pass is ~50ms. - # The bound is loose so a slow CI runner does not redden it. assert elapsed < 0.5, elapsed @@ -5677,12 +5422,7 @@ def test_a_fill_does_not_replay_a_stored_flag_through_validation(monkeypatch): # An ordinary save still validates what it is handed. with pytest.raises(HTTPException) as excinfo: - settings_route.update_openai_auto_switch_override( - settings_route.ModelOverridePayload( - model_id = "unsloth/C-GGUF", llama_extra_args = ["--flash-attn"] - ), - "tester", - ) + _put("unsloth/C-GGUF", llama_extra_args = ["--flash-attn"]) assert excinfo.value.status_code == 400 diff --git a/studio/frontend/tests/helpers/kit.ts b/studio/frontend/tests/helpers/kit.ts new file mode 100644 index 0000000000..d3d8bf80e4 --- /dev/null +++ b/studio/frontend/tests/helpers/kit.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { register } from "node:module"; + +import type { ResidentAdoptionState } from "../../src/features/hub/lib/adopt-inference-status.ts"; +import type { ResidentStatusRefreshTargets } from "../../src/features/hub/lib/resident-status-refresh.ts"; + +/** + * Teach the loader the two resolution rules vite and tsconfig's "bundler" mode + * give the app. Call this before the dynamic import of any src module that + * resolves the way vite and tsconfig resolve, not the way bare node does. + */ +export function registerBundlerResolver(): void { + register("../bundler-resolver.mjs", import.meta.url); +} + +export type StorageFake = { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; +}; + +/** + * An in-memory localStorage, installed on globalThis under both the names the + * app reads it by. The returned map is the backing store, so a test can stage + * records before the module under test is imported. + */ +export function installLocalStorageFake(): { + store: Map; + storage: StorageFake; +} { + const store = new Map(); + const storage: StorageFake = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }; + Object.assign(globalThis, { + window: { localStorage: storage }, + localStorage: storage, + }); + return { store, storage }; +} + +/** The chat-runtime store as it stands before anything has hydrated it. */ +export function emptyStore( + overrides: Partial = {}, +): ResidentAdoptionState { + return { + checkpoint: null, + checkpointIsExternal: false, + activeGgufVariant: null, + modelLoading: false, + ...overrides, + }; +} + +/** Records the store actions adoptResidentModelStatus takes, in order. */ +export function spies() { + const calls: string[] = []; + const previouslySeen: { checkpoint: string | null; ggufVariant: string | null }[] = + []; + return { + calls, + previouslySeen, + actions: { + setCheckpoint(checkpointId: string, ggufVariant: string | null) { + calls.push(`setCheckpoint:${checkpointId}:${ggufVariant ?? ""}`); + }, + applyStatus(previous: { + checkpoint: string | null; + ggufVariant: string | null; + }) { + calls.push("applyStatus"); + previouslySeen.push(previous); + }, + }, + }; +} + +/** A window/document pair whose events and visibility a test drives by hand. */ +export function fakeTargets(): ResidentStatusRefreshTargets & { + hidden: boolean; + fire: (target: "window" | "document", type: string) => void; + listenerCount: () => number; +} { + const listeners = new Map>(); + const key = (target: string, type: string) => `${target}:${type}`; + const make = (target: "window" | "document") => ({ + addEventListener(type: string, fn: EventListenerOrEventListenerObject) { + const set = listeners.get(key(target, type)) ?? new Set(); + set.add(fn); + listeners.set(key(target, type), set); + }, + removeEventListener(type: string, fn: EventListenerOrEventListenerObject) { + listeners.get(key(target, type))?.delete(fn); + }, + }); + const visibility = { hidden: false }; + const state = { + get hidden() { + return visibility.hidden; + }, + set hidden(next: boolean) { + visibility.hidden = next; + }, + window: make("window"), + document: { + ...make("document"), + get hidden() { + return visibility.hidden; + }, + }, + fire(target: "window" | "document", type: string) { + for (const fn of listeners.get(key(target, type)) ?? []) { + (fn as EventListener)(new Event(type)); + } + }, + listenerCount() { + let total = 0; + for (const set of listeners.values()) total += set.size; + return total; + }, + }; + return state as never; +} diff --git a/studio/frontend/tests/hub-resident-status-refresh.test.ts b/studio/frontend/tests/hub-resident-status-refresh.test.ts deleted file mode 100644 index 44d1ea6f6b..0000000000 --- a/studio/frontend/tests/hub-resident-status-refresh.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import assert from "node:assert/strict"; -import test from "node:test"; - -import { adoptResidentModelStatus } from "../src/features/hub/lib/adopt-inference-status.ts"; -import { - ggufVariantsMatch, - residentModelIdMatches, -} from "../src/features/hub/lib/model-identity.ts"; -import { - type ResidentStatusRefreshTargets, - subscribeResidentStatusRefresh, -} from "../src/features/hub/lib/resident-status-refresh.ts"; - -function fakeTargets(): ResidentStatusRefreshTargets & { - hidden: boolean; - fire: (target: "window" | "document", type: string) => void; - listenerCount: () => number; -} { - const listeners = new Map>(); - const key = (target: string, type: string) => `${target}:${type}`; - const make = (target: "window" | "document") => ({ - addEventListener(type: string, fn: EventListenerOrEventListenerObject) { - const set = listeners.get(key(target, type)) ?? new Set(); - set.add(fn); - listeners.set(key(target, type), set); - }, - removeEventListener(type: string, fn: EventListenerOrEventListenerObject) { - listeners.get(key(target, type))?.delete(fn); - }, - }); - const visibility = { hidden: false }; - const state = { - get hidden() { - return visibility.hidden; - }, - set hidden(next: boolean) { - visibility.hidden = next; - }, - window: make("window"), - document: { - ...make("document"), - get hidden() { - return visibility.hidden; - }, - }, - fire(target: "window" | "document", type: string) { - for (const fn of listeners.get(key(target, type)) ?? []) { - (fn as EventListener)(new Event(type)); - } - }, - listenerCount() { - let total = 0; - for (const set of listeners.values()) total += set.size; - return total; - }, - }; - return state as never; -} - -test("coming back to the window re-reads inference status", () => { - // An OpenAI-compatible request auto-switches the resident model whenever it - // likes. The Hub's only other status read is its mount effect, so without this - // the catalog and the settings page keep describing the previous model for as - // long as the Hub stays mounted. - const targets = fakeTargets(); - let reads = 0; - subscribeResidentStatusRefresh(() => { - reads += 1; - }, targets); - - assert.equal(reads, 0, "subscribing must not read on its own"); - targets.fire("window", "focus"); - assert.equal(reads, 1); - targets.fire("document", "visibilitychange"); - assert.equal(reads, 2); -}); - -test("a tab going hidden does not read", () => { - // visibilitychange fires on the way out too, and a hidden tab has no settings - // page to correct. - const targets = fakeTargets(); - let reads = 0; - subscribeResidentStatusRefresh(() => { - reads += 1; - }, targets); - - targets.hidden = true; - targets.fire("document", "visibilitychange"); - assert.equal(reads, 0); - - targets.hidden = false; - targets.fire("document", "visibilitychange"); - assert.equal(reads, 1); -}); - -test("an auto-switch under a mounted Hub stops hiding the live config", () => { - // The whole point, end to end: while the Hub is mounted an OpenAI-compatible - // request swaps the resident model. Without a second read the store still names - // the old one, so hub-page's settingsTargetIsResident says the newly loaded - // model is not resident, its settings page is handed loadedConfig=null, and - // ModelConfigPage seeds the editor from saved/default values -- which Apply then - // reloads the model with, over what the API actually selected. - const store = { - checkpoint: "unsloth/Qwen3-8B-GGUF" as string | null, - checkpointIsExternal: false, - activeGgufVariant: "Q4_K_M" as string | null, - modelLoading: false, - }; - // What the server reports once the API request has switched it. - let serverStatus = { - checkpointId: "unsloth/Llama-3.1-8B-Instruct-GGUF", - ggufVariant: "Q8_0", - }; - const readStatusAndAdopt = () => { - adoptResidentModelStatus( - serverStatus, - { ...store }, - { - setCheckpoint: (checkpointId, ggufVariant) => { - store.checkpoint = checkpointId; - store.activeGgufVariant = ggufVariant; - }, - applyStatus: () => undefined, - }, - ); - }; - - // hub-page.tsx's settingsTargetIsResident, for the model the API just loaded. - const settingsTargetIsResident = () => - residentModelIdMatches(store.checkpoint, serverStatus.checkpointId) && - ggufVariantsMatch(store.activeGgufVariant, serverStatus.ggufVariant); - - const targets = fakeTargets(); - subscribeResidentStatusRefresh(readStatusAndAdopt, targets); - - assert.equal( - settingsTargetIsResident(), - false, - "precondition: the mount-time read predates the switch", - ); - targets.fire("window", "focus"); - assert.equal(settingsTargetIsResident(), true); - - // A load this tab started owns the store until it settles, so a refresh landing - // mid-switch must not re-pin the model the user is moving away from. - store.modelLoading = true; - serverStatus = { - checkpointId: "unsloth/Qwen3-8B-GGUF", - ggufVariant: "Q4_K_M", - }; - targets.fire("window", "focus"); - assert.equal(store.checkpoint, "unsloth/Llama-3.1-8B-Instruct-GGUF"); -}); - -test("unsubscribing stops the reads and leaves no listener behind", () => { - const targets = fakeTargets(); - let reads = 0; - const unsubscribe = subscribeResidentStatusRefresh(() => { - reads += 1; - }, targets); - - assert.equal(targets.listenerCount(), 2); - unsubscribe(); - assert.equal(targets.listenerCount(), 0); - targets.fire("window", "focus"); - targets.fire("document", "visibilitychange"); - assert.equal(reads, 0); -}); diff --git a/studio/frontend/tests/hub-adopt-inference-status.test.ts b/studio/frontend/tests/hub-resident-status.test.ts similarity index 51% rename from studio/frontend/tests/hub-adopt-inference-status.test.ts rename to studio/frontend/tests/hub-resident-status.test.ts index e0eee95138..2b1813010d 100644 --- a/studio/frontend/tests/hub-adopt-inference-status.test.ts +++ b/studio/frontend/tests/hub-resident-status.test.ts @@ -4,47 +4,37 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { adoptResidentModelStatus } from "../src/features/hub/lib/adopt-inference-status.ts"; import { - type ResidentAdoptionState, - adoptResidentModelStatus, -} from "../src/features/hub/lib/adopt-inference-status.ts"; + ggufVariantsMatch, + residentModelIdMatches, +} from "../src/features/hub/lib/model-identity.ts"; +import { subscribeResidentStatusRefresh } from "../src/features/hub/lib/resident-status-refresh.ts"; +import { emptyStore, fakeTargets, spies } from "./helpers/kit.ts"; const RESIDENT = { checkpointId: "unsloth/Qwen3-8B-GGUF", ggufVariant: "Q4_K_M", }; -function emptyStore( - overrides: Partial = {}, -): ResidentAdoptionState { - return { - checkpoint: null, - checkpointIsExternal: false, - activeGgufVariant: null, - modelLoading: false, - ...overrides, +/** + * Store actions that refuse to be called. The message on each names what must + * not happen, so a test states its rule by the action it declines to forbid. + */ +function refusing(messages: { + setCheckpoint?: string; + clearCheckpoint?: string; + applyStatus?: string; +}) { + const refuse = (message = "unreachable") => { + return () => { + throw new Error(message); + }; }; -} - -function spies() { - const calls: string[] = []; - const previouslySeen: { checkpoint: string | null; ggufVariant: string | null }[] = - []; return { - calls, - previouslySeen, - actions: { - setCheckpoint(checkpointId: string, ggufVariant: string | null) { - calls.push(`setCheckpoint:${checkpointId}:${ggufVariant ?? ""}`); - }, - applyStatus(previous: { - checkpoint: string | null; - ggufVariant: string | null; - }) { - calls.push("applyStatus"); - previouslySeen.push(previous); - }, - }, + setCheckpoint: refuse(messages.setCheckpoint), + clearCheckpoint: refuse(messages.clearCheckpoint), + applyStatus: refuse(messages.applyStatus), }; } @@ -157,22 +147,18 @@ test("an empty status drops a local checkpoint the server no longer has", () => const cleared: string[] = []; const adopted = adoptResidentModelStatus( { checkpointId: null, ggufVariant: null }, - { + emptyStore({ checkpoint: "/models/llama.gguf", - checkpointIsExternal: false, activeGgufVariant: "Q4_K_M", - modelLoading: false, - }, + }), { - setCheckpoint: () => { - throw new Error("nothing is resident, so nothing may be pinned"); - }, + ...refusing({ + setCheckpoint: "nothing is resident, so nothing may be pinned", + applyStatus: "there is no status to apply", + }), clearCheckpoint: () => { cleared.push("cleared"); }, - applyStatus: () => { - throw new Error("there is no status to apply"); - }, }, ); assert.equal(adopted, true); @@ -184,23 +170,13 @@ test("an empty status leaves an external pick alone", () => { // status must not reach it: the local model is not what the user is talking to. const adopted = adoptResidentModelStatus( { checkpointId: null, ggufVariant: null }, - { + emptyStore({ checkpoint: "gemini/gemini-2.5-pro", checkpointIsExternal: true, - activeGgufVariant: null, - modelLoading: false, - }, - { - setCheckpoint: () => { - throw new Error("unreachable"); - }, - clearCheckpoint: () => { - throw new Error("an external pick must survive an empty status"); - }, - applyStatus: () => { - throw new Error("unreachable"); - }, - }, + }), + refusing({ + clearCheckpoint: "an external pick must survive an empty status", + }), ); assert.equal(adopted, false); }); @@ -208,23 +184,11 @@ test("an empty status leaves an external pick alone", () => { test("an empty status does not fight a load this tab started", () => { const adopted = adoptResidentModelStatus( { checkpointId: null, ggufVariant: null }, - { + emptyStore({ checkpoint: "/models/llama.gguf", - checkpointIsExternal: false, - activeGgufVariant: null, modelLoading: true, - }, - { - setCheckpoint: () => { - throw new Error("unreachable"); - }, - clearCheckpoint: () => { - throw new Error("the load owns the store until it settles"); - }, - applyStatus: () => { - throw new Error("unreachable"); - }, - }, + }), + refusing({ clearCheckpoint: "the load owns the store until it settles" }), ); assert.equal(adopted, false); }); @@ -232,23 +196,116 @@ test("an empty status does not fight a load this tab started", () => { test("an empty status on an already empty store changes nothing", () => { const adopted = adoptResidentModelStatus( { checkpointId: null, ggufVariant: null }, - { - checkpoint: null, - checkpointIsExternal: false, - activeGgufVariant: null, - modelLoading: false, - }, - { - setCheckpoint: () => { - throw new Error("unreachable"); - }, - clearCheckpoint: () => { - throw new Error("there is nothing to clear"); - }, - applyStatus: () => { - throw new Error("unreachable"); - }, - }, + emptyStore(), + refusing({ clearCheckpoint: "there is nothing to clear" }), ); assert.equal(adopted, false); }); + +test("coming back to the window re-reads inference status", () => { + // An OpenAI-compatible request auto-switches the resident model whenever it + // likes. The Hub's only other status read is its mount effect, so without this + // the catalog and the settings page keep describing the previous model for as + // long as the Hub stays mounted. + const targets = fakeTargets(); + let reads = 0; + subscribeResidentStatusRefresh(() => { + reads += 1; + }, targets); + + assert.equal(reads, 0, "subscribing must not read on its own"); + targets.fire("window", "focus"); + assert.equal(reads, 1); + targets.fire("document", "visibilitychange"); + assert.equal(reads, 2); +}); + +test("a tab going hidden does not read", () => { + // visibilitychange fires on the way out too, and a hidden tab has no settings + // page to correct. + const targets = fakeTargets(); + let reads = 0; + subscribeResidentStatusRefresh(() => { + reads += 1; + }, targets); + + targets.hidden = true; + targets.fire("document", "visibilitychange"); + assert.equal(reads, 0); + + targets.hidden = false; + targets.fire("document", "visibilitychange"); + assert.equal(reads, 1); +}); + +test("an auto-switch under a mounted Hub stops hiding the live config", () => { + // The whole point, end to end: while the Hub is mounted an OpenAI-compatible + // request swaps the resident model. Without a second read the store still names + // the old one, so hub-page's settingsTargetIsResident says the newly loaded + // model is not resident, its settings page is handed loadedConfig=null, and + // ModelConfigPage seeds the editor from saved/default values -- which Apply then + // reloads the model with, over what the API actually selected. + const store = emptyStore({ + checkpoint: "unsloth/Qwen3-8B-GGUF", + activeGgufVariant: "Q4_K_M", + }); + // What the server reports once the API request has switched it. + let serverStatus = { + checkpointId: "unsloth/Llama-3.1-8B-Instruct-GGUF", + ggufVariant: "Q8_0", + }; + const readStatusAndAdopt = () => { + adoptResidentModelStatus( + serverStatus, + { ...store }, + { + setCheckpoint: (checkpointId, ggufVariant) => { + store.checkpoint = checkpointId; + store.activeGgufVariant = ggufVariant; + }, + applyStatus: () => undefined, + }, + ); + }; + + // hub-page.tsx's settingsTargetIsResident, for the model the API just loaded. + const settingsTargetIsResident = () => + residentModelIdMatches(store.checkpoint, serverStatus.checkpointId) && + ggufVariantsMatch(store.activeGgufVariant, serverStatus.ggufVariant); + + const targets = fakeTargets(); + subscribeResidentStatusRefresh(readStatusAndAdopt, targets); + + assert.equal( + settingsTargetIsResident(), + false, + "precondition: the mount-time read predates the switch", + ); + targets.fire("window", "focus"); + assert.equal(settingsTargetIsResident(), true); + + // A load this tab started owns the store until it settles, so a refresh landing + // mid-switch must not re-pin the model the user is moving away from. + store.modelLoading = true; + serverStatus = { + checkpointId: "unsloth/Qwen3-8B-GGUF", + ggufVariant: "Q4_K_M", + }; + targets.fire("window", "focus"); + assert.equal(store.checkpoint, "unsloth/Llama-3.1-8B-Instruct-GGUF"); +}); + +test("unsubscribing stops the reads and leaves no listener behind", () => { + const targets = fakeTargets(); + let reads = 0; + const unsubscribe = subscribeResidentStatusRefresh(() => { + reads += 1; + }, targets); + + assert.equal(targets.listenerCount(), 2); + unsubscribe(); + assert.equal(targets.listenerCount(), 0); + targets.fire("window", "focus"); + targets.fire("document", "visibilitychange"); + assert.equal(reads, 0); +}); diff --git a/studio/frontend/tests/model-identity.test.ts b/studio/frontend/tests/model-identity.test.ts new file mode 100644 index 0000000000..d1da35ecc1 --- /dev/null +++ b/studio/frontend/tests/model-identity.test.ts @@ -0,0 +1,344 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { settingsGgufVariantForRow } from "../src/features/hub/inventory/settings-identity.ts"; +import type { + CachedInventoryRow, + LocalInventoryRow, +} from "../src/features/hub/inventory/types.ts"; +import { + isOllamaLinkPath, + modelIdsMatch, + publicModelId, + residentModelIdMatches, +} from "../src/features/hub/lib/model-identity.ts"; +import { + installLocalStorageFake, + registerBundlerResolver, +} from "./helpers/kit.ts"; + +registerBundlerResolver(); +const { store, storage } = installLocalStorageFake(); + +const REPO_KEY = 'v2:["unsloth/repo-gguf","q4_k_m"]'; + +// The legacy import of unsloth_load_settings runs once, on the first read after +// load, so it has to be staged before the module is imported. +store.set( + "unsloth_model_configs", + JSON.stringify({ [REPO_KEY]: { version: 1, maxSeqLength: 32768 } }), +); +store.set( + "unsloth_load_settings", + JSON.stringify({ "Unsloth/Repo-GGUF::Q4_K_M": { contextLength: 8192 } }), +); + +const { listPerModelConfigs, resolveInitialConfig, savePerModelConfig } = + await import("../src/features/model-picker/model-config/per-model-config.ts"); +const { modelStorageKey, splitQuantSuffix } = await import( + "../src/features/model-picker/model-config/model-identity.ts" +); + +function config(maxSeqLength: number, kvCacheDtype: string | null = null) { + return { + customContextLength: null, + maxSeqLength, + kvCacheDtype, + speculativeType: null, + specDraftNMax: null, + nParallel: null, + tensorParallel: false, + chatTemplateOverride: null, + }; +} + +function storedKeys(): string[] { + return Object.keys( + JSON.parse(storage.getItem("unsloth_model_configs") ?? "{}"), + ); +} + +test("publicModelId mirrors what /status reports for a path-loaded model", () => { + // Mirrors public_model_id in studio/backend/core/inference/model_ids.py. + assert.equal( + publicModelId("/srv/models/Qwen3-8B-Q4_K_M.gguf"), + "Qwen3-8B-Q4_K_M", + ); + assert.equal( + publicModelId( + "/home/u/.cache/huggingface/hub/models--unsloth--Qwen3-8B-GGUF/snapshots/abc123", + ), + "unsloth/Qwen3-8B-GGUF", + ); + assert.equal(publicModelId("C:\\models\\Foo-Q4_K_M.gguf"), "Foo-Q4_K_M"); + assert.equal(publicModelId("~/models/Foo.gguf"), "Foo"); + assert.equal(publicModelId("/srv/models/repo/"), "repo"); + // A repo id and an already-clean name come back untouched. + assert.equal(publicModelId("unsloth/Qwen3-8B-GGUF"), "unsloth/Qwen3-8B-GGUF"); + assert.equal(publicModelId("Qwen3-8B-Q4_K_M"), "Qwen3-8B-Q4_K_M"); + // "models--" alone is not the cache layout; only the snapshots sibling is. + assert.equal(publicModelId("models--only--nosnapshots/blobs/x"), "x"); +}); + +test("a resident path-loaded model is matched by the id /status reports", () => { + // A loose .gguf: the catalog row is keyed by the path, and the Hub page records + // the loadable identifier (status.model_identifier), so the literal pass answers. + assert.equal( + modelIdsMatch("Qwen3-8B-Q4_K_M", "/srv/models/Qwen3-8B-Q4_K_M.gguf"), + false, + ); + assert.equal( + residentModelIdMatches( + "/srv/models/Qwen3-8B-Q4_K_M.gguf", + "/srv/models/Qwen3-8B-Q4_K_M.gguf", + "/srv/models/Qwen3-8B-Q4_K_M.gguf", + ), + true, + ); + // A repo in an inactive HF cache loads by snapshot path but keeps the repo id + // as its settings identity, so the configId alias already covers it. + assert.equal( + residentModelIdMatches( + "unsloth/Qwen3-8B-GGUF", + "/mnt/old-cache/models--unsloth--Qwen3-8B-GGUF/snapshots/abc123", + "unsloth/Qwen3-8B-GGUF", + ), + true, + ); + // The raw identifier is still matched literally. + assert.equal( + residentModelIdMatches( + "/srv/models/Qwen3-8B-Q4_K_M.gguf", + "/srv/models/Qwen3-8B-Q4_K_M.gguf", + null, + ), + true, + ); + // Another model is still not the loaded one. + assert.equal( + residentModelIdMatches( + "Qwen3-8B-Q4_K_M", + "/srv/models/Llama-3-8B-Q4_K_M.gguf", + null, + ), + false, + ); + assert.equal( + residentModelIdMatches( + "unsloth/Qwen3-8B-GGUF", + "/mnt/old-cache/models--unsloth--Llama-3-GGUF/snapshots/abc123", + "unsloth/Llama-3-GGUF", + ), + false, + ); + assert.equal(residentModelIdMatches(null, "/srv/models/x.gguf"), false); + assert.equal(residentModelIdMatches("Qwen3-8B-Q4_K_M"), false); +}); + +test("a shared filename or folder name never marks a row resident", () => { + // Two loose GGUFs with the same filename in different folders collapse onto one + // public id, so a stem can only say "one of these", never which. + const loaded = "/srv/models/alpha/model.gguf"; + const other = "/srv/models/beta/model.gguf"; + assert.equal(publicModelId(loaded), publicModelId(other)); + assert.equal(residentModelIdMatches(publicModelId(loaded), other, other), false); + // The loadable identifier names exactly one of them. + assert.equal(residentModelIdMatches(loaded, loaded, loaded), true); + assert.equal(residentModelIdMatches(loaded, other, other), false); + + // Same collapse one level up: two model directories sharing a basename. + const loadedDir = "/srv/lmstudio/publisher-a/Llama-3-8B-GGUF"; + const otherDir = "/srv/models/publisher-b/Llama-3-8B-GGUF"; + assert.equal(publicModelId(loadedDir), publicModelId(otherDir)); + assert.equal( + residentModelIdMatches(publicModelId(loadedDir), otherDir, otherDir), + false, + ); + + // A cache snapshot still collapses onto its repo id, which names one model. + assert.equal( + residentModelIdMatches( + "unsloth/Qwen3-8B-GGUF", + "/mnt/old-cache/models--unsloth--Qwen3-8B-GGUF/snapshots/abc123", + null, + ), + true, + ); +}); + +test("Ollama link paths are recognised the way the resolver excludes them", () => { + // core/inference/local_model_resolver.py refuses any path with these segments. + assert.equal( + isOllamaLinkPath("/home/u/.ollama/models/.studio_links/q/qwen3-Q4_K_M.gguf"), + true, + ); + assert.equal( + isOllamaLinkPath("/home/u/.cache/unsloth/ollama_links/ab12/llama3.gguf"), + true, + ); + assert.equal( + isOllamaLinkPath("C:\\Users\\u\\.ollama\\models\\.studio_links\\q\\a.gguf"), + true, + ); + // Only those exact segments, not a directory that merely contains the name. + assert.equal(isOllamaLinkPath("/srv/studio_links_backup/a.gguf"), false); + assert.equal(isOllamaLinkPath("/srv/models/Qwen3-8B-Q4_K_M.gguf"), false); + assert.equal(isOllamaLinkPath("unsloth/Qwen3-8B-GGUF"), false); + assert.equal(isOllamaLinkPath(null), false); +}); + +test("a standalone gguf keeps one settings identity across surfaces", () => { + const loose = { + kind: "local", + path: "/srv/models/Qwen3-8B-Q4_K_M.gguf", + // What hub/services/models/common.py emits for a single scanned file. + formatVariant: "Q4_K_M", + } as LocalInventoryRow; + // The Chat picker opens the same file with no variant, so the Hub row must not + // adopt the filename-derived label or the two edit different configs. + assert.equal(settingsGgufVariantForRow(loose), null); + + // A GGUF directory still has a variant slot for the quant lookup to fill. + const repoDir = { + kind: "local", + path: "/srv/models/Qwen3-8B-GGUF", + formatVariant: null, + } as LocalInventoryRow; + assert.equal(settingsGgufVariantForRow(repoDir), null); + const lmStudioDir = { + kind: "local", + path: "/srv/lmstudio/Qwen3-8B-GGUF", + formatVariant: "Q8_0", + } as LocalInventoryRow; + assert.equal(settingsGgufVariantForRow(lmStudioDir), "Q8_0"); + + // Cached repo rows are unaffected (cache_inventory.py never sets one). + const cached = { kind: "cache", formatVariant: null } as CachedInventoryRow; + assert.equal(settingsGgufVariantForRow(cached), null); +}); + +// The one-time backfill re-reads listPerModelConfigs() to pick up a save that +// landed while the override fetch was in flight, and matches on the folded +// identity. That is only unambiguous because storage holds one record per model, +// so these pin that rule rather than the backfill. +test("importing the legacy load settings never doubles up a model", () => { + // The typed casing in unsloth_load_settings names the model the v2 record + // already holds, so the import has to leave it alone rather than add a second + // record the picker would prefer and the backfill would not. + assert.deepEqual(listPerModelConfigs().length, 1); + assert.deepEqual(storedKeys(), [REPO_KEY]); + assert.equal( + resolveInitialConfig("unsloth/repo-gguf", "q4_k_m").config + .customContextLength, + null, + ); +}); + +test("two spellings of one model id keep a single stored record", () => { + store.clear(); + savePerModelConfig("Unsloth/Repo-GGUF", "Q4_K_M", config(4096)); + savePerModelConfig("unsloth/repo-gguf", "q4_k_m", config(32768, "q8_0")); + + assert.deepEqual(storedKeys(), [REPO_KEY]); + const listed = listPerModelConfigs(); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.config.maxSeqLength, 32768); + // What the picker applies and the only thing the backfill can see agree. + assert.equal( + resolveInitialConfig("Unsloth/Repo-GGUF", "Q4_K_M").config.maxSeqLength, + 32768, + ); +}); + +test("two spellings of one Windows path keep a single stored record", () => { + store.clear(); + savePerModelConfig("C:\\Models\\Foo.gguf", null, config(4096)); + savePerModelConfig("c:/models/foo.gguf", null, config(32768, "q8_0")); + + assert.deepEqual(storedKeys(), ['v2:["c:/models/foo.gguf",""]']); + assert.equal(listPerModelConfigs().length, 1); +}); + +test("a POSIX path is case sensitive, so its two spellings stay separate", () => { + store.clear(); + savePerModelConfig("/models/Foo.gguf", null, config(4096)); + savePerModelConfig("/models/foo.gguf", null, config(32768, "q8_0")); + + assert.equal(storedKeys().length, 2); + assert.equal( + resolveInitialConfig("/models/Foo.gguf", null).config.maxSeqLength, + 4096, + ); +}); + +// Every answer below is the one split_quant_suffix in +// studio/backend/utils/openai_auto_switch_settings.py gives for the same key. The +// backfill folds a stored key with this before comparing it against the server's, +// so a suffix this splits and the backend does not collapses two models onto one +// key on the browser side only. +const CASES: [string, [string, string] | null][] = [ + // A known quant label, with and without the optional bpw modifier. + ["org/Repo-GGUF:Q4_K_M", ["org/Repo-GGUF", "Q4_K_M"]], + ["org/Repo-GGUF:IQ4_XS-3.53bpw", ["org/Repo-GGUF", "IQ4_XS-3.53bpw"]], + ["org/Repo-GGUF:UD-Q4_K_XL", ["org/Repo-GGUF", "UD-Q4_K_XL"]], + // A .gguf with no quant token in its name is labelled by its stem, and storage + // lowercases the label while the scanner keeps the filename's casing. + ["/models/CustomModel.gguf:custommodel", ["/models/CustomModel.gguf", "custommodel"]], + ["/models/CustomModel.gguf:CustomModel", ["/models/CustomModel.gguf", "CustomModel"]], + ["C:\\models\\CustomModel.gguf:custommodel", ["C:\\models\\CustomModel.gguf", "custommodel"]], + // A shard suffix is not part of the label. + [ + "/models/Custom-00001-of-00003.gguf:custom", + ["/models/Custom-00001-of-00003.gguf", "custom"], + ], + ["/models/Custom-00001-of-00003.gguf:custom-00001-of-00003", null], + // An extensionless .gguf still has a label. + ["/models/.gguf:gguf", ["/models/.gguf", "gguf"]], + // A quant token inside the filename wins over the stem. + ["/models/tinyllama-Q4_K_M.gguf:q4_k_m", ["/models/tinyllama-Q4_K_M.gguf", "q4_k_m"]], + ["/models/tinyllama-Q4_K_M.gguf:tinyllama-q4_k_m", null], + // Only the basename is labelled, never the directories above it. + [ + "/models/dir/CustomModel.gguf:custommodel", + ["/models/dir/CustomModel.gguf", "custommodel"], + ], + ["/models/dir/CustomModel.gguf:dir/custommodel", null], + // A colon is legal in a POSIX filename. Neither of these is a variant, and + // reading them as one folds two real files onto a single key. + ["/models/foo:Bar.gguf", null], + ["/models/foo:bar.gguf", null], + ["/models/llama.gguf:Bar.gguf", null], + ["/models/llama.gguf:bar.gguf", null], + ["/models/CustomModel.gguf:othermodel", null], + ["/models/model.gguf:notalabel", null], + ["/models/plain.gguf:plain:extra", null], + // A Windows drive letter is not a separator either. + ["C:\\models\\foo.gguf", null], + ["C:/models/foo.gguf", null], + // Nothing to split. + ["org/Repo-GGUF", null], + ["/models/foo.gguf", null], + ["org/Repo:", null], + [":Q4_K_M", null], +]; + +test("splitQuantSuffix answers exactly as the backend's split_quant_suffix", () => { + for (const [value, expected] of CASES) { + assert.deepEqual(splitQuantSuffix(value), expected, value); + } +}); + +test("a .gguf filename carrying a colon is not folded into a variant", () => { + // Two real, distinct files: POSIX allows a colon in a name and is case + // sensitive, so the one-time backfill has to keep their settings apart. The + // variant half of an override key is stored lowercased, so folding these makes + // one key and strands whichever file the backfill reaches second. + const upper = "/models/llama.gguf:Bar.gguf"; + const lower = "/models/llama.gguf:bar.gguf"; + assert.equal(splitQuantSuffix(upper), null); + assert.equal(splitQuantSuffix(lower), null); + assert.notEqual(modelStorageKey(upper, null), modelStorageKey(lower, null)); +}); diff --git a/studio/frontend/tests/model-settings-identity.test.ts b/studio/frontend/tests/model-settings-identity.test.ts deleted file mode 100644 index 04311ec902..0000000000 --- a/studio/frontend/tests/model-settings-identity.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import assert from "node:assert/strict"; -import test from "node:test"; - -import { settingsGgufVariantForRow } from "../src/features/hub/inventory/settings-identity.ts"; -import type { - CachedInventoryRow, - LocalInventoryRow, -} from "../src/features/hub/inventory/types.ts"; -import { - isOllamaLinkPath, - modelIdsMatch, - publicModelId, - residentModelIdMatches, -} from "../src/features/hub/lib/model-identity.ts"; - -test("publicModelId mirrors what /status reports for a path-loaded model", () => { - // Mirrors public_model_id in studio/backend/core/inference/model_ids.py. - assert.equal( - publicModelId("/srv/models/Qwen3-8B-Q4_K_M.gguf"), - "Qwen3-8B-Q4_K_M", - ); - assert.equal( - publicModelId( - "/home/u/.cache/huggingface/hub/models--unsloth--Qwen3-8B-GGUF/snapshots/abc123", - ), - "unsloth/Qwen3-8B-GGUF", - ); - assert.equal(publicModelId("C:\\models\\Foo-Q4_K_M.gguf"), "Foo-Q4_K_M"); - assert.equal(publicModelId("~/models/Foo.gguf"), "Foo"); - assert.equal(publicModelId("/srv/models/repo/"), "repo"); - // A repo id and an already-clean name come back untouched. - assert.equal(publicModelId("unsloth/Qwen3-8B-GGUF"), "unsloth/Qwen3-8B-GGUF"); - assert.equal(publicModelId("Qwen3-8B-Q4_K_M"), "Qwen3-8B-Q4_K_M"); - // "models--" alone is not the cache layout; only the snapshots sibling is. - assert.equal(publicModelId("models--only--nosnapshots/blobs/x"), "x"); -}); - -test("a resident path-loaded model is matched by the id /status reports", () => { - // A loose .gguf: the catalog row is keyed by the path, and the Hub page records - // the loadable identifier (status.model_identifier), so the literal pass answers. - assert.equal( - modelIdsMatch("Qwen3-8B-Q4_K_M", "/srv/models/Qwen3-8B-Q4_K_M.gguf"), - false, - ); - assert.equal( - residentModelIdMatches( - "/srv/models/Qwen3-8B-Q4_K_M.gguf", - "/srv/models/Qwen3-8B-Q4_K_M.gguf", - "/srv/models/Qwen3-8B-Q4_K_M.gguf", - ), - true, - ); - // A repo in an inactive HF cache loads by snapshot path but keeps the repo id - // as its settings identity, so the configId alias already covers it. - assert.equal( - residentModelIdMatches( - "unsloth/Qwen3-8B-GGUF", - "/mnt/old-cache/models--unsloth--Qwen3-8B-GGUF/snapshots/abc123", - "unsloth/Qwen3-8B-GGUF", - ), - true, - ); - // The raw identifier is still matched literally. - assert.equal( - residentModelIdMatches( - "/srv/models/Qwen3-8B-Q4_K_M.gguf", - "/srv/models/Qwen3-8B-Q4_K_M.gguf", - null, - ), - true, - ); - // Another model is still not the loaded one. - assert.equal( - residentModelIdMatches( - "Qwen3-8B-Q4_K_M", - "/srv/models/Llama-3-8B-Q4_K_M.gguf", - null, - ), - false, - ); - assert.equal( - residentModelIdMatches( - "unsloth/Qwen3-8B-GGUF", - "/mnt/old-cache/models--unsloth--Llama-3-GGUF/snapshots/abc123", - "unsloth/Llama-3-GGUF", - ), - false, - ); - assert.equal(residentModelIdMatches(null, "/srv/models/x.gguf"), false); - assert.equal(residentModelIdMatches("Qwen3-8B-Q4_K_M"), false); -}); - -test("a shared filename or folder name never marks a row resident", () => { - // Two loose GGUFs with the same filename in different folders collapse onto one - // public id, so a stem can only say "one of these", never which. - const loaded = "/srv/models/alpha/model.gguf"; - const other = "/srv/models/beta/model.gguf"; - assert.equal(publicModelId(loaded), publicModelId(other)); - assert.equal(residentModelIdMatches(publicModelId(loaded), other, other), false); - // The loadable identifier names exactly one of them. - assert.equal(residentModelIdMatches(loaded, loaded, loaded), true); - assert.equal(residentModelIdMatches(loaded, other, other), false); - - // Same collapse one level up: two model directories sharing a basename. - const loadedDir = "/srv/lmstudio/publisher-a/Llama-3-8B-GGUF"; - const otherDir = "/srv/models/publisher-b/Llama-3-8B-GGUF"; - assert.equal(publicModelId(loadedDir), publicModelId(otherDir)); - assert.equal( - residentModelIdMatches(publicModelId(loadedDir), otherDir, otherDir), - false, - ); - - // A cache snapshot still collapses onto its repo id, which names one model. - assert.equal( - residentModelIdMatches( - "unsloth/Qwen3-8B-GGUF", - "/mnt/old-cache/models--unsloth--Qwen3-8B-GGUF/snapshots/abc123", - null, - ), - true, - ); -}); - -test("Ollama link paths are recognised the way the resolver excludes them", () => { - // core/inference/local_model_resolver.py refuses any path with these segments. - assert.equal( - isOllamaLinkPath("/home/u/.ollama/models/.studio_links/q/qwen3-Q4_K_M.gguf"), - true, - ); - assert.equal( - isOllamaLinkPath("/home/u/.cache/unsloth/ollama_links/ab12/llama3.gguf"), - true, - ); - assert.equal( - isOllamaLinkPath("C:\\Users\\u\\.ollama\\models\\.studio_links\\q\\a.gguf"), - true, - ); - // Only those exact segments, not a directory that merely contains the name. - assert.equal(isOllamaLinkPath("/srv/studio_links_backup/a.gguf"), false); - assert.equal(isOllamaLinkPath("/srv/models/Qwen3-8B-Q4_K_M.gguf"), false); - assert.equal(isOllamaLinkPath("unsloth/Qwen3-8B-GGUF"), false); - assert.equal(isOllamaLinkPath(null), false); -}); - -test("a standalone gguf keeps one settings identity across surfaces", () => { - const loose = { - kind: "local", - path: "/srv/models/Qwen3-8B-Q4_K_M.gguf", - // What hub/services/models/common.py emits for a single scanned file. - formatVariant: "Q4_K_M", - } as LocalInventoryRow; - // The Chat picker opens the same file with no variant, so the Hub row must not - // adopt the filename-derived label or the two edit different configs. - assert.equal(settingsGgufVariantForRow(loose), null); - - // A GGUF directory still has a variant slot for the quant lookup to fill. - const repoDir = { - kind: "local", - path: "/srv/models/Qwen3-8B-GGUF", - formatVariant: null, - } as LocalInventoryRow; - assert.equal(settingsGgufVariantForRow(repoDir), null); - const lmStudioDir = { - kind: "local", - path: "/srv/lmstudio/Qwen3-8B-GGUF", - formatVariant: "Q8_0", - } as LocalInventoryRow; - assert.equal(settingsGgufVariantForRow(lmStudioDir), "Q8_0"); - - // Cached repo rows are unaffected (cache_inventory.py never sets one). - const cached = { kind: "cache", formatVariant: null } as CachedInventoryRow; - assert.equal(settingsGgufVariantForRow(cached), null); -}); diff --git a/studio/frontend/tests/per-model-config-storage-identity.test.ts b/studio/frontend/tests/per-model-config-storage-identity.test.ts deleted file mode 100644 index ce165ffb1b..0000000000 --- a/studio/frontend/tests/per-model-config-storage-identity.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import assert from "node:assert/strict"; -import { register } from "node:module"; -import test from "node:test"; - -register("./bundler-resolver.mjs", import.meta.url); - -const store = new Map(); -const storage = { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => { - store.set(key, value); - }, - removeItem: (key: string) => { - store.delete(key); - }, -}; -Object.assign(globalThis, { - window: { localStorage: storage }, - localStorage: storage, -}); - -const REPO_KEY = 'v2:["unsloth/repo-gguf","q4_k_m"]'; - -// The legacy import of unsloth_load_settings runs once, on the first read after -// load, so it has to be staged before the module is imported. -store.set( - "unsloth_model_configs", - JSON.stringify({ [REPO_KEY]: { version: 1, maxSeqLength: 32768 } }), -); -store.set( - "unsloth_load_settings", - JSON.stringify({ "Unsloth/Repo-GGUF::Q4_K_M": { contextLength: 8192 } }), -); - -const { listPerModelConfigs, resolveInitialConfig, savePerModelConfig } = - await import("../src/features/model-picker/model-config/per-model-config.ts"); - -function config(maxSeqLength: number, kvCacheDtype: string | null = null) { - return { - customContextLength: null, - maxSeqLength, - kvCacheDtype, - speculativeType: null, - specDraftNMax: null, - nParallel: null, - tensorParallel: false, - chatTemplateOverride: null, - }; -} - -function storedKeys(): string[] { - return Object.keys( - JSON.parse(storage.getItem("unsloth_model_configs") ?? "{}"), - ); -} - -// The one-time backfill re-reads listPerModelConfigs() to pick up a save that -// landed while the override fetch was in flight, and matches on the folded -// identity. That is only unambiguous because storage holds one record per model, -// so these pin that rule rather than the backfill. -test("importing the legacy load settings never doubles up a model", () => { - // The typed casing in unsloth_load_settings names the model the v2 record - // already holds, so the import has to leave it alone rather than add a second - // record the picker would prefer and the backfill would not. - assert.deepEqual(listPerModelConfigs().length, 1); - assert.deepEqual(storedKeys(), [REPO_KEY]); - assert.equal( - resolveInitialConfig("unsloth/repo-gguf", "q4_k_m").config - .customContextLength, - null, - ); -}); - -test("two spellings of one model id keep a single stored record", () => { - store.clear(); - savePerModelConfig("Unsloth/Repo-GGUF", "Q4_K_M", config(4096)); - savePerModelConfig("unsloth/repo-gguf", "q4_k_m", config(32768, "q8_0")); - - assert.deepEqual(storedKeys(), [REPO_KEY]); - const listed = listPerModelConfigs(); - assert.equal(listed.length, 1); - assert.equal(listed[0]?.config.maxSeqLength, 32768); - // What the picker applies and the only thing the backfill can see agree. - assert.equal( - resolveInitialConfig("Unsloth/Repo-GGUF", "Q4_K_M").config.maxSeqLength, - 32768, - ); -}); - -test("two spellings of one Windows path keep a single stored record", () => { - store.clear(); - savePerModelConfig("C:\\Models\\Foo.gguf", null, config(4096)); - savePerModelConfig("c:/models/foo.gguf", null, config(32768, "q8_0")); - - assert.deepEqual(storedKeys(), ['v2:["c:/models/foo.gguf",""]']); - assert.equal(listPerModelConfigs().length, 1); -}); - -test("a POSIX path is case sensitive, so its two spellings stay separate", () => { - store.clear(); - savePerModelConfig("/models/Foo.gguf", null, config(4096)); - savePerModelConfig("/models/foo.gguf", null, config(32768, "q8_0")); - - assert.equal(storedKeys().length, 2); - assert.equal( - resolveInitialConfig("/models/Foo.gguf", null).config.maxSeqLength, - 4096, - ); -}); diff --git a/studio/frontend/tests/quant-suffix-split.test.ts b/studio/frontend/tests/quant-suffix-split.test.ts deleted file mode 100644 index 55c1bc84f3..0000000000 --- a/studio/frontend/tests/quant-suffix-split.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import assert from "node:assert/strict"; -import { register } from "node:module"; -import test from "node:test"; - -// The module under test resolves the way vite and tsconfig resolve, not the way -// bare node does. -register("./bundler-resolver.mjs", import.meta.url); - -const { modelStorageKey, splitQuantSuffix } = await import( - "../src/features/model-picker/model-config/model-identity.ts" -); - -// Every answer below is the one split_quant_suffix in -// studio/backend/utils/openai_auto_switch_settings.py gives for the same key. The -// backfill folds a stored key with this before comparing it against the server's, -// so a suffix this splits and the backend does not collapses two models onto one -// key on the browser side only. -const CASES: [string, [string, string] | null][] = [ - // A known quant label, with and without the optional bpw modifier. - ["org/Repo-GGUF:Q4_K_M", ["org/Repo-GGUF", "Q4_K_M"]], - ["org/Repo-GGUF:IQ4_XS-3.53bpw", ["org/Repo-GGUF", "IQ4_XS-3.53bpw"]], - ["org/Repo-GGUF:UD-Q4_K_XL", ["org/Repo-GGUF", "UD-Q4_K_XL"]], - // A .gguf with no quant token in its name is labelled by its stem, and storage - // lowercases the label while the scanner keeps the filename's casing. - ["/models/CustomModel.gguf:custommodel", ["/models/CustomModel.gguf", "custommodel"]], - ["/models/CustomModel.gguf:CustomModel", ["/models/CustomModel.gguf", "CustomModel"]], - ["C:\\models\\CustomModel.gguf:custommodel", ["C:\\models\\CustomModel.gguf", "custommodel"]], - // A shard suffix is not part of the label. - [ - "/models/Custom-00001-of-00003.gguf:custom", - ["/models/Custom-00001-of-00003.gguf", "custom"], - ], - ["/models/Custom-00001-of-00003.gguf:custom-00001-of-00003", null], - // An extensionless .gguf still has a label. - ["/models/.gguf:gguf", ["/models/.gguf", "gguf"]], - // A quant token inside the filename wins over the stem. - ["/models/tinyllama-Q4_K_M.gguf:q4_k_m", ["/models/tinyllama-Q4_K_M.gguf", "q4_k_m"]], - ["/models/tinyllama-Q4_K_M.gguf:tinyllama-q4_k_m", null], - // Only the basename is labelled, never the directories above it. - [ - "/models/dir/CustomModel.gguf:custommodel", - ["/models/dir/CustomModel.gguf", "custommodel"], - ], - ["/models/dir/CustomModel.gguf:dir/custommodel", null], - // A colon is legal in a POSIX filename. Neither of these is a variant, and - // reading them as one folds two real files onto a single key. - ["/models/foo:Bar.gguf", null], - ["/models/foo:bar.gguf", null], - ["/models/llama.gguf:Bar.gguf", null], - ["/models/llama.gguf:bar.gguf", null], - ["/models/CustomModel.gguf:othermodel", null], - ["/models/model.gguf:notalabel", null], - ["/models/plain.gguf:plain:extra", null], - // A Windows drive letter is not a separator either. - ["C:\\models\\foo.gguf", null], - ["C:/models/foo.gguf", null], - // Nothing to split. - ["org/Repo-GGUF", null], - ["/models/foo.gguf", null], - ["org/Repo:", null], - [":Q4_K_M", null], -]; - -test("splitQuantSuffix answers exactly as the backend's split_quant_suffix", () => { - for (const [value, expected] of CASES) { - assert.deepEqual(splitQuantSuffix(value), expected, value); - } -}); - -test("a .gguf filename carrying a colon is not folded into a variant", () => { - // Two real, distinct files: POSIX allows a colon in a name and is case - // sensitive, so the one-time backfill has to keep their settings apart. The - // variant half of an override key is stored lowercased, so folding these makes - // one key and strands whichever file the backfill reaches second. - const upper = "/models/llama.gguf:Bar.gguf"; - const lower = "/models/llama.gguf:bar.gguf"; - assert.equal(splitQuantSuffix(upper), null); - assert.equal(splitQuantSuffix(lower), null); - assert.notEqual(modelStorageKey(upper, null), modelStorageKey(lower, null)); -}); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index c674dbc01e..d8739c269a 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -27,9 +27,9 @@ def _read(rel: str) -> str: def test_models_api_sends_token_via_header_not_query(): - """getModelConfig / checkVisionModel / checkEmbeddingModel must pass the HF - token through hubTokenHeader, never as a ?hf_token= query param (which leaks - the credential into server/proxy access logs).""" + """getModelConfig / checkVisionModel / checkEmbeddingModel must pass the HF token + through hubTokenHeader, never as a ?hf_token= query param (which leaks the + credential into server/proxy access logs).""" src = _read("features/training/api/models-api.ts") assert src.count("hubTokenHeader(") >= 3 assert "hf_token=" not in src @@ -43,27 +43,27 @@ def test_model_metadata_probe_never_puts_token_in_query(): def test_model_config_page_floors_the_context_ceiling(): - """The model's native max-context must be FLOORED to the step grid, never - rounded up (rounding up can offer/persist a length above the model's real - ceiling and break loading).""" + """The model's native max-context must be FLOORED to the step grid, never rounded up + (rounding up can offer/persist a length above the model's real ceiling and break + loading).""" src = _read("features/model-picker/components/model-config-page.tsx") assert "floorMaxSeqLength(modelMaxPosition.maxPositionEmbeddings)" in src assert "normalizeMaxSeqLength(modelMaxPosition.maxPositionEmbeddings)" not in src def test_compare_load_clears_stale_native_lease(): - """A compare-pane load never comes from the desktop file picker, so it must - clear any prior picked file's lease token + expiry, otherwise a reload can - send a stale lease for the now-active model.""" + """A compare-pane load never comes from the desktop file picker, so it must clear any + prior picked file's lease token + expiry, otherwise a reload can send a stale lease + for the now-active model.""" src = _read("features/chat/shared-composer.tsx") assert "activeNativePathToken: null" in src assert "activeNativePathExpiresAtMs: null" in src def test_autoload_records_backend_loaded_model_identity(): - """An inactive-cache inventory row loads by local path, so startup autoload - must key both the active checkpoint and its summary by the backend's loaded - model identity instead of the catalog repo id.""" + """An inactive-cache inventory row loads by local path, so startup autoload must key + both the active checkpoint and its summary by the backend's loaded model identity + instead of the catalog repo id.""" src = _read("features/chat/api/chat-adapter.ts") autoload = src.split("async function loadAutoLoadCandidate", 1)[1] autoload = autoload.split("\n try {", 1)[0] @@ -74,8 +74,8 @@ def test_autoload_records_backend_loaded_model_identity(): def test_chat_autoload_toast_is_persistent_and_dismissible(): - """Send-triggered autoload stays visible until it settles but remains - dismissible, matching the explicit model-loading toast's lifetime.""" + """Send-triggered autoload stays visible until it settles but remains dismissible, + matching the explicit model-loading toast's lifetime.""" src = _read("features/chat/api/chat-adapter.ts") auto_load = src.split("async function autoLoadSmallestModel", 1)[1] auto_load = auto_load.split("export function createOpenAIStreamAdapter", 1)[0] @@ -101,8 +101,8 @@ def test_chat_autoload_toast_is_persistent_and_dismissible(): def test_recipe_model_load_toast_is_persistent_and_dismissible(): - """Recipe model loading uses the same dismissible persistent lifecycle as - chat loading because both call the non-abortable loadModel API.""" + """Recipe model loading uses the same dismissible persistent lifecycle as chat loading + because both call the non-abortable loadModel API.""" src = _read("features/recipe-studio/hooks/use-recipe-executions.ts") model_load = src.split("async function loadLocalModelSelection", 1)[1] model_load = model_load.split("function getLocalModelLoadPlanForPayload", 1)[0] @@ -125,9 +125,9 @@ def test_recipe_model_load_toast_is_persistent_and_dismissible(): def test_rollback_restores_native_lease_expiry_with_token(): - """A failed model switch that rolls back to a previously loaded picked GGUF - must restore the lease expiry paired with the token, never the token alone - (which would look non-expiring and skip the expiry guard).""" + """A failed model switch that rolls back to a previously loaded picked GGUF must + restore the lease expiry paired with the token, never the token alone (which would + look non-expiring and skip the expiry guard).""" src = _read("features/chat/hooks/use-chat-model-runtime.ts") assert "previousActiveNativePathExpiresAtMs" in src assert re.search( @@ -136,17 +136,17 @@ def test_rollback_restores_native_lease_expiry_with_token(): def test_default_caches_keyed_on_inventory_version(): - """The chat-template and max-position caches must key on the inventory - version so a model update in the same session invalidates the cached value - instead of showing the stale revision.""" + """The chat-template and max-position caches must key on the inventory version so a + model update in the same session invalidates the cached value instead of showing the + stale revision.""" src = _read("features/model-picker/hooks/use-model-defaults.ts") # Both cache keys (template + max-position) end with the inventory version. assert src.count("${inventoryVersion}") >= 2 def test_hidden_infra_model_needles_present(): - """The frontend static needle list must keep hiding the RAG embedder and the - llama.cpp validation probe.""" + """The frontend static needle list must keep hiding the RAG embedder and the llama.cpp + validation probe.""" src = _read("features/hub/lib/hidden-models.ts") assert '"bge-small-en-v1.5"' in src assert '"ggml-org/models"' in src @@ -154,9 +154,9 @@ def test_hidden_infra_model_needles_present(): def test_hidden_models_dynamic_exact_ids_wired(): - """The configured embedder arrives from /api/hub/hidden-models as exact - repo ids; a substring needle would let a generic basename like "model" - hide unrelated chat models.""" + """The configured embedder arrives from /api/hub/hidden-models as exact repo ids; a + substring needle would let a generic basename like "model" hide unrelated chat + models.""" src = _read("features/hub/lib/hidden-models.ts") assert "toLowerStrings(data.exact_ids)" in src assert "dynamicExactIds.includes(lower)" in src @@ -170,9 +170,9 @@ def test_hidden_model_matchers_refresh_with_inventory_version(): def test_diffusion_capability_labeled_image_generation(): - """The diffusion capability detects image GENERATORS (FLUX, SDXL, - text-to-image tags); labeling it "Image to text" showed generators when - users asked for captioning models.""" + """The diffusion capability detects image GENERATORS (FLUX, SDXL, text-to-image tags); + labeling it "Image to text" showed generators when users asked for captioning + models.""" for rel in ( "features/hub/lib/model-capabilities.ts", "features/hub/lib/model-type-filter.ts", @@ -184,9 +184,9 @@ def test_diffusion_capability_labeled_image_generation(): def test_active_model_config_round_trips_gpu_fields(): - """The active model's config must carry the GPU Memory knobs (GGUF only) so - a sidebar/hub-gear reload cannot silently reset manual GPU settings, and - "Remember settings" cannot persist a GPU-less config over a saved one.""" + """The active model's config must carry the GPU Memory knobs (GGUF only) so a + sidebar/hub-gear reload cannot silently reset manual GPU settings, and "Remember + settings" cannot persist a GPU-less config over a saved one.""" src = _read("features/model-picker/hooks/use-active-model-config.ts") for field in ("gpuMemoryMode", "gpuLayers", "nCpuMoe", "selectedGpuIds"): assert field in src, field @@ -196,9 +196,8 @@ def test_active_model_config_round_trips_gpu_fields(): "features/hub/catalog/sampling-settings-dialog.tsx", ): assert "useActiveModelConfig(" in _read(rel), rel - # The GPU knobs are part of the editor's instance key, so a reload that lands - # on different placement re-seeds the editor instead of leaving it on the old - # values. Shared by every host that mounts ModelConfigPage. + # The GPU knobs are part of the editor's instance key, so a reload that lands on + # different placement re-seeds the editor instead of leaving it on the old values. shared = _read("features/model-picker/model-config/config-signature.ts") assert "export function gpuFieldsSignature" in shared assert "gpuFieldsSignature(config)," in shared @@ -214,8 +213,8 @@ def test_active_model_config_round_trips_gpu_fields(): def test_gpu_picker_round_trips_requested_pool_not_fitted_subset(): - """A GGUF fit may narrow [0, 1] to [0], but load/status hydration must keep - [0, 1] as the editable pool so a later reload can grow back onto GPU 1.""" + """A GGUF fit may narrow [0, 1] to [0], but load/status hydration must keep [0, 1] as + the editable pool so a later reload can grow back onto GPU 1.""" types = _read("features/chat/types/api.ts") assert types.count("requested_gpu_ids?: number[] | null") >= 2 @@ -256,9 +255,9 @@ def test_model_default_hooks_do_not_reset_state_in_effect(): def test_variant_expander_refreshes_after_delete(): - """Deleting a downloaded quant from an expanded repo that still has other - cached quants must bump the expander refresh key, or the deleted quant stays - shown as downloaded and clickable and tries to reload the removed file.""" + """Deleting a downloaded quant from an expanded repo that still has other cached quants + must bump the expander refresh key, or the deleted quant stays shown as downloaded + and clickable and tries to reload the removed file.""" src = _read("features/model-picker/components/model-selector/pickers.tsx") del_confirm = re.search( r"await onDeleteVariant\(v\.quant\);.*?setRefreshKey\(\(key\) => key \+ 1\)", @@ -269,10 +268,7 @@ def test_variant_expander_refreshes_after_delete(): def test_local_picker_rows_require_chat_capability(): - """Local inventory rows can be classified non-chat (canChat false, e.g. a - folder with only config.json). The picker must filter those out, or selecting - one loads a weightless path; toLocalModelInfo drops capabilities so the memo - is the only place the guard can live.""" + """Local inventory rows can be classified non-chat (canChat false, e.g.""" src = _read("features/model-picker/inventory/use-chat-picker-inventory.ts") memo = re.search(r"const localModels = useMemo\(.*?\[inventory\.localRows\]", src, re.S) assert memo, "localModels memo not found" @@ -280,8 +276,8 @@ def test_local_picker_rows_require_chat_capability(): def test_model_picker_toolbar_reflows_before_crossing_picker_edge(): - """The content-sized section tabs and fixed-width dropdowns must reflow, - while an oversized tab group must shrink labels but preserve its icons.""" + """The content-sized section tabs and fixed-width dropdowns must reflow, while an + oversized tab group must shrink labels but preserve its icons.""" picker = _read("features/model-picker/components/model-selector/pickers.tsx") assert '"flex flex-wrap items-center gap-2"' in picker assert 'hasConnected ? "-mr-4" : "-mr-2"' in picker @@ -298,12 +294,10 @@ def test_model_picker_toolbar_reflows_before_crossing_picker_edge(): def test_native_picked_gguf_template_read_through_lease(): - """A native (picked / drag-drop) GGUF's path lives only in its signed lease, - and the picker chat-template GET has no lease plumbing, so the default - template must be read through the lease-aware validate probe: mint a - validate-model lease and post include_chat_template. The native token also - has to reach the fetch (threaded through the hook) and be part of the cache - key so two picks of the same basename don't share a template.""" + """A native (picked / drag-drop) GGUF's path lives only in its signed lease, and the + picker chat-template GET has no lease plumbing, so the default template must be read + through the lease-aware validate probe: mint a validate-model lease and post + include_chat_template.""" api = _read("features/model-picker/api/templates.ts") assert 'consumeNativePathToken(nativePathToken, "validate-model")' in api assert "include_chat_template: true" in api @@ -314,10 +308,9 @@ def test_native_picked_gguf_template_read_through_lease(): def test_model_load_guard_is_cross_instance(): - """The in-flight load guard must consult the shared store pick (not only the - per-hook ref) and ejectModel must refuse while any instance is loading: - three live useChatModelRuntime instances exist (chat page, hub page, hub - gear dialog).""" + """The in-flight load guard must consult the shared store pick (not only the per-hook + ref) and ejectModel must refuse while any instance is loading: three live + useChatModelRuntime instances exist (chat page, hub page, hub gear dialog).""" src = _read("features/chat/hooks/use-chat-model-runtime.ts") assert "useChatRuntimeStore.getState().loadingModelPick" in src assert "clearLoadingModelPick" in src @@ -326,23 +319,17 @@ def test_model_load_guard_is_cross_instance(): def test_partial_safetensors_download_keeps_delete_menu(): - """A stopped partial safetensors download must keep its options menu (the - Delete affordance) like the GGUF card does, or partial downloads can only - be cleaned up by finishing or leaving them. During an ACTIVE download the - menu stays hidden (every item would be disabled: no Copy path while not - downloaded, no Delete while downloading, pin suppressed in the run bar).""" + """A stopped partial safetensors download must keep its options menu (the Delete + affordance) like the GGUF card does, or partial downloads can only be cleaned up by + finishing or leaving them.""" src = _read("features/hub/catalog/safetensors-download-card.tsx") assert "(isDownloaded || (isPartial && !downloading))" in src def test_pinned_validation_uses_cached_local_variant_listing(): - """Pinned-quant validation must use the TTL-cached hub client with - preferLocalCache (downloaded-ness is local state) instead of one uncached - round-trip per pinned repo on every picker open. Picker deletes must go - through the hub inventory client, whose delete invalidates both the - variants TTL cache and the server-side HF cache scan (the legacy - /api/models/delete-cached route invalidates neither, so a post-delete - inventory refresh would resurrect the deleted row until the scan TTL).""" + """Pinned-quant validation must use the TTL-cached hub client with preferLocalCache + (downloaded-ness is local state) instead of one uncached round-trip per pinned repo + on every picker open.""" src = _read("features/model-picker/components/model-selector/pickers.tsx") assert "listGgufVariantsCached(" in src assert "preferLocalCache: true" in src @@ -355,8 +342,8 @@ def test_pinned_validation_uses_cached_local_variant_listing(): def test_chat_autoload_scopes_variant_lookup_to_cached_repo_path(): - """Autoload must probe the exact cache row it will load, including rows - retained from a previously selected Hugging Face cache.""" + """Autoload must probe the exact cache row it will load, including rows retained from a + previously selected Hugging Face cache.""" src = _read("features/chat/api/chat-adapter.ts") auto_load = src.split("async function autoLoadSmallestModel", 1)[1] assert auto_load.count("preferLocalCache: true") >= 2 @@ -370,8 +357,8 @@ def test_chat_autoload_scopes_variant_lookup_to_cached_repo_path(): def test_cache_location_update_invalidates_frontend_inventory(): - """A successful cache switch must refresh both inventory rows and cached - GGUF variant results before any stale active-cache identity can be reused.""" + """A successful cache switch must refresh both inventory rows and cached GGUF variant + results before any stale active-cache identity can be reused.""" src = _read("features/settings/api/hugging-face-cache.ts") update_fn = src.split("export async function updateHuggingFaceCacheSettings", 1)[1] assert "bumpInventoryVersion();" in update_fn @@ -379,18 +366,17 @@ def test_cache_location_update_invalidates_frontend_inventory(): def test_downloaded_list_offsets_virtual_rows(): - """The On Device virtualized list sits below the Pinned block in the same - scroll element, so it must pass its measured offset as scrollMargin or rows - past the overscan render blank.""" + """The On Device virtualized list sits below the Pinned block in the same scroll + element, so it must pass its measured offset as scrollMargin or rows past the + overscan render blank.""" src = _read("features/hub/catalog/models-catalog-lists.tsx") assert "scrollMargin={scrollMargin}" in src def test_local_gguf_diagnostics_gate_on_broad_is_gguf(): - """The MTP fallback note and the context/VRAM warning must gate on the broad - isGguf (variant, loaded gguf context, or .gguf suffix), not the variant-only - isLoadedGguf, so direct-file and custom-folder GGUF loads keep those - diagnostics.""" + """The MTP fallback note and the context/VRAM warning must gate on the broad isGguf + (variant, loaded gguf context, or .gguf suffix), not the variant-only isLoadedGguf, + so direct-file and custom-folder GGUF loads keep those diagnostics.""" src = _read("features/chat/chat-settings-sheet.tsx") spec = re.search(r"const showSpecFallback =.*?;", src, re.S) vram = re.search(r"const showContextVramWarning =.*?;", src, re.S) @@ -399,9 +385,9 @@ def test_local_gguf_diagnostics_gate_on_broad_is_gguf(): def test_fixed_layer_gguf_pins_displayed_context(): - """An already-loaded auto-fit GGUF saved with Manual fixed GPU layers must - pin the shown context, so a later fresh load keeps the fitted placement - instead of sending native/0 and recreating the OOM.""" + """An already-loaded auto-fit GGUF saved with Manual fixed GPU layers must pin the + shown context, so a later fresh load keeps the fitted placement instead of sending + native/0 and recreating the OOM.""" src = _read("features/model-picker/components/model-config-page.tsx") assert "const pinFixedLayerContext =" in src assert 'config.gpuMemoryMode === "manual"' in src @@ -409,11 +395,8 @@ def test_fixed_layer_gguf_pins_displayed_context(): def test_fixed_layer_pin_recomputed_after_committing_gpu_layers(): - """pinFixedLayerContext is computed from the render-time config, before a - same-click GPU Layers draft is committed. handleRun must recompute it from the - committed effectiveConfig; otherwise typing a positive GPU Layers value on an - auto-fit GGUF and clicking Reload saves customContextLength: null, so a later - fresh load sends the native context with fixed layers (the OOM the pin avoids).""" + """pinFixedLayerContext is computed from the render-time config, before a same-click + GPU Layers draft is committed.""" src = _read("features/model-picker/components/model-config-page.tsx") assert "const effectivePinFixedLayerContext =" in src assert 'effectiveConfig.gpuMemoryMode === "manual"' in src @@ -424,11 +407,7 @@ def test_fixed_layer_pin_recomputed_after_committing_gpu_layers(): def test_blur_cache_cleared_on_every_settled_render(): """The lastBlurCommittedRef bridge is valid only across the single synchronous - same-click gesture that set it. Keying its clear on [value] missed a Reset (or - external edit) that restores the shown value unchanged after the blur dispatched - onChange: value nets back to its prior number, the effect never re-ran, and a - later Load/Save replayed the override Reset removed. Clear it on every settled - render instead.""" + same-click gesture that set it.""" src = _read("features/model-picker/components/numeric-value-input.tsx") # The clearing effect must run on every commit, not be gated on [value] alone. assert not re.search(r"lastBlurCommittedRef\.current = null;\s*\}, \[value\]\);", src) @@ -439,9 +418,9 @@ def test_blur_cache_cleared_on_every_settled_render(): def test_auto_defaults_not_persisted_as_overrides(): - """Auto GPU memory mode and Auto/default speculative type are follow-global - defaults; normalization must not persist them as per-model overrides, else a - model stops following later changes to the global preference.""" + """Auto GPU memory mode and Auto/default speculative type are follow-global defaults; + normalization must not persist them as per-model overrides, else a model stops + following later changes to the global preference.""" src = _read("features/model-picker/model-config/per-model-config.ts") assert 'if (partial.gpuMemoryMode === "manual") {' in src assert 'partial.gpuMemoryMode === "auto" || partial.gpuMemoryMode === "manual"' not in src @@ -450,18 +429,18 @@ def test_auto_defaults_not_persisted_as_overrides(): def test_compare_pane_context_from_own_config_only(): - """A compare pane's context comes from its own config only (a saved pin, else - null for Auto/native); it must not inherit the active model's shared snapshot, - which resolveFitMaxSeqLength would treat as an explicit pin (VRAM/OOM).""" + """A compare pane's context comes from its own config only (a saved pin, else null for + Auto/native); it must not inherit the active model's shared snapshot, which + resolveFitMaxSeqLength would treat as an explicit pin (VRAM/OOM).""" src = _read("features/chat/shared-composer.tsx") assert "const effectiveCustomContextLength = ownConfig.customContextLength;" in src assert "compareLoadKnobs.customContextLength" not in src def test_reset_max_seq_length_falls_back_to_app_default(): - """After Reset clears maxSeqLength (null), a non-GGUF active model's shown - max sequence length must fall back to the app default, never the loaded - runtime snapshot, or a remembered/active override can never be cleared.""" + """After Reset clears maxSeqLength (null), a non-GGUF active model's shown max sequence + length must fall back to the app default, never the loaded runtime snapshot, or a + remembered/active override can never be cleared.""" src = _read("features/model-picker/components/model-config-page.tsx") # The null fallback resolves to the app-default constant, not a runtime value. assert "clampMaxSeqLength(DEFAULT_MAX_SEQ_LENGTH, nativeMaxSeqLength)" in src @@ -470,9 +449,9 @@ def test_reset_max_seq_length_falls_back_to_app_default(): def test_reset_persists_null_max_length_and_substitutes_only_for_load(): - """The persisted per-model record must keep config.maxSeqLength (null after - Reset) so isDefaultConfig can clear a remembered override; the concrete - fallback is substituted only into the load request, not the saved record.""" + """The persisted per-model record must keep config.maxSeqLength (null after Reset) so + isDefaultConfig can clear a remembered override; the concrete fallback is + substituted only into the load request, not the saved record.""" src = _read("features/model-picker/components/model-config-page.tsx") # Load-only substitution of the resolved value (recomputed from any committed # same-click Max Seq Length draft, so it is never dropped). @@ -485,8 +464,8 @@ def test_reset_persists_null_max_length_and_substitutes_only_for_load(): def test_initial_load_uses_staged_config_payload(): - """Run-settings Load must pass the staged config through to /load even when - React has not flushed NumericValueInput blur commits into the store yet.""" + """Run-settings Load must pass the staged config through to /load even when React has + not flushed NumericValueInput blur commits into the store yet.""" runtime = _read("features/chat/hooks/use-chat-model-runtime.ts") assert "const pendingLoadConfig =" in runtime assert "pendingLoadConfig?.kvCacheDtype" in runtime @@ -522,11 +501,8 @@ def test_initial_load_uses_staged_config_payload(): def test_same_click_commit_covers_all_numeric_inputs(): - """The same-click blur bridge must flush every NumericValueInput-backed - setting, not just Context Length. Max Seq Length (non-GGUF), GPU Layers and - MoE Layers (GGUF) also stage their draft only on blur, so handleRun must - imperatively commit each and fold the value into the staged load config; - otherwise a value the user typed right before clicking Load/Reload is lost.""" + """The same-click blur bridge must flush every NumericValueInput-backed setting, not + just Context Length.""" page = _read("features/model-picker/components/model-config-page.tsx") # Each numeric input owns an imperative handle that handleRun commits, and the # handle is forwarded down to the actual NumericValueInput. @@ -559,10 +535,8 @@ def test_context_commit_rechecks_persistence_only_shortcut(): def test_reset_enabled_for_explicit_context_pin_at_native(): - """An explicit customContextLength that equals the native ceiling is still a - user override, so contextAtDefault must require customContextLength == null. - The buggy form treated `contextValue === native` alone as default, wedging - the Reset button disabled for a deliberate pin-to-native.""" + """An explicit customContextLength that equals the native ceiling is still a user + override, so contextAtDefault must require customContextLength == null.""" src = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert ( "const contextAtDefault = !target.isGguf || " @@ -580,9 +554,9 @@ def test_reset_enabled_for_explicit_context_pin_at_native(): def test_compare_pane_non_gguf_falls_back_to_app_default(): - """A non-GGUF compare pane with no saved maxSeqLength must fall back to the - shared app default, not the active model's runtime snapshot; otherwise an - unconfigured pane inherits a saved 128K neighbor's context and can OOM.""" + """A non-GGUF compare pane with no saved maxSeqLength must fall back to the shared app + default, not the active model's runtime snapshot; otherwise an unconfigured pane + inherits a saved 128K neighbor's context and can OOM.""" per_model = _read("features/model-picker/model-config/per-model-config.ts") assert "export const DEFAULT_MAX_SEQ_LENGTH = 4096;" in per_model barrel = _read("features/model-picker/index.ts") @@ -601,8 +575,8 @@ def test_compare_pane_non_gguf_falls_back_to_app_default(): def test_default_gpu_mode_clears_manual_knobs(): """Switching GPU Memory back to Default must clear the Manual-only knobs - (gpuLayers/nCpuMoe/selectedGpuIds); otherwise a remembered config keeps stale - pins that a later load re-applies when the global preference is Manual.""" + (gpuLayers/nCpuMoe/selectedGpuIds); otherwise a remembered config keeps stale pins + that a later load re-applies when the global preference is Manual.""" src = _read("features/model-picker/components/model-config-page.tsx") assert 'gpuMemoryMode: "auto",' in src assert "gpuLayers: undefined," in src @@ -611,13 +585,10 @@ def test_default_gpu_mode_clears_manual_knobs(): def test_legacy_migration_is_idempotent_and_non_destructive(): - """The v1->v2 localStorage migration (unsloth_load_settings -> - unsloth_model_configs) is invoked on every store read, so it must be - idempotent: repeated reads, browser reloads, and Studio restarts must never - re-migrate, duplicate records, or overwrite a newer per-model config. This - was the class of regression that reverted the predecessor PR, so pin all - three idempotency layers at source level; dropping any of them reddens here. - """ + """The v1->v2 localStorage migration (unsloth_load_settings -> unsloth_model_configs) + is invoked on every store read, so it must be idempotent: repeated reads, browser + reloads, and Studio restarts must never re-migrate, duplicate records, or overwrite + a newer per-model config.""" raw = _read("features/model-picker/model-config/per-model-config.ts") src = " ".join(raw.split()) # Migration runs from readMap (every store read), so it must be safe to repeat. @@ -630,10 +601,7 @@ def test_legacy_migration_is_idempotent_and_non_destructive(): assert "let legacyMigrationChecked = false;" in src assert "if (legacyMigrationChecked || !canUseStorage()) {" in src assert "legacyMigrationChecked = true;" in src - # Layer 2: persistent cross-session flag so a completed migration is never - # redone. Set in every terminal branch (malformed data, nothing to migrate, - # successful write); a failed quota write leaves it unset so the next session - # retries. Three set-sites encode exactly that. + # Layer 2: persistent cross-session flag so a completed migration is never redone. assert 'const LEGACY_MIGRATION_FLAG = "unsloth_model_configs_migrated";' in src assert "if (localStorage.getItem(LEGACY_MIGRATION_FLAG)) {" in src assert src.count('localStorage.setItem(LEGACY_MIGRATION_FLAG, "1");') >= 3 @@ -643,10 +611,10 @@ def test_legacy_migration_is_idempotent_and_non_destructive(): def test_parallel_slots_setting_wired_end_to_end(): - """The per-load Parallel Slots knob (llama-server --parallel) must flow from - the run-settings form through persistence, every /load builder, the validate - preflight and the cross-model reset; a lost hop silently reverts the model to - the server-wide slot default.""" + """The per-load Parallel Slots knob (llama-server --parallel) must flow from the + run-settings form through persistence, every /load builder, the validate preflight + and the cross-model reset; a lost hop silently reverts the model to the server-wide + slot default.""" config = _read("features/model-picker/model-config/per-model-config.ts") # Persisted per model, clamped on every read/write, and null (= server # default) counts as default so blank configs are not stored. @@ -684,9 +652,7 @@ def test_parallel_slots_setting_wired_end_to_end(): # the control would pin a blank "server default" to a number. assert "loadedNParallel: status.requested_parallel_slots," in status assert "nParallel: status.requested_parallel_slots," not in status - # The sidebar form remounts when an external change lands. The signature it - # keys on is shared with the hub and the model config page now, so the slot - # count has to be in that one definition rather than the sidebar's own copy. + # The sidebar form remounts when an external change lands. signature = _read("features/model-picker/model-config/config-signature.ts") assert 'config.nParallel ?? "",' in signature sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split()) @@ -694,13 +660,8 @@ def test_parallel_slots_setting_wired_end_to_end(): def test_parallel_slots_reach_an_api_load_through_the_server_mirror(): - """The server mirror is the hop an OpenAI-compatible auto-switch load reads, - and it is the browser's only way to express a per-model setting to a load no - browser makes. A slot count missing from it silently reverts that load to the - server-wide --parallel default, and llama_extra_args cannot stand in because - --parallel is denylisted. A config whose only change is the slot count also - serializes to an empty payload, so the one-time backfill sends nothing and - still marks itself done.""" + """The server mirror is the hop an OpenAI-compatible auto-switch load reads, and it is + the browser's only way to express a per-model setting to a load no browser makes.""" api = " ".join(_read("features/model-picker/api/model-overrides.ts").split()) assert "n_parallel?: number;" in api assert ( @@ -725,16 +686,11 @@ def test_parallel_slots_reach_an_api_load_through_the_server_mirror(): def test_parallel_slots_control_cleared_when_the_load_never_sent_them(): - """`nParallel` is the editable control ("blank = follow the server default") - and `loadedNParallel` the rollback baseline. A success path that sends no - slot count must blank the control, or a value staged for another model shows - as applied, is persisted into this model's config (`isDefaultConfig` keys on - nParallel) and is re-sent by the next Apply. Each assertion below is the only - thing pinning one such path.""" + """`nParallel` is the editable control ("blank = follow the server default") and + `loadedNParallel` the rollback baseline.""" status = " ".join(_read("features/chat/lib/apply-inference-status-to-store.ts").split()) - # A model/variant swap underneath this tab must reset the control like - # performLoad's cross-model reset, or model A's count follows onto model B. - # Narrowly gated -- see test_hydration_keeps_the_slot_control_when_readopting_the_running_model. + # A model/variant swap underneath this tab must reset the control like performLoad's + # cross-model reset, or model A's count follows onto model B. assert "...(seedLoadParams && slotsModelChanged && { nParallel: null })," in status # ... while still never adopting the RESOLVED echo into the control. assert "nParallel: status.requested_parallel_slots," not in status @@ -750,8 +706,7 @@ def test_parallel_slots_control_cleared_when_the_load_never_sent_them(): # The cached-GGUF branch keeps the remembered override via the gated local... assert "nParallel: committedSlots," in gguf_branch assert "nParallel: null," not in gguf_branch - # ... the safetensors fallback sends no slots, so it clears both, or the count - # survives on a model whose form does not even render the field. + # ... assert "nParallel: null," in non_gguf_branch assert "loadedNParallel: null," in non_gguf_branch @@ -766,10 +721,8 @@ def test_parallel_slots_control_cleared_when_the_load_never_sent_them(): def test_hydration_clears_the_slot_baseline_for_a_slotless_model(): - """The baseline is what a rollback re-sends and what preset capture reads, so - a model that cannot have slots must not inherit the previous GGUF's count. - /status omits the echo for non-GGUF and sends an explicit null for diffusion; - an absent field on a GGUF is an older backend and must NOT wipe it.""" + """The baseline is what a rollback re-sends and what preset capture reads, so a model + that cannot have slots must not inherit the previous GGUF's count.""" src = _read("features/chat/lib/apply-inference-status-to-store.ts") assert ( "(status.is_gguf === false || status.requested_parallel_slots === null) && {" in src @@ -781,17 +734,10 @@ def test_hydration_clears_the_slot_baseline_for_a_slotless_model(): def test_hydration_keeps_the_slot_control_when_readopting_the_running_model(): - """`hydratingExistingModel` is true whenever the incoming status disagrees - with what this tab last recorded, which includes RE-ADOPTING a model the tab - never lost: the resident-adopt branch restores the model's own per-model - config and only then hydrates, passing the EXTERNAL id as - `previousCheckpoint`. An ungated clear there wipes the slot count that branch - just restored, and the blank persists into `savePerModelConfig`, so a Save - the user reads as a no-op erases their remembered override. - - Only that branch knows the model is unchanged, so it says so explicitly. - Slot counts cannot stand in: the echo falls back to the server-wide default, - so a genuine A->B swap can echo exactly A's explicit count.""" + """`hydratingExistingModel` is true whenever the incoming status disagrees with what + this tab last recorded, which includes RE-ADOPTING a model the tab never lost: the + resident-adopt branch restores the model's own per-model config and only then + hydrates, passing the EXTERNAL id as `previousCheckpoint`.""" status = " ".join(_read("features/chat/lib/apply-inference-status-to-store.ts").split()) assert ( "const slotsModelChanged = hydratingExistingModel && !options.readoptingSameModel;" @@ -825,14 +771,7 @@ def test_hydration_keeps_the_slot_control_when_readopting_the_running_model(): def test_parallel_slots_are_never_recorded_for_a_diffusion_load(): """A DiffusionGemma GGUF answers ``is_gguf: true``, but its runner ignores - ``--parallel``, so ``_parallel_slot_echo`` reports null slots for it. The - three load success paths must gate on ``is_diffusion`` too, or they record a - click-time count the load never committed. - - That phantom does not stay put: ``capturePresetLoadConfig`` snapshots - ``nParallel`` with no model gate and a preset carries no model identity, so - applying it over a TEXT GGUF sends the count as a real ``n_parallel``. - """ + ``--parallel``, so ``_parallel_slot_echo`` reports null slots for it.""" runtime = " ".join(_read("features/chat/hooks/use-chat-model-runtime.ts").split()) # One gated local feeds the control and the baseline, so they cannot drift. assert "(loadResponse.is_gguf ?? false) && !(loadResponse.is_diffusion ?? false)" in runtime @@ -854,17 +793,9 @@ def test_parallel_slots_are_never_recorded_for_a_diffusion_load(): def test_hydration_restores_a_remembered_slot_override(): - """The control is never seeded from the status echo, so a model running on a - remembered override shows a BLANK slot control after a browser reload or a - tab move to another GGUF. `ModelConfigPage.resolveInitial` prefers the live - store for the active model, so that blank is what the form edits: the next - Apply reloads at the server default and a Save writes the blank over the - remembered count. - - The seed is deliberately narrow: storage is read only on a fresh store or a - model change, never on a steady poll, and the value is adopted only when the - server already runs that exact count, which proves it is this model's own. - """ + """The control is never seeded from the status echo, so a model running on a remembered + override shows a BLANK slot control after a browser reload or a tab move to another + GGUF.""" src = _read("features/chat/lib/apply-inference-status-to-store.ts") status = " ".join(src.split()) assert ( @@ -892,16 +823,11 @@ def test_hydration_restores_a_remembered_slot_override(): def test_failed_switch_rollback_restores_the_slot_intent_not_the_resolved_count(): - """`loadedNParallel` holds a RESOLVED count even for a load that sent no - slots (the echo falls back to the server-wide default), so it is the right - value to re-send when recreating the previous server and the wrong one to put - back in the control: it turns "follow the server default" into an explicit - override that a later Save or preset capture pins. The outer catch only - repairs that for a staged config, so a plain string pick keeps the phantom. - - The intent comes from the picker's own pre-switch snapshot when there is one: - chat-page pre-applies the TARGET's config before calling selectModel, so the - live control describes the outgoing model only for a bare pick.""" + """`loadedNParallel` holds a RESOLVED count even for a load that sent no slots (the + echo falls back to the server-wide default), so it is the right value to re-send + when recreating the previous server and the wrong one to put back in the control: it + turns "follow the server default" into an explicit override that a later Save or + preset capture pins.""" runtime = " ".join(_read("features/chat/hooks/use-chat-model-runtime.ts").split()) assert ( 'const previousNParallel = typeof selection !== "string" && ' @@ -925,12 +851,9 @@ def test_failed_switch_rollback_restores_the_slot_intent_not_the_resolved_count( def test_vulkan_inference_devices_are_the_pickable_set(): - """GGUF loads run through llama-server, so on a Vulkan build the picker must - offer the inference inventory (ggml ordinals, the space `--device Vulkan` - pins) rather than the torch view, which can miss cards llama-server drives. - The XPU ban must not apply there: it is about torch-xpu ordinals no - applicator speaks, and a Vulkan pick does not use them. - """ + """GGUF loads run through llama-server, so on a Vulkan build the picker must offer the + inference inventory (ggml ordinals, the space `--device Vulkan` pins) rather than + the torch view, which can miss cards llama-server drives.""" src = " ".join(_read("hooks/use-gpu-info.ts").split()) # The Vulkan inventory is consulted first, and only when it has devices. assert ( @@ -946,12 +869,8 @@ def test_vulkan_inference_devices_are_the_pickable_set(): def test_only_gguf_configs_are_mirrored_to_the_server(): - """The server override map is read by the OpenAI-compatible auto-switch, and - its resolver indexes GGUFs only. Mirroring a safetensors config there would - advertise settings on the monitor's applied-on-API-load list that no API - request can ever apply. The local write stays unconditional: the picker - loads safetensors models and must honour their config. - """ + """The server override map is read by the OpenAI-compatible auto-switch, and its + resolver indexes GGUFs only.""" src = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert ( "if ( !saveFailed && (target.apiLoadable ?? target.isGguf) && !nativePathToken ) " @@ -963,15 +882,8 @@ def test_only_gguf_configs_are_mirrored_to_the_server(): def test_a_native_leased_gguf_is_not_mirrored_to_the_server(): """A dropped or file-picked GGUF loads through a signed native-path lease, and - /api/inference/status reports model_identifier as null for it, so the checkpoint - the browser keys settings by is the bare file name the backend echoes back. - _build_index keys a standalone GGUF by its on-disk path and by its .gguf-stripped - stem, so that name is never an index key: mirroring it wrote an override no load - can read, which the monitor's applied-on-API-load list then advertised as live. - - The live save gates on the lease token rather than the name, because the label - falls back to a plain string with no suffix when the host reports none. - """ + /api/inference/status reports model_identifier as null for it, so the checkpoint the + browser keys settings by is the bare file name the backend echoes back.""" page = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert "&& !nativePathToken ) { syncModelOverride(" in page assert ( @@ -996,14 +908,7 @@ def test_a_native_leased_gguf_is_not_mirrored_to_the_server(): def test_evicted_local_configs_drop_their_server_overrides(): - """savePerModelConfig evicts older models when the map exceeds its budget. - Those models keep a server override that API loads still apply, with nothing - left in the UI showing it or able to forget it, so eviction has to propagate. - - It propagates as a clear, not a Forget: saving one model silently drops the - oldest OTHER model, and sending the full remove would also take llama_extra_args - that only the settings API writes and no UI can show or restore. - """ + """savePerModelConfig evicts older models when the map exceeds its budget.""" src = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert "const evicted: { modelId: string; ggufVariant: string | null }[] = [];" in src assert ( @@ -1030,13 +935,8 @@ def test_evicted_local_configs_drop_their_server_overrides(): def test_backfill_compares_server_keys_by_normalized_identity(): - """app_settings has no schema version, so an install predating identity - normalization holds rows keyed by whatever id was typed, e.g. - "Unsloth/Repo-GGUF:Q4_K_M". This browser only ever stores the folded form, - so an exact property lookup reports "not on the server" for a row that is, - and the one-time backfill then overwrites settings it documents as the newer - authority. The comparison has to fold the same way the backend resolves. - """ + """app_settings has no schema version, so an install predating identity normalization + holds rows keyed by whatever id was typed, e.g.""" src = " ".join(_read("features/model-picker/api/migrate-model-overrides.ts").split()) assert "function normalizedOverrideKey(" in src # Folded on both sides: the older `id::variant` local keys are not. @@ -1049,13 +949,7 @@ def test_backfill_compares_server_keys_by_normalized_identity(): def test_monitor_stats_exclude_model_lifecycle_rows(): - """A load, unload or download is recorded as a monitor entry but is not an - HTTP call. It reads as "running" for as long as the load takes, so counting - it reports an in-flight request with no client waiting and folds a - multi-minute download into "Avg latency". The backend already leaves these - out of active_count, so counting them here also makes the page disagree with - the number the API itself reports. - """ + """A load, unload or download is recorded as a monitor entry but is not an HTTP call.""" src = " ".join(_read("features/api-monitor/use-api-monitor.ts").split()) assert 'if (entry.kind === "lifecycle") { continue; }' in src # "Requests" is a request count too, so it cannot stay entries.length. @@ -1069,23 +963,17 @@ def test_monitor_stats_exclude_model_lifecycle_rows(): def test_api_reach_copy_is_limited_to_gguf_models(): - """The Hub opens this page for every downloaded model, but ModelConfigPage - mirrors settings to the server only when target.isGguf, because API - auto-switch indexes GGUFs only. Telling a safetensors user the settings - apply to an API request describes a load that cannot happen. - """ + """The Hub opens this page for every downloaded model, but ModelConfigPage mirrors + settings to the server only when target.isGguf, because API auto-switch indexes + GGUFs only.""" src = " ".join(_read("features/hub/catalog/hub-model-settings-view.tsx").split()) assert "{(target.apiLoadable ?? target.isGguf)" in src assert "Saved settings apply everywhere Studio loads this model." in src def test_backfill_includes_a_standalone_gguf_with_no_variant(): - """A standalone .gguf picked directly has no quant to choose between, so it - is stored with a null variant. The quant filter classified it like - safetensors and skipped it, and since the done flag is set on the same pass - those settings stayed browser-only permanently while API auto-switch, which - does resolve that model, kept loading it with defaults. - """ + """A standalone .gguf picked directly has no quant to choose between, so it is stored + with a null variant.""" src = " ".join(_read("features/model-picker/api/migrate-model-overrides.ts").split()) assert 'entry.modelId.toLowerCase().endsWith(".gguf")' in src # Still excluded for safetensors, which auto-switch does not resolve. @@ -1093,13 +981,9 @@ def test_backfill_includes_a_standalone_gguf_with_no_variant(): def test_monitor_overlay_does_not_pull_in_the_lazy_page(): - """The overlay is mounted from __root.tsx, so a static import of the page - for two label helpers drags the whole 900-line page and its dependency - graph into the eagerly loaded bundle and undoes the route's - lazyRouteComponent. Measured: the async api-monitor chunk was 0.20 kB with - the page in the main bundle, and 18.83 kB after the helpers moved, with the - main bundle 18 kB smaller. - """ + """The overlay is mounted from __root.tsx, so a static import of the page for two label + helpers drags the whole 900-line page and its dependency graph into the eagerly + loaded bundle and undoes the route's lazyRouteComponent.""" overlay = _read("features/api-monitor/api-monitor-overlay.tsx") assert 'from "./lifecycle"' in overlay assert "api-monitor-page" not in overlay, "the overlay must not reach the page" @@ -1110,17 +994,12 @@ def test_monitor_overlay_does_not_pull_in_the_lazy_page(): def test_override_writes_are_ordered_per_model(): - """Two saves for one model, or a save racing the one-time backfill, started - independent requests with no sequencing, so the older response could commit - last and resurrect the entry the newer one meant to replace. An API load - then applies settings the user has already changed. Different models still - overlap, so a slow write for one cannot hold up another. - """ + """Two saves for one model, or a save racing the one-time backfill, started independent + requests with no sequencing, so the older response could commit last and resurrect + the entry the newer one meant to replace.""" src = " ".join(_read("features/model-picker/api/model-overrides.ts").split()) assert "const writesByKey = new Map>();" in src # Keyed by the same override key the server stores under. - # Folded, not literal: the backfill uses a legacy casing and a UI save the - # normalized one, and the backend resolves both to one row. assert ( "const key = modelOverrideKey( normalizeModelIdentity(modelId), normalizeGgufVariantIdentity(ggufVariant), );" in src @@ -1132,23 +1011,16 @@ def test_override_writes_are_ordered_per_model(): def test_backfill_skips_future_schema_local_records(): - """loadPerModelConfig refuses to apply a record written by a newer Studio and - eviction refuses to drop one, because this client cannot interpret that - schema. The enumeration the backfill uses had no such guard, so it would - persist this client's partial reading server-side and an API-triggered load - would then apply settings the same client will not apply locally. - """ + """loadPerModelConfig refuses to apply a record written by a newer Studio and eviction + refuses to drop one, because this client cannot interpret that schema.""" src = " ".join(_read("features/model-picker/model-config/per-model-config.ts").split()) listing = src[src.index("export function listPerModelConfigs()") :] assert "storedConfigVersion(raw) > STORAGE_SCHEMA_VERSION" in listing[:900] def test_detail_settings_need_a_resolved_quant(): - """The on-device card passes a null variant while its own lookup is pending - or after it failed. Opening the editor then saves a bare-model config, which - the picker never finds because it matches variants exactly, while the API's - bare-key fallback would apply it. openModelSettings already refuses; this - entry point has to refuse the same way.""" + """The on-device card passes a null variant while its own lookup is pending or after it + failed.""" src = " ".join(_read("features/hub/hub-page.tsx").split()) # `variant`, not the argument: a derived quant may have been replaced by the # resident one first, and the guard has to judge what will actually be saved. @@ -1158,11 +1030,9 @@ def test_detail_settings_need_a_resolved_quant(): def test_a_failed_detail_fetch_is_retried(): - """A terminal row's updated_at never advances and selectedIsMissing stays - true, so a fetch that failed had nothing left to re-run the effect and the - payload stayed unavailable until another row was selected. The retry is - bounded because the usual failure is an entry aged out of the ring buffer, - which never arrives however often it is asked for.""" + """A terminal row's updated_at never advances and selectedIsMissing stays true, so a + fetch that failed had nothing left to re-run the effect and the payload stayed + unavailable until another row was selected.""" src = " ".join(_read("features/api-monitor/api-monitor-page.tsx").split()) assert "const DETAIL_FETCH_ATTEMPTS = 3;" in src assert "const detailInFlight = selectedId_ != null && loadingDetails.has(selectedId_);" in src @@ -1170,10 +1040,8 @@ def test_a_failed_detail_fetch_is_retried(): def test_ollama_models_are_not_advertised_as_api_loadable(): - """local_model_resolver skips Ollama's scanner, so an Ollama GGUF is never in - the auto-switch index and no OpenAI request can resolve it. target.isGguf is - still true for one, so gating on that alone mirrored settings the API can - never apply and told the user the opposite.""" + """local_model_resolver skips Ollama's scanner, so an Ollama GGUF is never in the + auto-switch index and no OpenAI request can resolve it.""" types_src = " ".join(_read("features/model-picker/components/model-selector/types.ts").split()) assert "apiLoadable?: boolean;" in types_src hub = " ".join(_read("features/hub/hub-page.tsx").split()) @@ -1188,10 +1056,9 @@ def test_ollama_models_are_not_advertised_as_api_loadable(): def test_cached_repo_settings_are_keyed_by_the_repo_id(): - """A repo cached outside the active HF cache reports load_id = the snapshot - path (hub/services/cache_inventory.py), while the chat picker and the - auto-switch index key it by repo_id. Keying the Hub's settings by the load id - saved them where no other load looks, so they silently never applied.""" + """A repo cached outside the active HF cache reports load_id = the snapshot path + (hub/services/cache_inventory.py), while the chat picker and the auto-switch index + key it by repo_id.""" config_page = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert "const configId = target.configId ?? target.id;" in config_page for call in ( @@ -1220,11 +1087,10 @@ def test_cached_repo_settings_are_keyed_by_the_repo_id(): def test_backfill_splits_a_quant_suffix_the_way_the_backend_does(): - """The backfill compared server keys under an identity taken by splitting on - the last colon, so a Windows drive letter and an ordinary colon inside a POSIX - filename were read as quant separators: `/models/foo:Bar.gguf` and - `/models/foo:bar.gguf` folded to one key, and whichever was already on the - server made the other look migrated.""" + """The backfill compared server keys under an identity taken by splitting on the last + colon, so a Windows drive letter and an ordinary colon inside a POSIX filename were + read as quant separators: `/models/foo:Bar.gguf` and `/models/foo:bar.gguf` folded + to one key, and whichever was already on the server made the other look migrated.""" identity = " ".join(_read("features/model-picker/model-config/model-identity.ts").split()) assert "export function splitQuantSuffix(" in identity # The two rules that keep a path out: no separator in the tail, and a head @@ -1264,13 +1130,11 @@ def test_backfill_splits_a_quant_suffix_the_way_the_backend_does(): assert "_FLOAT_PRECISION_QUANTS" in gguf and "FLOAT_PRECISION_QUANTS" in identity # The executable half of this contract, checked case by case against the # answers split_quant_suffix gives. - assert (WORKDIR / "studio" / "frontend" / "tests" / "quant-suffix-split.test.ts").is_file() + assert (WORKDIR / "studio" / "frontend" / "tests" / "model-identity.test.ts").is_file() def test_the_detail_card_also_gates_ollama_out_of_the_api_promise(): - """Settings opens from two places in the Hub. The row menu gated Ollama out of - the server mirror and the "API loads use these" copy; the detail card did not, - so the same model made the same false promise from the other entry point.""" + """Settings opens from two places in the Hub.""" hub = " ".join(_read("features/hub/hub-page.tsx").split()) assert hub.count("LOCAL_MODEL_SOURCE.OLLAMA") == 2 assert "selectedModel.localSource !== LOCAL_MODEL_SOURCE.OLLAMA" in hub @@ -1278,11 +1142,8 @@ def test_the_detail_card_also_gates_ollama_out_of_the_api_promise(): def test_the_settings_page_judges_the_config_storage_actually_keeps(): - """savePerModelConfig normalizes before deciding, and the runtime hands this - page Speculative Decoding "auto", which canonicalizes to null. Judging the raw - object called a default config non-default, so Remember reported saved while - the local write had dropped the entry, and the mirror sent the server an - "auto" override the browser did not have.""" + """savePerModelConfig normalizes before deciding, and the runtime hands this page + Speculative Decoding "auto", which canonicalizes to null.""" src = " ".join(_read("features/model-picker/components/model-config-page.tsx").split()) assert ( "const normalizedRuntimeConfig = normalizePerModelConfig( effectiveRuntimeConfig, );" in src @@ -1300,11 +1161,8 @@ def test_the_settings_page_judges_the_config_storage_actually_keeps(): def test_the_chat_picker_marks_ollama_targets_unloadable_by_the_api(): - """A settings target opened from the Chat model picker carried no apiLoadable, - so the `?? target.isGguf` fallback mirrored an Ollama GGUF to the server. - local_model_resolver.py refuses every path under a .studio_links/ollama_links - link dir, which is exactly how Ollama's blobs reach this picker, so the mirror - advertised a load the API can never make.""" + """A settings target opened from the Chat model picker carried no apiLoadable, so the + `??""" picker = " ".join(_read("features/model-picker/components/model-selector.tsx").split()) assert "apiLoadable: isGguf && !isOllamaLinkPath(id)," in picker sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split()) @@ -1323,13 +1181,9 @@ def test_the_chat_picker_marks_ollama_targets_unloadable_by_the_api(): def test_the_backfill_fills_in_fields_rather_than_skipping_known_keys(): - """The backfill reads the override map once and then writes each model in turn, - so a save by another tab during that pass was overwritten by this browser's - older localStorage copy. The server reads and writes under one transaction - rather than this re-fetching per model, and it does so field by field: the - override map shipped before this browser mirror did, holding only - llama_extra_args and max_seq_length, so an entry-level skip would strand the - context, KV cache, speculative and GPU settings this migration exists to carry.""" + """The backfill reads the override map once and then writes each model in turn, so a + save by another tab during that pass was overwritten by this browser's older + localStorage copy.""" backfill = " ".join(_read("features/model-picker/api/migrate-model-overrides.ts").split()) assert "{ fillAbsentFields: true }," in backfill # Key presence alone is not "done": what the server lacks decides. @@ -1363,10 +1217,10 @@ def test_the_backfill_fills_in_fields_rather_than_skipping_known_keys(): def test_the_hub_settings_page_matches_a_resident_path_loaded_model(): - """A GGUF loaded from an inactive HF cache or straight off disk loads by path, - but /status reports the clean public id, so comparing it to settingsTarget.id - said "not loaded" and the page showed saved or default values instead of the - live launch config.""" + """A GGUF loaded from an inactive HF cache or straight off disk loads by path, but + /status reports the clean public id, so comparing it to settingsTarget.id said "not + loaded" and the page showed saved or default values instead of the live launch + config.""" hub = " ".join(_read("features/hub/hub-page.tsx").split()) assert ( "residentModelIdMatches( activeCheckpoint, settingsTarget.id, settingsTarget.configId, )" @@ -1374,9 +1228,7 @@ def test_the_hub_settings_page_matches_a_resident_path_loaded_model(): ) assert "loadedConfig={settingsTargetIsResident ? activeModelConfig : null}" in hub assert "settingsTargetIsResident ? activeGgufContextLength : null" in hub - # The loadable identifier, as every other status reader records it. active_model - # is the clean public id, and two files sharing a filename collapse onto one, so - # storing it would let the wrong catalog row look loaded. + # The loadable identifier, as every other status reader records it. assert "checkpointId: resolveInferenceCheckpointId(status)," in hub assert "setCheckpoint(status.active_model" not in hub chat = " ".join(_read("features/chat/lib/apply-inference-status-to-store.ts").split()) @@ -1395,12 +1247,9 @@ def test_the_hub_settings_page_matches_a_resident_path_loaded_model(): def test_the_hub_hydrates_the_live_settings_before_it_offers_them(): - """The Hub builds activeModelConfig out of the chat runtime store, and landing - straight on /hub is the one entry point where nothing has applied - /api/inference/status yet: useChatModelRuntime has no mount sync and the chat - page is a different route. Pinning only the checkpoint left every other field - at its default, so the settings page passed those defaults on as the resident - model's live config and Apply reloaded the model with them.""" + """The Hub builds activeModelConfig out of the chat runtime store, and landing straight + on /hub is the one entry point where nothing has applied /api/inference/status yet: + useChatModelRuntime has no mount sync and the chat page is a different route.""" hub = " ".join(_read("features/hub/hub-page.tsx").split()) assert "adoptResidentModelStatus(" in hub assert "applyActiveModelStatusToStore(status, {" in hub @@ -1423,17 +1272,11 @@ def test_the_hub_hydrates_the_live_settings_before_it_offers_them(): def test_the_hub_settings_editor_reseeds_when_the_live_config_lands(): - """ModelConfigPage reads loadedConfig in a useState initializer, so it seeds - once per mounted instance. Opening the Hub's settings page before - /api/inference/status has hydrated (or while the target is still loading) - flips loadedConfig from null to the live config after mount, and without the - config in the React key the editor kept the saved/default values for a model - running with something else, which Apply then wrote back over it.""" + """ModelConfigPage reads loadedConfig in a useState initializer, so it seeds once per + mounted instance.""" view = " ".join(_read("features/hub/catalog/hub-model-settings-view.tsx").split()) assert "key={modelConfigInstanceKey( target.id, target.ggufVariant, loadedConfig, )}" in view - # Same key the sidebar entry uses; that parity is the point. It keys by the - # settings variant, which is the loader's filename label nulled out for a - # standalone .gguf, so both surfaces name one config per file. + # Same key the sidebar entry uses; that parity is the point. sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split()) assert "key={modelConfigInstanceKey(modelId, settingsGgufVariant, loadedConfig)}" in sidebar @@ -1457,9 +1300,9 @@ def test_the_hub_settings_editor_reseeds_when_the_live_config_lands(): def test_a_standalone_gguf_has_one_settings_key(): - """The inventory labels a single scanned .gguf from its filename, so the Hub row - menu keyed its settings to `:Q4_K_M` while the Chat picker, the detail - card and the backfill all use the bare path: two surfaces, two configs.""" + """The inventory labels a single scanned .gguf from its filename, so the Hub row menu + keyed its settings to `:Q4_K_M` while the Chat picker, the detail card and the + backfill all use the bare path: two surfaces, two configs.""" hub = " ".join(_read("features/hub/hub-page.tsx").split()) assert "let ggufVariant = settingsGgufVariantForRow(row);" in hub assert "row.formatVariant" not in hub, "the row's raw label is not a settings key" @@ -1476,11 +1319,10 @@ def test_a_standalone_gguf_has_one_settings_key(): def test_a_standalone_gguf_is_resident_despite_its_derived_quant(): - """A loose .gguf keys its settings by the bare path with no variant, but the - loader derives one from the filename (llama_cpp sets _hf_variant from - _extract_quant_label) and /status reports it, so an equality between the two - could never hold and the settings page withheld the live launch config from - the very file that was loaded.""" + """A loose .gguf keys its settings by the bare path with no variant, but the loader + derives one from the filename (llama_cpp sets _hf_variant from _extract_quant_label) + and /status reports it, so an equality between the two could never hold and the + settings page withheld the live launch config from the very file that was loaded.""" hub = " ".join(_read("features/hub/hub-page.tsx").split()) assert "const settingsTargetIsStandaloneFile =" in hub assert 'settingsTarget.id.toLowerCase().endsWith(".gguf")' in hub @@ -1498,12 +1340,8 @@ def test_a_standalone_gguf_is_resident_despite_its_derived_quant(): def test_a_standalone_gguf_has_one_settings_identity_everywhere(): """A loose .gguf has no quant to choose between, but llama_cpp falls back to - _extract_quant_label(gguf_path) when a load names no variant, and /status - echoes that as gguf_variant. The sidebar took it straight from the store, so - an edit there landed under ":Q4_K_M" while the Hub row, the picker and - the backfill all wrote the bare path. The auto-switch lookup reads the bare - path BEFORE ":", so the sidebar's entry was never the one - an API load applied: the user edited settings that could not take effect.""" + _extract_quant_label(gguf_path) when a load names no variant, and /status echoes + that as gguf_variant.""" sidebar = " ".join(_read("features/model-picker/components/sidebar-model-config.tsx").split()) # Nulled for the settings identity, and used for every field that keys it. assert ( @@ -1532,12 +1370,9 @@ def test_a_standalone_gguf_has_one_settings_identity_everywhere(): def test_monitor_unload_clears_only_the_model_it_freed(): - """Unload targets the resident local model from /status, but the store may - hold either spelling: status reports the concrete load path while the store - can hold the advertised repo id. Matching one spelling leaves the store - pinned to a model just freed; matching none of them, or matching an external - pick, deletes a selection this button never touched, because clearCheckpoint - also drops the persisted external checkpoint.""" + """Unload targets the resident local model from /status, but the store may hold either + spelling: status reports the concrete load path while the store can hold the + advertised repo id.""" page = " ".join(_read("features/api-monitor/api-monitor-page.tsx").split()) assert "const unloadedAliases = [checkpoint, status.active_model];" in page assert "!isExternalModelId(selected)" in page @@ -1546,13 +1381,8 @@ def test_monitor_unload_clears_only_the_model_it_freed(): def test_settings_open_reads_status_before_resolving_the_quant(): - """A cache row carries no quant, so opening its settings resolves one from - the store's active variant. The effect that re-reads /status watches - settingsTarget and so cannot run until the target already exists, and the - Hub has no polling timer: it re-reads on focus and visibility only. Without - a read of its own the resolution therefore sees a checkpoint from before any - API-driven switch, for as long as the window has kept focus, and opens the - editor on the quant of whichever model that switch displaced.""" + """A cache row carries no quant, so opening its settings resolves one from the store's + active variant.""" page = " ".join(_read("features/hub/hub-page.tsx").split()) assert "const refreshResidentModelStatus = useCallback((): Promise => {" in page assert "const [res] = await Promise.all([ listGgufVariants(" in page @@ -1560,22 +1390,16 @@ def test_settings_open_reads_status_before_resolving_the_quant(): def test_cached_repo_settings_key_follows_the_row_not_the_view(): - """A repo in an inactive HF cache loads by snapshot path while its settings - are keyed by repo id. The same row is reachable from Discover, where the view - kind is "discover" and only the resource says it is a cache row, so keying - off the kind stranded the settings for exactly the case the helper exists to - handle: Run from Discover looked up the snapshot path, found nothing, and - loaded with default context, GPU and template.""" + """A repo in an inactive HF cache loads by snapshot path while its settings are keyed + by repo id.""" page = " ".join(_read("features/hub/hub-page.tsx").split()) assert 'if (kind !== "cache" && resource.source !== "hub_cache") {' in page def test_detail_settings_defers_a_derived_quant_to_a_fresh_status_read(): - """The on-device card resolves the quant it shows from the store's active - variant, and nothing re-reads status while the window keeps focus, so an - API-driven switch leaves that quant naming the model it displaced. A quant - the user picked in the card's selector is a choice and must survive; only a - derived one defers to the read.""" + """The on-device card resolves the quant it shows from the store's active variant, and + nothing re-reads status while the window keeps focus, so an API-driven switch leaves + that quant naming the model it displaced.""" page = " ".join(_read("features/hub/hub-page.tsx").split()) card = " ".join(_read("features/hub/catalog/local-on-device-card.tsx").split()) assert "if (!quantIsUserPicked) { await refreshResidentModelStatus();" in page