Fold case-insensitive path ids the way the browser already does

resolve_model_override_key refused the case fallback for every filesystem path,
but only a POSIX path is case-sensitive. A Windows drive path, a UNC share and a
WSL drive path each name one file whatever the casing, and the browser folds
exactly those three before storing. A Windows user's migrated entry was
therefore keyed lowercase while an API auto-switch resolved the same file with
its on-disk casing, so the lookup missed and the saved launch flags silently
stopped applying until the settings were saved again.

Fold those three shapes here too, normalizing the separator as the browser does
so C:/Models/Foo.gguf and c:\models\foo.gguf agree. POSIX stays case-sensitive,
/mnt/data stays an ordinary mount rather than a WSL drive, and an ambiguous fold
still matches nothing so a load takes defaults instead of guessing.

The existing Windows test asserted the opposite. It carried no rationale, unlike
its POSIX sibling, and get_model_override's docstring already scopes the rule to
POSIX, so it read as an over-generalisation of the POSIX case.
This commit is contained in:
danielhanchen 2026-07-28 14:18:15 +00:00
commit 082e0dd9d1
2 changed files with 86 additions and 9 deletions

View file

@ -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/<letter> 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):

View file

@ -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