diff --git a/client/src/components/settings/LocalLlmTab.test.jsx b/client/src/components/settings/LocalLlmTab.test.jsx
index dd9918980c..1063a3a170 100644
--- a/client/src/components/settings/LocalLlmTab.test.jsx
+++ b/client/src/components/settings/LocalLlmTab.test.jsx
@@ -6,10 +6,6 @@ vi.mock('../../services/api', () => ({
getLocalLlmStatus: vi.fn(),
getLocalLlmCatalog: vi.fn(),
getLocalLlmHuggingFaceSearch: vi.fn(),
- getModelAbuseGuardStatus: vi.fn(),
- getHfTokenStatus: vi.fn(),
- installModelAbuseGuard: vi.fn(),
- cancelModelAbuseGuardInstall: vi.fn(),
installLocalLlmModel: vi.fn(),
deleteLocalLlmModel: vi.fn(),
switchLocalLlmBackend: vi.fn(),
@@ -44,15 +40,14 @@ vi.mock('../../services/socket', () => ({
vi.mock('../ui/Toast', () => ({
default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }),
}));
+vi.mock('../models/ModelAbuseGuardPanel.jsx', () => ({
+ default: () => ,
+}));
import {
deleteLocalLlmModel,
getLocalLlmStatus,
getLocalLlmCatalog,
- getModelAbuseGuardStatus,
- getHfTokenStatus,
- installModelAbuseGuard,
- cancelModelAbuseGuardInstall,
installLocalLlmBackend,
patchSettingsSlice,
installLocalLlmModel,
@@ -79,6 +74,8 @@ const renderTab = async (view = 'runtimes') => {
await waitFor(() => expect(screen.getByRole('tabpanel')).toHaveAttribute('id', `llm-management-panel-${view}`));
if (view === 'library') {
await waitFor(() => expect(screen.getByText(/Installed on (Ollama|LM Studio)/)).toBeTruthy());
+ } else if (view === 'abuse') {
+ await waitFor(() => expect(screen.getByTestId('model-abuse-guard-card')).toBeInTheDocument());
} else {
await waitFor(() => expect(screen.getByTitle(/PortOS routes local-LLM runs here by default/)).toBeInTheDocument());
}
@@ -110,18 +107,6 @@ beforeEach(() => {
lmstudio: { installed: false, available: false, modelCount: 0, models: [] },
});
getLocalLlmCatalog.mockResolvedValue({ models: [] });
- getModelAbuseGuardStatus.mockResolvedValue({
- id: 'llama-prompt-guard-2-86m',
- name: 'Llama Prompt Guard 2 86M',
- repository: 'meta-llama/Llama-Prompt-Guard-2-86M',
- sourceUrl: 'https://huggingface.co/meta-llama/Llama-Prompt-Guard-2-86M',
- ready: false,
- modelCached: false,
- runtimeReady: false,
- });
- getHfTokenStatus.mockResolvedValue({ hfTokenPresent: true, source: 'stored' });
- installModelAbuseGuard.mockResolvedValue({ ok: true, ready: true });
- cancelModelAbuseGuardInstall.mockResolvedValue({ cancelled: true });
installLocalLlmBackend.mockResolvedValue({ success: true });
patchSettingsSlice.mockResolvedValue({});
deleteLocalLlmModel.mockResolvedValue({ success: true });
@@ -136,6 +121,15 @@ describe('LocalLlmTab information architecture', () => {
expect(getLocalLlmCatalog).not.toHaveBeenCalled();
});
+ it('gives the model-abuse guard its own panel without mounting the catalog', async () => {
+ await renderTab('abuse');
+
+ expect(screen.getByTestId('model-abuse-guard-card')).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Models' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Local Runtime Servers' })).not.toBeInTheDocument();
+ expect(getLocalLlmCatalog).not.toHaveBeenCalled();
+ });
+
it('gives model installation its own panel without mounting runtime management', async () => {
const { getLlamaServerStatus, getMtplxServerStatus } = await import('../../services/api');
@@ -153,6 +147,15 @@ describe('LocalLlmTab information architecture', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Model Library' }));
expect(screen.getByTestId('location')).toHaveTextContent('/models/llms/library');
+ fireEvent.click(screen.getByRole('tab', { name: 'Abuse Guard' }));
+ expect(screen.getByTestId('location')).toHaveTextContent('/models/llms/abuse');
+ });
+
+ it('keeps the model-abuse guard off the catalog panel', async () => {
+ await renderTab('library');
+
+ expect(screen.queryByRole('heading', { name: 'Model-abuse guard' })).not.toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Models' })).toBeInTheDocument();
});
});
@@ -489,21 +492,6 @@ describe('LocalLlmTab recommendations', () => {
await waitFor(() => expect(screen.getByText('Qwen3.8 27B')).toBeTruthy());
});
- it('highlights the managed model-abuse guard separately from the chat catalog', async () => {
- getLocalLlmCatalog.mockResolvedValue({
- models: [],
- securityGuards: [{ id: 'llama-prompt-guard-2-86m' }],
- });
-
- await renderTab('library');
-
- expect(await screen.findByRole('heading', { name: 'Model-abuse guard' })).toBeInTheDocument();
- expect(screen.getByText('Recommended safety layer · managed classifier')).toBeInTheDocument();
- expect(screen.getByText('Llama Prompt Guard 2 86M')).toBeInTheDocument();
- expect(screen.queryByText('Recommended for Security Scan')).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Install model-abuse guard' })).toBeInTheDocument();
- });
-
it('offers redownload on an already-installed catalog card', async () => {
installLocalLlmModel.mockResolvedValue({ success: true });
getLocalLlmCatalog.mockResolvedValue({
diff --git a/client/src/pages/Models.jsx b/client/src/pages/Models.jsx
index 56b3db8a76..931a380f73 100644
--- a/client/src/pages/Models.jsx
+++ b/client/src/pages/Models.jsx
@@ -28,7 +28,7 @@ const MediaModels = lazyWithReload(() => import('./MediaModels'));
*
* - **3D** — image-to-3D runtime install/repair (TRELLIS.2, Pixal3D).
* - **Embeddings** — the embedding model backing pgvector search.
- * - **LLMs** — focused runtime-management and model-library sub-routes.
+ * - **LLMs** — focused runtime, model-library, and abuse-guard sub-routes.
* - **LoRAs** — installed image/video adapters.
* - **Media** — image/video checkpoints and the Hugging Face cache.
* - **Performance** — measured assessments and launch-tuning comparison.
@@ -83,7 +83,7 @@ export default function Models() {
// A record id in the URL selects the tab's drill-down, when it has one. Tabs
// without a detail component receive it as a focused sub-view id (LLMs uses
- // `runtimes` and `library`); tabs that do not recognize it render their index.
+ // `runtimes`, `library`, and `abuse`); tabs that do not recognize it render their index.
const DetailContent = recordId && Object.hasOwn(TAB_DETAIL, activeTab) ? TAB_DETAIL[activeTab] : null;
const TabContent = TAB_CONTENT[activeTab];
diff --git a/client/src/pages/Models.test.jsx b/client/src/pages/Models.test.jsx
index 2eb2b00981..283f6eea36 100644
--- a/client/src/pages/Models.test.jsx
+++ b/client/src/pages/Models.test.jsx
@@ -130,9 +130,9 @@ describe('Models', () => {
});
describe('Models — tab drill-downs', () => {
- it('passes an LLM sub-route through to the focused LLM view', async () => {
- renderAt('/models/llms/library');
- expect(await screen.findByTestId('llms-view')).toHaveAttribute('data-view', 'library');
+ it.each(['library', 'abuse'])('passes the LLM %s sub-route through to the focused LLM view', async (view) => {
+ renderAt(`/models/llms/${view}`);
+ expect(await screen.findByTestId('llms-view')).toHaveAttribute('data-view', view);
expect(screen.getByRole('tab', { name: 'LLMs' })).toHaveAttribute('aria-selected', 'true');
});
diff --git a/server/lib/README.md b/server/lib/README.md
index 3cfd0d7ff7..f54d576fa3 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -366,7 +366,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `huggingfaceLora.js` | HuggingFace LoRA import helpers: parse HF ref → `{repo,revision,file}`, fetch `/api/models` metadata, select an exact or family-matching `.safetensors`, detect the image or video LoRA family (`flux2` / `ltx-video` / …), build the sidecar + `resolve` download URL. The HF analogue of `civitai.js`. Pure. |
| `huggingfaceModel.js` | HuggingFace base-model (image/video) classifier for the self-service "add a model" flow (#2124): inspect repo siblings + card → decide the loadable runtime/runner, STRICTLY refuse GGUF-only / wan / hunyuan / unclassifiable repos (so a bad add can't wedge the picker), build the `media-models.json` entry (`source:'user'`), + a `searchHuggingfaceModels` Hub-search helper. Pure. |
| `localLlmCatalog.js` | Curated cross-backend (Ollama↔LM Studio) local-LLM catalog + install-id mapping for the migrate flow. Pure. |
-| `modelAbuseGuard.js` | Pinned model-abuse boundary contract: Prompt Guard metadata, deterministic abuse signals, classifier-envelope validation, chunk/timeout limits, and fixed install requirements. Pure. |
+| `modelAbuseGuard.js` | Pinned model-abuse boundary contract: Prompt Guard metadata, deterministic abuse signals, classifier-envelope validation, chunk/timeout limits, fixed install requirements, and `MODEL_ABUSE_GUARD_STAGES` / `modelAbuseGuardStageReadiness` for the operator-facing install checklist. Pure. |
| `localLlmDisk.js` | Pure on-disk reasoning for the migrate "copy GGUF locally instead of re-downloading" fast-path (Ollama manifest/blob parsing, LM Studio path layout, MLX/projector/shard detection) plus the Hugging Face registry addressing used to finish an abandoned Ollama pull. |
| `specDecodePresets.js` | Curated llama-server speculative-decoding presets (target + drafter GGUF paths, `--spec-type`, and the Hugging Face repo/quant each file comes from) plus `findSpecDecodePreset` / `specDecodeSource` / `hfSearchUrl`. Also owns the `--spec-type` vocabulary: `SPEC_TYPE_SUGGESTIONS` (published to the launcher card), `parseSpecTypes` (the flag is a comma-separated list) and `isDraftSpecType` (the `draft-` prefix is what needs a drafter GGUF — every `ngram-*` type runs without one). Server-owned so the launcher card can offer a Download button per file. Pure. |
| `llamaCppInstall.js` | Where a llama.cpp install comes from on this host, for `services/llamaServerManager.js`. `llamaCppInstallPlan(platform)` → the frozen descriptor for that platform's package manager — Homebrew on macOS/Linux (`brew install llama.cpp`), winget on Windows (`winget install ggml.llamacpp`) — carrying `manager`, `managerLabel`, `packageId`, `installCommand`, the install/upgrade (and, for winget, list/upgrade-check) argv, and the three refusal strings a caller would otherwise hardcode: `missingManagerError`, `notInstalledError`, `pathRepairHint`. The LLMs page renders `installCommand` out of the llama-server status payload, which is what stopped a Windows install prompt from telling the user to run Homebrew. Also the winget-side readers Homebrew needs no equivalent for: `parseWingetPackageFields(stdout, id)` (winget has NO machine-readable output mode and LOCALIZED table headers, so fields are read positionally from the tokens after the id token — `Version [Available] [Source]`; `null` = not installed), `wingetLinkDirs(env)` (the portable-shim directories winget adds to the USER PATH, which an already-running server has not inherited) and `isWingetManagedPath` (counterpart of the manager's `isHomebrewLlamaServer`: a source build earlier on PATH must not be offered a `winget upgrade`). Every WinGet path is reasoned about with `path.win32` regardless of host, so both branches are coverable from a macOS/Linux checkout. Pure. |
diff --git a/server/lib/modelAbuseGuard.js b/server/lib/modelAbuseGuard.js
index fbe4bce3a9..2fd09e2aa7 100644
--- a/server/lib/modelAbuseGuard.js
+++ b/server/lib/modelAbuseGuard.js
@@ -67,6 +67,68 @@ export const MODEL_ABUSE_GUARD_PYTHON_IMPORTS = Object.freeze([
'huggingface_hub'
]);
+// Operator-facing install stages, in the order `installModelAbuseGuard` runs
+// them. Status maps host facts onto this list; the UI must not invent a
+// parallel checklist. Token presence is a boolean on the stage — never a token
+// value, path, or exception string.
+export const MODEL_ABUSE_GUARD_STAGES = Object.freeze([
+ {
+ id: 'huggingface-token',
+ label: 'Hugging Face access token',
+ description: 'A read token plus gated-model approval on the Prompt Guard model card.',
+ },
+ {
+ id: 'python',
+ label: 'Host Python',
+ description: 'A Python interpreter PortOS can use as the base for the dedicated runtime.',
+ },
+ {
+ id: 'venv',
+ label: 'Dedicated Prompt Guard runtime',
+ description: 'A private virtualenv that never shares packages with image or video generation.',
+ },
+ {
+ id: 'packages',
+ label: 'Classifier packages',
+ description: 'Pinned torch, transformers, safetensors, and huggingface_hub imports.',
+ },
+ {
+ id: 'model',
+ label: 'Pinned model snapshot',
+ description: 'The five required Prompt Guard files from the pinned revision.',
+ },
+]);
+
+/**
+ * Map host facts onto the fixed install-stage list.
+ *
+ * `ready` on the envelope is the scan-time gate (cached weights + importable
+ * runtime). Token/Python/venv are prerequisites the installer still has to
+ * clear; they do not by themselves make the classifier usable.
+ */
+export function modelAbuseGuardStageReadiness({
+ huggingfaceTokenPresent = false,
+ pythonAvailable = false,
+ venvReady = false,
+ runtimeReady = false,
+ modelCached = false,
+} = {}) {
+ const readyById = {
+ 'huggingface-token': huggingfaceTokenPresent === true,
+ python: pythonAvailable === true,
+ venv: venvReady === true,
+ packages: runtimeReady === true,
+ model: modelCached === true,
+ };
+ return {
+ stages: MODEL_ABUSE_GUARD_STAGES.map((stage) => ({
+ ...stage,
+ ready: readyById[stage.id] === true,
+ })),
+ ready: runtimeReady === true && modelCached === true,
+ };
+}
+
// Read the complete supplied item up to this bound. Never truncate and then
// treat the prefix as a trustworthy verdict.
export const MODEL_ABUSE_GUARD_MAX_INPUT_CHARS = 2_000_000;
diff --git a/server/lib/modelAbuseGuard.test.js b/server/lib/modelAbuseGuard.test.js
index a12b2dbab5..ffc7d46e04 100644
--- a/server/lib/modelAbuseGuard.test.js
+++ b/server/lib/modelAbuseGuard.test.js
@@ -1,14 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
MODEL_ABUSE_GUARD_MAX_CHUNKS,
+ MODEL_ABUSE_GUARD_STAGES,
detectDeterministicModelAbuseSignals,
formatPublicReviewInputPrompt,
hasToolFreeTextCapability,
modelAbuseContentFingerprint,
+ modelAbuseGuardStageReadiness,
normalizeModelAbuseGuardResult,
} from './modelAbuseGuard.js';
describe('model-abuse guard contract', () => {
+ it('lists every install stage in installer order without implying the classifier is ready', () => {
+ expect(MODEL_ABUSE_GUARD_STAGES.map((stage) => stage.id)).toEqual([
+ 'huggingface-token',
+ 'python',
+ 'venv',
+ 'packages',
+ 'model',
+ ]);
+ expect(modelAbuseGuardStageReadiness({
+ huggingfaceTokenPresent: true,
+ pythonAvailable: true,
+ venvReady: true,
+ })).toMatchObject({
+ ready: false,
+ stages: [
+ expect.objectContaining({ id: 'huggingface-token', ready: true }),
+ expect.objectContaining({ id: 'python', ready: true }),
+ expect.objectContaining({ id: 'venv', ready: true }),
+ expect.objectContaining({ id: 'packages', ready: false }),
+ expect.objectContaining({ id: 'model', ready: false }),
+ ],
+ });
+ expect(modelAbuseGuardStageReadiness({
+ huggingfaceTokenPresent: true,
+ pythonAvailable: true,
+ venvReady: true,
+ runtimeReady: true,
+ modelCached: true,
+ }).ready).toBe(true);
+ });
+
it('requires an explicit text capability and rejects native tools', () => {
expect(hasToolFreeTextCapability(['completion'])).toBe(true);
expect(hasToolFreeTextCapability(['chat'])).toBe(true);
diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js
index 64e8032a76..07f0dccb00 100644
--- a/server/lib/navManifest.js
+++ b/server/lib/navManifest.js
@@ -279,6 +279,7 @@ const RAW_NAV_COMMANDS = [
{ id: 'nav.models.3d', path: '/models/3d', label: '3D', section: 'Models', aliases: ['3d-runtimes', 'image-to-3d-runtimes', 'trellis-install', 'pixal3d-install'], keywords: ['trellis', 'pixal3d', 'install', 'repair', 'runtime', 'mesh', 'image to 3d', 'on-device'] },
{ id: 'nav.settings.embeddings', path: '/models/embeddings', label: 'Embeddings', section: 'Models', previousPaths: ['/settings/embeddings'], aliases: ['settings-embeddings', 'embeddings', 'embedding'], keywords: ['vector', 'pgvector', 'semantic search', 'nomic', 'ollama', 'lm studio'] },
{ id: 'nav.settings.local-llm', path: '/models/llms', label: 'LLMs', section: 'Models', previousPaths: ['/settings/local-llm'], aliases: ['local-llm', 'local-llms', 'llms', 'models-llms', 'ollama', 'lm-studio', 'lmstudio'], keywords: ['ollama', 'lm studio', 'local model', 'local llm', 'gguf', 'pull model', 'install model', 'migrate', 'switch backend', 'llama.cpp'] },
+ { id: 'nav.models.llms.abuse', path: '/models/llms/abuse', label: 'Abuse Guard', section: 'Models', aliases: ['abuse-guard', 'model-abuse', 'model-abuse-guard', 'prompt-guard', 'prompt guard'], keywords: ['classifier', 'prompt injection', 'security scan', 'llama prompt guard', 'install guard'] },
{ id: 'nav.media.loras', path: '/models/loras', label: 'LoRAs', section: 'Models', previousPaths: ['/media/loras'], aliases: ['loras', 'lora', 'lora-manager', 'civitai'], keywords: ['lora', 'civitai', 'fine-tune', 'style adapter', 'realstagram', 'photoreal', 'flux lora'] },
{ id: 'nav.media.training', path: '/models/training', label: 'Training', section: 'Models', previousPaths: ['/media/training', '/media/training/:datasetId'], aliases: ['training', 'lora-training', 'train-lora', 'datasets', 'character-lora'], keywords: ['fine-tune', 'dataset', 'caption', 'dreambooth', 'character consistency', 'train', 'flux lora'] },
{ id: 'nav.media.models', path: '/models/media', label: 'Media', section: 'Models', previousPaths: ['/media/models', '/media-models'], aliases: ['media-models', 'image-models', 'video-models', 'huggingface'], keywords: ['hf cache', 'model storage', 'disk', 'add model', 'install model', 'custom model'] },
diff --git a/server/lib/navManifest.test.js b/server/lib/navManifest.test.js
index e48b5aae27..ef47782319 100644
--- a/server/lib/navManifest.test.js
+++ b/server/lib/navManifest.test.js
@@ -36,7 +36,10 @@ const TABBED_PAGES = [
{ prefix: '/messages', file: 'client/src/pages/Messages.jsx', kind: 'ids', constName: 'TABS' },
{ prefix: '/wiki', file: 'client/src/pages/Wiki.jsx', kind: 'ids', constName: 'TABS' },
{ prefix: '/settings', file: 'client/src/components/settings/SettingsTabsHeader.jsx', kind: 'links', constName: 'TABS' },
- { prefix: '/models', file: 'client/src/components/models/ModelsTabsHeader.jsx', kind: 'links', constName: 'TABS' },
+ { prefix: '/models', file: 'client/src/components/models/ModelsTabsHeader.jsx', kind: 'links', constName: 'TABS',
+ nestedIdSources: [
+ { parent: 'llms', file: 'client/src/components/settings/LocalLlmTab.jsx', constName: 'LLM_NAV_SUBROUTES' },
+ ] },
{ prefix: '/media', file: 'client/src/pages/MediaGen.jsx', kind: 'ids', constName: 'TABS', allowBasePrefix: true },
{ prefix: '/music', file: 'client/src/pages/Music.jsx', kind: 'ids', constName: 'TABS', allowBasePrefix: true },
{ prefix: '/sharing', file: 'client/src/pages/Sharing.jsx', kind: 'links', constName: 'SECTIONS' },
@@ -111,12 +114,15 @@ function extractTabPaths(filePath, { kind, constName, switchVar, prefix, nestedI
const block = extractConstArrayBlock(src, constName);
if (kind === 'ids') {
const ids = [...block.matchAll(/id:\s*['"]([^'"]+)['"]/g)].map((m) => `${prefix}/${m[1]}`);
- return allowBasePrefix ? [prefix, ...ids] : ids;
+ return [...(allowBasePrefix ? [prefix, ...ids] : ids), ...nested];
}
// kind 'links': keep only entries that point at this page, dropping cross-links.
- return [...block.matchAll(/(?:to|path):\s*['"]([^'"]+)['"]/g)]
- .map((m) => m[1])
- .filter((p) => p === prefix || p.startsWith(`${prefix}/`));
+ return [
+ ...[...block.matchAll(/(?:to|path):\s*['"]([^'"]+)['"]/g)]
+ .map((m) => m[1])
+ .filter((p) => p === prefix || p.startsWith(`${prefix}/`)),
+ ...nested,
+ ];
}
describe('navManifest — shape invariants', () => {
@@ -244,6 +250,8 @@ describe('resolveNavCommand — fuzzy matching', () => {
expect(resolveNavCommand('loras')?.path).toBe('/models/loras');
expect(resolveNavCommand('lora training')?.path).toBe('/models/training');
expect(resolveNavCommand('embeddings')?.path).toBe('/models/embeddings');
+ expect(resolveNavCommand('prompt guard')?.path).toBe('/models/llms/abuse');
+ expect(resolveNavCommand('abuse-guard')?.path).toBe('/models/llms/abuse');
});
it('resolves Universe Builder to the /universes index path', () => {
diff --git a/server/routes/localLlm.js b/server/routes/localLlm.js
index 2c05f189d3..06aa3c8d99 100644
--- a/server/routes/localLlm.js
+++ b/server/routes/localLlm.js
@@ -94,7 +94,7 @@ const router = Router()
const emitter = (req) => {
const io = req.app.get('io')
- return (event, message) => io?.emit('localLlm:progress', { event, message })
+ return (event, message, extra) => io?.emit('localLlm:progress', { event, message, ...extra })
}
// GET /api/local-llm/status — both backends + active marker
@@ -191,7 +191,7 @@ router.get('/security-guard/status', asyncHandler(async (_req, res) => {
router.post('/security-guard/install', asyncHandler(async (req, res) => {
const emit = emitter(req)
const result = await installModelAbuseGuard({
- onEvent: ({ event, message }) => emit(event, message),
+ onEvent: ({ event, message, stage }) => emit(event, message, { scope: 'security-guard', stage }),
})
if (!result?.ok) {
const code = result?.code || 'security-guard-install-failed'
@@ -200,7 +200,7 @@ router.post('/security-guard/install', asyncHandler(async (req, res) => {
: code === 'security-guard-huggingface-access-required'
? 'Hugging Face has not granted Prompt Guard access yet. Submit the usage request on its model card, then retry.'
: code
- emit('error', message)
+ emit('error', message, { scope: 'security-guard' })
throw new ServerError(message, { status: 502, code })
}
res.json(result)
diff --git a/server/services/modelAbuseGuard.js b/server/services/modelAbuseGuard.js
index 7ede94be9c..05581c1c3f 100644
--- a/server/services/modelAbuseGuard.js
+++ b/server/services/modelAbuseGuard.js
@@ -32,6 +32,7 @@ import {
MODEL_ABUSE_GUARD_TIMEOUT_MS,
detectDeterministicModelAbuseSignals,
hasToolFreeTextCapability,
+ modelAbuseGuardStageReadiness,
normalizeModelAbuseGuardResult,
} from '../lib/modelAbuseGuard.js';
import { findCachedRepoFiles } from '../lib/hfCache.js';
@@ -216,9 +217,13 @@ export function buildModelAbuseGuardEnv(source = process.env) {
};
}
-const emitInstall = (onEvent, event, message) => {
+const emitInstall = (onEvent, event, message, stage) => {
if (typeof onEvent !== 'function') return;
- onEvent({ event, message: String(message || '').slice(0, MAX_INSTALL_EVENT_CHARS) });
+ onEvent({
+ event,
+ message: String(message || '').slice(0, MAX_INSTALL_EVENT_CHARS),
+ ...(stage ? { stage } : {}),
+ });
};
function availableGuardPython() {
@@ -256,19 +261,32 @@ async function isRuntimeReady(pythonPath) {
* are intentionally omitted from the API contract.
*/
export async function getModelAbuseGuardStatus() {
- const [files, pythonPath] = await Promise.all([
+ const [files, pythonPath, huggingfaceTokenPresent] = await Promise.all([
findCachedRepoFiles(MODEL_ABUSE_GUARD.repository, MODEL_ABUSE_GUARD_REQUIRED_FILES, {
revision: MODEL_ABUSE_GUARD.revision,
}),
Promise.resolve(availableGuardPython()),
+ getHfToken().then((token) => Boolean(token)).catch(() => false),
]);
const modelCached = Array.isArray(files);
+ const venvReady = Boolean(pythonPath);
+ const pythonAvailable = Boolean(detectVenvBasePythonSync());
const runtimeReady = await isRuntimeReady(pythonPath);
+ const { stages, ready } = modelAbuseGuardStageReadiness({
+ huggingfaceTokenPresent,
+ pythonAvailable,
+ venvReady,
+ runtimeReady,
+ modelCached,
+ });
return {
...MODEL_ABUSE_GUARD,
modelCached,
runtimeReady,
- ready: modelCached && runtimeReady,
+ pythonAvailable,
+ venvReady,
+ stages,
+ ready,
};
}
@@ -288,31 +306,31 @@ export function installModelAbuseGuard({ onEvent } = {}) {
const basePython = detectVenvBasePythonSync();
if (!basePython) return failure('security-guard-python-unavailable');
await ensureDir(dirname(GUARD_VENV_DIR));
- emitInstall(onEvent, 'stage', 'Preparing the dedicated Prompt Guard runtime…');
+ emitInstall(onEvent, 'stage', 'Preparing the dedicated Prompt Guard runtime…', 'venv');
const pythonPath = await createVenv(basePython, GUARD_VENV_DIR);
cachedRuntime = null;
- emitInstall(onEvent, 'stage', 'Installing the fixed classifier runtime packages…');
+ emitInstall(onEvent, 'stage', 'Installing the fixed classifier runtime packages…', 'packages');
const packageRun = installPackages(pythonPath, [...MODEL_ABUSE_GUARD_PYTHON_IMPORTS], ({ type, message }) => {
- if (type === 'complete') emitInstall(onEvent, 'stage', 'Classifier runtime packages are ready.');
- else if (type === 'error') emitInstall(onEvent, 'error', 'Classifier runtime package installation failed.');
- else if (message && /install|uninstall/i.test(message)) emitInstall(onEvent, 'stage', 'Installing classifier runtime packages…');
+ if (type === 'complete') emitInstall(onEvent, 'stage', 'Classifier runtime packages are ready.', 'packages');
+ else if (type === 'error') emitInstall(onEvent, 'error', 'Classifier runtime package installation failed.', 'packages');
+ else if (message && /install|uninstall/i.test(message)) emitInstall(onEvent, 'stage', 'Installing classifier runtime packages…', 'packages');
});
installKill = packageRun.kill;
const packageResult = await packageRun.promise;
installKill = null;
if (!packageResult?.ok) return failure('security-guard-runtime-install-failed');
- emitInstall(onEvent, 'stage', 'Downloading the pinned Prompt Guard model snapshot…');
+ emitInstall(onEvent, 'stage', 'Downloading the pinned Prompt Guard model snapshot…', 'model');
const download = downloadHfRepo({
repo: MODEL_ABUSE_GUARD.repository,
revision: MODEL_ABUSE_GUARD.revision,
only: [...MODEL_ABUSE_GUARD_REQUIRED_FILES],
pythonPath,
onEvent: (event) => {
- if (event?.type === 'error') emitInstall(onEvent, 'error', 'Prompt Guard model download failed.');
- else if (event?.type === 'progress') emitInstall(onEvent, 'progress', event.stage || 'Downloading Prompt Guard…');
- else if (event?.type === 'complete') emitInstall(onEvent, 'stage', 'Pinned Prompt Guard model snapshot downloaded.');
+ if (event?.type === 'error') emitInstall(onEvent, 'error', 'Prompt Guard model download failed.', 'model');
+ else if (event?.type === 'progress') emitInstall(onEvent, 'progress', event.stage || 'Downloading Prompt Guard…', 'model');
+ else if (event?.type === 'complete') emitInstall(onEvent, 'stage', 'Pinned Prompt Guard model snapshot downloaded.', 'model');
},
});
installKill = download.kill;