diff --git a/studio/frontend/src/features/api-monitor/api-monitor-page.tsx b/studio/frontend/src/features/api-monitor/api-monitor-page.tsx index 48d9dca650..590348c347 100644 --- a/studio/frontend/src/features/api-monitor/api-monitor-page.tsx +++ b/studio/frontend/src/features/api-monitor/api-monitor-page.tsx @@ -21,6 +21,7 @@ import { getInferenceStatus, unloadModel } from "@/features/chat/api/chat-api"; import { resolveInferenceCheckpointId } from "@/features/chat/lib/apply-inference-status-to-store"; import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; import type { ApiMonitorEntry } from "@/features/chat/types/api"; +import { isExternalModelId } from "@/features/chat/external-providers"; import { modelIdsMatch } from "@/features/hub/lib/model-identity"; import { useSettingsDialogStore } from "@/features/settings"; import { getApiBase, isTauri } from "@/lib/api-base"; @@ -520,9 +521,17 @@ export function ApiMonitorPage(): ReactElement { // external provider selected while a local model stays resident, and // clearCheckpoint calls saveLastExternalCheckpoint(null), so clearing // unconditionally would delete a selection this button never touched. + // Both spellings: status reports the concrete load path as the identifier + // while the store may hold the advertised repo id, so matching only the + // path leaves the store pinned to a model this button just freed. const store = useChatRuntimeStore.getState(); const selected = store.params.checkpoint; - if (selected && modelIdsMatch(selected, checkpoint)) { + const unloadedAliases = [checkpoint, status.active_model]; + if ( + selected && + !isExternalModelId(selected) && + unloadedAliases.some((alias) => modelIdsMatch(selected, alias)) + ) { store.clearCheckpoint(); } setUnloadError(null); diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index 7459a45854..ff8bc5b873 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -1527,3 +1527,21 @@ def test_a_standalone_gguf_has_one_settings_identity_everywhere(): bare = route.index("\n target_id,\n") labelled = route.index('f"{target_id}:{file_variant}"') assert bare < labelled, "the bare path must be read before the filename label" + + +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.""" + 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 + assert ( + "unloadedAliases.some((alias) => modelIdsMatch(selected, alias))" in page + ) + assert "store.clearCheckpoint();" in page