diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 6f204b7e38..0615485304 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4509,8 +4509,9 @@ async def _load_model_impl( is_vision = llama_backend._is_vision, is_lora = False, is_gguf = True, - is_local_model = native_grant_backed - or is_local_path(llama_backend.model_identifier), + is_local_model = _loaded_is_local_model( + llama_backend, native_grant_backed, llama_backend.model_identifier + ), is_diffusion = llama_backend.is_diffusion, is_audio = _gguf_is_audio, audio_type = _gguf_audio, diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index d0a525ca18..ce6fe6d0f5 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -394,14 +394,12 @@ export function ChatSettingsPanel({ ggufContextLength != null || (currentCheckpoint?.toLowerCase().endsWith(".gguf") ?? false); // activeModelIsLocal is the backend's own classification and covers native - // picks. activeNativePathToken must not be used here: status reconciliation - // keeps it across a switch to a remote GGUF (no replacement token exists), - // so a stale token would label that remote model local. + // picks. Two things must not decide this: activeNativePathToken, which + // status reconciliation keeps across a switch to a remote GGUF (no + // replacement token exists), and a bare .gguf suffix, since the backend + // reads a one-slash org/name.gguf as a repository id, not a file. const isLocalGguf = - isGguf && - (activeModelIsLocal || - isLocalModelPath(currentCheckpoint ?? "") || - (currentCheckpoint?.toLowerCase().endsWith(".gguf") ?? false)); + isGguf && (activeModelIsLocal || isLocalModelPath(currentCheckpoint ?? "")); const ggufMaxContextLength = useChatRuntimeStore( (s) => s.ggufMaxContextLength, ); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index dedda3f297..35125f02dd 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -351,9 +351,12 @@ def test_local_mtp_warning_covers_path_and_native_gguf_sources(): assert "isGguf &&" in local.group(0) assert "activeModelIsLocal" in local.group(0) assert "isLocalModelPath" in local.group(0) - # A native token outlives a switch to a remote GGUF, so it must not - # classify the model here; activeModelIsLocal already covers native picks. + # Two signals must not classify the model here, because both mislabel a + # remote GGUF as local: a native token, which outlives a switch to a remote + # model, and a bare .gguf suffix, since a one-slash org/name.gguf is a + # repository id. activeModelIsLocal is the backend's own answer for both. assert "activeNativePathToken" not in local.group(0) + assert ".gguf" not in local.group(0) assert "isLocalGguf" in src.split('specFallbackReason === "drafter_not_found"', 1)[1] @@ -380,7 +383,9 @@ def test_local_mtp_warning_uses_backend_source_metadata(): # the filesystem would flip a local model to remote once its directory goes # away underneath a running server. assert "llama_backend._is_local_model = bool(native_grant_backed or config.is_local)" in route - assert "is_local_model = _loaded_is_local_model(" in route + # Both GGUF responses report it: the status poll and the already_loaded + # dedup reply. Either one re-deriving it reintroduces the flip. + assert route.count("is_local_model = _loaded_is_local_model(") >= 2 assert "backend.active_model_name and is_local_path(backend.active_model_name)" in route