Skip to content

Commit ee9b077

Browse files
authored
Merge pull request #4527 from atomantic/cos/task-msxtlgam/agent-f9c38435
fix: suppress memory-panel nag for disabled local LLM backends
2 parents c027075 + 30d57c3 commit ee9b077

4 files changed

Lines changed: 151 additions & 20 deletions

File tree

client/src/components/settings/MemoryManagement.jsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const EMPTY_SNAPSHOT = {
4646
whisperRunning: false,
4747
sttEngine: 'whisper',
4848
unavailableSources: [],
49+
disabledSources: [],
4950
};
5051

5152
const SOURCE_LABELS = {
@@ -80,6 +81,10 @@ export default function MemoryManagement({ onLoadedModelsChange } = {}) {
8081
const [loading, setLoading] = useState(true);
8182
const [lastFetched, setLastFetched] = useState(0);
8283
const [unavailableSources, setUnavailableSources] = useState([]);
84+
// Backends the user marked intentionally disabled stay in unavailableSources
85+
// (so "Free everything" still skips their unknown residency) but are excluded
86+
// from the "Status unavailable" banner — the nag the user opted out of.
87+
const [disabledSources, setDisabledSources] = useState([]);
8388
// Guards the polled setState calls — a late /voice/status response that
8489
// resolves after unmount would otherwise call setState on a dead tree.
8590
// useMounted resets the ref to true on every mount so React 18 StrictMode's
@@ -123,6 +128,10 @@ export default function MemoryManagement({ onLoadedModelsChange } = {}) {
123128
const ttsValid = typeof tts?.kokoro?.state === 'string';
124129
const voiceValid = voice != null && typeof voice === 'object';
125130
const llmSourceErrors = llmValid && Array.isArray(llm.sourceErrors) ? llm.sourceErrors : [];
131+
// Distinguish "field absent" from "present-but-empty" so a later failed poll
132+
// keeps the last-known disabled backends (the outage is the very scenario
133+
// the banner suppression exists for) while an explicit [] clears them.
134+
const llmDisabledSources = Array.isArray(llm?.disabled) ? llm.disabled : previous.disabledSources;
126135
const failedSources = [
127136
...(!llmValid ? ['ollama', 'lmstudio'] : llmSourceErrors),
128137
...(!ttsValid ? ['tts'] : []),
@@ -139,7 +148,8 @@ export default function MemoryManagement({ onLoadedModelsChange } = {}) {
139148
whisperRunning: voiceValid ? Boolean(voice.services?.whisper?.ok) : previous.whisperRunning,
140149
sttEngine: voiceValid ? (voice.sttEngine || 'whisper') : previous.sttEngine,
141150
unavailableSources: [...new Set(failedSources)],
142-
};
151+
disabledSources: [...new Set(llmDisabledSources)],
152+
};
143153
snapshotRef.current = snapshot;
144154
if (priority) priorityRefreshRef.current = false;
145155
if (!mountedRef.current) return snapshot;
@@ -149,6 +159,7 @@ export default function MemoryManagement({ onLoadedModelsChange } = {}) {
149159
setWhisperRunning(snapshot.whisperRunning);
150160
setSttEngine(snapshot.sttEngine);
151161
setUnavailableSources(snapshot.unavailableSources);
162+
setDisabledSources(snapshot.disabledSources);
152163
setLoading(false);
153164
setLastFetched(Date.now());
154165
onLoadedModelsChange?.({
@@ -233,6 +244,11 @@ export default function MemoryManagement({ onLoadedModelsChange } = {}) {
233244
|| whisperRunning || ttsState.state !== 'lazy';
234245
const anyActionRunning =
235246
unloadingModel || unloadingLmStudio || unloadingKokoro || stoppingWhisper || startingWhisper || freeingAll;
247+
// The banner is the nag the user can opt out of per backend, so it drops
248+
// user-disabled backends. The free-everything guard and empty-state check
249+
// below still use the full unavailableSources, so a disabled-but-running
250+
// backend can't be silently freed as "nothing resident."
251+
const bannerSources = unavailableSources.filter((source) => !disabledSources.includes(source));
236252

237253
return (
238254
<div className="bg-port-card border border-port-border rounded mb-4">
@@ -266,15 +282,15 @@ export default function MemoryManagement({ onLoadedModelsChange } = {}) {
266282
</div>
267283
</div>
268284

269-
{unavailableSources.length > 0 && (
270-
<div className="mx-3 mt-3 flex items-start gap-2 rounded border border-port-warning/30 bg-port-warning/10 px-3 py-2 text-xs text-port-warning">
271-
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
272-
<span>
273-
Status unavailable for {unavailableSources.map((source) => SOURCE_LABELS[source] || source).join(', ')}.
285+
{bannerSources.length > 0 && (
286+
<div className="mx-3 mt-3 flex items-start gap-2 rounded border border-port-warning/30 bg-port-warning/10 px-3 py-2 text-xs text-port-warning">
287+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
288+
<span>
289+
Status unavailable for {bannerSources.map((source) => SOURCE_LABELS[source] || source).join(', ')}.
274290
Last known values remain visible; unknown resources are excluded from Free everything.
275-
</span>
276-
</div>
277-
)}
291+
</span>
292+
</div>
293+
)}
278294

279295
{loadedOllama.length === 0 && loadedLmStudio.length === 0
280296
&& !whisperRunning && ttsState.state === 'lazy' && unavailableSources.length === 0 ? (

client/src/components/settings/MemoryManagement.test.jsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,57 @@ describe('MemoryManagement', () => {
8383
});
8484
expect(screen.queryByText('older-model')).not.toBeInTheDocument();
8585
expect(screen.getByText('newer-model')).toBeInTheDocument();
86-
});
86+
});
87+
88+
it('hides the unavailability banner for a user-disabled backend', async () => {
89+
// A backend the user marked disabled opts out of the availability nag. Its
90+
// failed residency still lands in unavailableSources (the "Free everything"
91+
// guard), but the banner excludes it.
92+
getLoadedLlmModels.mockResolvedValue({
93+
ollama: [],
94+
lmstudio: [],
95+
sourceErrors: ['lmstudio'],
96+
disabled: ['lmstudio'],
97+
});
98+
99+
render(<MemoryManagement />);
100+
101+
// Wait for the first refresh to clear the loading state so the poll result
102+
// is the thing under test, not the pre-poll empty render.
103+
expect(await screen.findByRole('button', { name: 'Free everything' })).toBeInTheDocument();
104+
// The banned nag is suppressed for the disabled backend, even though its
105+
// residency is unknown.
106+
expect(screen.queryByText(/Status unavailable for LM Studio/i)).not.toBeInTheDocument();
107+
// The "free everything" guard and empty-state check still key off the full
108+
// unavailable list, so "nothing resident" is NOT claimed while lmstudio's
109+
// residency is unconfirmed.
110+
expect(screen.queryByText(/full unified memory is available/i)).not.toBeInTheDocument();
111+
});
112+
113+
it('keeps a backend disabled across a later failed poll', async () => {
114+
// The banner-suppression scenario IS a transient outage, so a failed poll
115+
// must not resurrect the warning for a backend a good poll already marked
116+
// disabled — disabled sources are retained (present-vs-empty, not falsy).
117+
getLoadedLlmModels
118+
.mockResolvedValueOnce({
119+
ollama: [],
120+
lmstudio: [],
121+
sourceErrors: ['lmstudio'],
122+
disabled: ['lmstudio'],
123+
})
124+
.mockRejectedValueOnce(new Error('LLM status failed'));
125+
126+
render(<MemoryManagement />);
127+
// First (good) poll: lmstudio is known-disabled, so the banner is silent even
128+
// though its residency error would otherwise show.
129+
expect(await screen.findByRole('button', { name: 'Free everything' })).toBeInTheDocument();
130+
expect(screen.queryByText(/Status unavailable for/i)).not.toBeInTheDocument();
131+
// A later FAILED poll re-adds both backends to unavailableSources, but the
132+
// still-known-disabled lmstudio must stay excluded from the banner (ollama,
133+
// which is enabled, shows instead).
134+
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
135+
await waitFor(() => expect(getLoadedLlmModels).toHaveBeenCalledTimes(2));
136+
expect(screen.getByText(/Status unavailable for Ollama/i)).toBeInTheDocument();
137+
expect(screen.queryByText(/LM Studio/i)).not.toBeInTheDocument();
138+
});
87139
});

server/routes/localLlm.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
getStatus, listModels, listVisionModels, listToolUseModels, installModel, deleteModel, switchBackend, migrateBackend, installBackend, upgradeBackend, controlOllamaServer,
3232
describeInstallProgress
3333
} from '../services/localLlm.js'
34+
import { getSettings } from '../services/settings.js'
3435
import { runLocalLlmTest, compareLocalLlmModels } from '../services/localLlmPlayground.js'
3536
import { listUserModels } from '../services/audioModels.js'
3637
import { ENGINES } from '../services/pipeline/musicGen.js'
@@ -288,16 +289,31 @@ router.post('/migrate', asyncHandler(async (req, res) => {
288289
// Distinct from /catalog (disk-installed) — only flags what's eating VRAM
289290
// right now so the Memory Management panel can show what to unload before
290291
// kicking off a big diffusion render.
292+
//
293+
// sourceErrors stays the full failure list: a backend the user marked disabled
294+
// (localLlm.<id>.disabled — "PortOS will not expect this backend to be running")
295+
// is STILL probed, and a failed probe on a disabled-but-actually-running backend
296+
// must keep its "unknown residency" status so "Free everything" can't claim it
297+
// reclaimed a model it can't even see. The `disabled` field names the backends
298+
// the user opted out of availability warnings for, so the panel stays quiet
299+
// about them WITHOUT weakening that cleanup guard.
291300
router.get('/loaded', asyncHandler(async (_req, res) => {
301+
const settings = await getSettings().catch(() => ({}))
302+
const ollamaDisabled = Boolean(settings.localLlm?.ollama?.disabled)
303+
const lmStudioDisabled = Boolean(settings.localLlm?.lmstudio?.disabled)
292304
const [ollama, lmstudio] = await Promise.all([
293305
getLoadedOllamaModels(),
294306
getLoadedLmStudioModels(true),
295-
])
307+
])
296308
const sourceErrors = [
297-
...(getOllamaResidencyError() ? ['ollama'] : []),
298-
...(getLmStudioResidencyError() ? ['lmstudio'] : []),
299-
]
300-
res.json({ ollama, lmstudio, sourceErrors })
309+
...(getOllamaResidencyError() ? ['ollama'] : []),
310+
...(getLmStudioResidencyError() ? ['lmstudio'] : []),
311+
]
312+
const disabled = [
313+
...(ollamaDisabled ? ['ollama'] : []),
314+
...(lmStudioDisabled ? ['lmstudio'] : []),
315+
]
316+
res.json({ ollama, lmstudio, sourceErrors, disabled })
301317
}))
302318

303319
// POST /api/local-llm/unload — body: { backend: 'ollama', modelId }.

server/routes/localLlm.test.js

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { runLocalLlmTest, compareLocalLlmModels } from '../services/localLlmPlay
66
import { listModels, listVisionModels, listToolUseModels } from '../services/localLlm.js';
77
import { enrichCatalogWithVariants } from '../services/huggingFaceCatalog.js';
88
import { getLoadedModels, unloadModel } from '../services/ollamaManager.js';
9-
import { getLoadedModels as getLoadedLmStudioModels } from '../services/lmStudioManager.js';
9+
import { getLoadedModels as getLoadedLmStudioModels, getLastLoadedModelsError as getLmStudioResidencyError } from '../services/lmStudioManager.js';
10+
import { getSettings } from '../services/settings.js';
1011
import { localLlmCompareSchema, localLlmTestSchema } from '../lib/validation.js';
1112
import { errorEvents } from '../lib/errorHandler.js';
1213

@@ -54,6 +55,12 @@ vi.mock('../services/huggingFaceCatalog.js', () => ({
5455
enrichCatalogWithVariants: vi.fn(async (catalog) => catalog),
5556
}));
5657

58+
// /loaded reads getSettings() to honor a user's intentionally-disabled backends,
59+
// so mock it (defaults to no backends disabled; the disabled-case test flips it).
60+
vi.mock('../services/settings.js', () => ({
61+
getSettings: vi.fn(async () => ({})),
62+
}));
63+
5764
function makeApp() {
5865
const app = express();
5966
app.use(express.json());
@@ -245,8 +252,8 @@ describe('local LLM memory-management routes', () => {
245252
});
246253

247254
it('GET /loaded reports models both local backends currently have resident', async () => {
248-
// Mirror the real getLoadedModels() field set so the fixture documents the
249-
// pass-through contract and would catch any future field-stripping.
255+
// Mirror the real getLoadedModels() field set so the fixture documents the
256+
// pass-through contract and would catch any future field-stripping.
250257
const resident = { id: 'llama3.2', name: 'llama3.2', size: 4096, sizeVram: 4096, expiresAt: null };
251258
const lmStudioResident = { id: 'example/lmstudio', state: 'loaded' };
252259
getLoadedModels.mockResolvedValue([resident]);
@@ -255,10 +262,50 @@ describe('local LLM memory-management routes', () => {
255262
const res = await request(makeApp()).get('/api/local-llm/loaded');
256263

257264
expect(res.status).toBe(200);
258-
expect(res.body).toEqual({ ollama: [resident], lmstudio: [lmStudioResident], sourceErrors: [] });
265+
expect(res.body).toEqual({ ollama: [resident], lmstudio: [lmStudioResident], sourceErrors: [], disabled: [] });
259266
expect(getLoadedModels).toHaveBeenCalledTimes(1);
260267
expect(getLoadedLmStudioModels).toHaveBeenCalledWith(true);
261-
});
268+
});
269+
270+
it('GET /loaded keeps a failed disabled backend in sourceErrors but names it disabled', async () => {
271+
// "Mark disabled" only silences the availability NAG — it is not evidence the
272+
// backend holds no memory. So /loaded must still probe a disabled backend AND
273+
// still surface its failed residency in sourceErrors (the panel's
274+
// "Free everything" guard keys off that), while separately naming it in
275+
// `disabled` so the banner can stay quiet about it.
276+
getSettings.mockResolvedValueOnce({ localLlm: { lmstudio: { disabled: true } } });
277+
getLmStudioResidencyError.mockReturnValueOnce('LM Studio is unavailable');
278+
getLoadedLmStudioModels.mockResolvedValue([{ id: 'example/lmstudio', state: 'loaded' }]);
279+
280+
const res = await request(makeApp()).get('/api/local-llm/loaded');
281+
282+
expect(res.status).toBe(200);
283+
// Residency is honored — the backend is still probed…
284+
expect(getLoadedLmStudioModels).toHaveBeenCalledWith(true);
285+
expect(res.body.lmstudio).toEqual([{ id: 'example/lmstudio', state: 'loaded' }]);
286+
// …and its failed probe keeps the "unknown residency" status sourceErrors so
287+
// "Free everything" can't claim it freed a model it never saw.
288+
expect(res.body.sourceErrors).toContain('lmstudio');
289+
// The disabled flag is the signal the panel uses to hold the banner.
290+
expect(res.body.disabled).toEqual(['lmstudio']);
291+
expect(getSettings).toHaveBeenCalled();
292+
});
293+
294+
it('GET /loaded still reports an enabled backend whose residency probe fails', async () => {
295+
// An enabled backend that fails its residency probe surfaces in sourceErrors
296+
// and is NOT in `disabled`, so the panel both shows the nag and keeps its
297+
// "excluded from Free everything" guard.
298+
getSettings.mockResolvedValueOnce({});
299+
getLmStudioResidencyError.mockReturnValueOnce('LM Studio is unavailable');
300+
301+
const res = await request(makeApp()).get('/api/local-llm/loaded');
302+
303+
expect(res.status).toBe(200);
304+
expect(res.body.sourceErrors).toContain('lmstudio');
305+
// An enabled backend is not in the disabled list.
306+
expect(res.body.disabled).not.toContain('lmstudio');
307+
expect(getLoadedLmStudioModels).toHaveBeenCalledWith(true);
308+
});
262309

263310
it('POST /unload evicts a resident model and echoes the service result', async () => {
264311
// Real unloadModel() success shape is { unloaded: true, model } — NOT modelId

0 commit comments

Comments
 (0)