diff --git a/studio/backend/tests/test_openai_auto_switch.py b/studio/backend/tests/test_openai_auto_switch.py index 4782816e53..2119e26164 100644 --- a/studio/backend/tests/test_openai_auto_switch.py +++ b/studio/backend/tests/test_openai_auto_switch.py @@ -4604,10 +4604,41 @@ def test_case_fallback_never_applies_to_a_posix_path(monkeypatch): assert settings.get_model_override("/models/foo.gguf")["max_seq_length"] == 8192 -def test_case_fallback_never_applies_to_a_windows_path(monkeypatch): +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 models would leave every + # migrated Windows entry unreachable until the user saved it again, which is + # the opposite of the POSIX rule and for the opposite reason. The separator + # is interchangeable there too. _mock_override_store(monkeypatch) - settings.set_model_override(r"C:\models\foo.gguf", max_seq_length = 8192) - assert settings.get_model_override(r"C:\models\FOO.gguf") == {} + 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) + 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): + # Only /mnt/ is a WSL drive mount. /mnt/data is an ordinary Linux + # mount point and stays case-sensitive like any other POSIX path. + _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): + # Two stored keys folding to one leaves no single answer, so the load takes + # defaults rather than guessing between them. + _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): diff --git a/studio/backend/utils/openai_auto_switch_settings.py b/studio/backend/utils/openai_auto_switch_settings.py index 57d65b98f8..24fa7704f0 100644 --- a/studio/backend/utils/openai_auto_switch_settings.py +++ b/studio/backend/utils/openai_auto_switch_settings.py @@ -27,6 +27,7 @@ per-request hot path; writes invalidate the cache. from __future__ import annotations import os +import re import threading import time from typing import Any, Optional @@ -470,6 +471,37 @@ def _looks_like_filesystem_path(model_id: str) -> bool: return len(model_id) >= 3 and model_id[1] == ":" and model_id[2] in ("\\", "/") +# The three path shapes whose filesystem is case-insensitive, matching the rule +# the browser applies in features/hub/lib/model-identity.ts. Kept in step with +# it: the browser folds these before storing, so the two sides have to agree on +# which paths fold or a stored key becomes unreachable. +_WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]") +_WSL_DRIVE_PATH = re.compile(r"^/mnt/[A-Za-z](?:/|$)") + + +def _fold_case_insensitive_path(model_id: str) -> Optional[str]: + """``model_id`` folded for comparison, or None when the path is case-sensitive. + + A Windows drive path, a UNC share and a WSL drive path all name one file + whatever the casing, and the separator is interchangeable on Windows. A + POSIX path is not: folding "/models/Foo.gguf" onto "/models/foo.gguf" would + replay another model's context and GPU pin. + """ + slashed = model_id.replace("\\", "/") + if _WINDOWS_DRIVE_PATH.match(model_id): + minimum = 3 + elif slashed.startswith("//"): + minimum = 2 + elif _WSL_DRIVE_PATH.match(slashed): + minimum = 6 + else: + return None + trimmed = slashed + while len(trimmed) > minimum and trimmed.endswith("/"): + trimmed = trimmed[:-1] + return trimmed.casefold() + + def get_model_overrides() -> dict[str, dict]: """Per-model launch configs keyed by model id (see normalize_model_override).""" raw = _cached_setting(MODEL_OVERRIDES_SETTING_KEY, None) @@ -504,16 +536,30 @@ def resolve_model_override_key(model_id: str) -> Optional[str]: return model_id if not isinstance(model_id, str): return None - # Only repo-style ids fold. A POSIX path is case-sensitive and names a - # different file, so matching "/models/Foo.gguf" against an entry saved for - # "/models/foo.gguf" would replay another model's context and GPU pin. + # A POSIX path is case-sensitive and names a different file, so matching + # "/models/Foo.gguf" against an entry saved for "/models/foo.gguf" would + # replay another model's context and GPU pin. A Windows drive path, a UNC + # share and a WSL drive path are not case-sensitive, and the browser folds + # exactly those before storing, so refusing to fold them here would leave + # every migrated Windows entry unreachable until the user saved it again. if _looks_like_filesystem_path(model_id): - return None - folded = model_id.casefold() + folded = _fold_case_insensitive_path(model_id) + if folded is None: + return None + + def fold(key: str) -> Optional[str]: + return _fold_case_insensitive_path(key) + else: + folded = model_id.casefold() + + def fold(key: str) -> Optional[str]: + # A path never folds onto a repo id: the shapes cannot collide. + return None if _looks_like_filesystem_path(key) else key.casefold() + matches = [ key for key, value in overrides.items() - if isinstance(key, str) and key.casefold() == folded and isinstance(value, dict) + if isinstance(key, str) and fold(key) == folded and isinstance(value, dict) ] return matches[0] if len(matches) == 1 else None