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