Files
LocalAI/core/http/react-ui/e2e/models-cluster-fit.spec.js
mudler's LocalAI [bot]andEttore Di Giacinto 29899cd1e0 fix(ui): size model fit against the cluster and move node labels into the selector (#11765)
* fix(ui): move node labels into the scheduling selector field

The scheduling page kept a node-label browser open above the rules
whether or not anyone was writing one, while the field that actually
needs labels, the rule's node selector, was two bare text inputs with no
hint of what the cluster reports.

The browser is gone. The selector's key input now completes against the
label keys the cluster uses, and the value input offers only the values
that key takes. The roster already loads for the page, so the
suggestions cost no request, and a roster that fails to load costs the
admin the hints and nothing else.

Suggestions stay suggestions: a key no node reports yet still commits as
typed, which is how an admin writes a rule before labelling the nodes
for it.

Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(distributed): size model fit against the cluster, not the frontend

The models page asked the frontend how much memory a model may occupy.
In distributed mode the frontend is usually a GPU-less pod while every
model runs on a worker, so a fleet of GPU nodes was told it could only
run the smallest CPU build. The variant picker's fits flag and its
auto-selection came from the same place, as did the hardware
recommendations.

The registry now reports the largest single healthy backend node. The
largest node, not the fleet total: a model loads into one node, so four
16GB workers are not a home for a 40GB model. An operator-set VRAM
budget caps a node's contribution, because the scheduler refuses a load
above that ceiling anyway, and a GPU node beats a CPU node holding more
system RAM.

GET /api/resources and GET /api/models carry this as an additional
cluster object. Their aggregate and ram fields keep reporting the
frontend's own hardware, which is what the resource monitor shows.
Variant selection judges backends against the union of the capabilities
present in the cluster, the way backend discovery already did.

Every path degrades to the local host: no cluster object in single-node
mode, and none when the registry cannot be read, so a hiccup narrows the
answer back to single-node behaviour rather than marking the whole
catalog too large.

The verdicts now name the node they belong to, since a model fits
somewhere or nowhere.

Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-08-28 22:57:35 +02:00

121 lines
4.3 KiB
JavaScript

import { test, expect } from "./coverage-fixtures.js";
// On a distributed controller the models run on the workers, so every "will
// this fit" answer on this page is about their hardware. The controller is
// usually a GPU-less pod: sized against it, a cluster of A100s is told it can
// only run the smallest CPU build.
const GB = 1024 * 1024 * 1024;
const MODELS = [
{ name: "big-gpu-model", description: "Needs a real GPU", backend: "vllm", installed: false, tags: ["chat"] },
];
// 40GB: far past the controller's 8GB of RAM, comfortably inside one 80GB card.
const ESTIMATES = {
"big-gpu-model": {
sizeBytes: 40 * GB,
sizeDisplay: "40.0 GB",
estimates: { 8192: { vramBytes: 40 * GB, vramDisplay: "40.0 GB" } },
},
};
// The controller as Argus actually runs it: 8GB of system RAM, no GPU.
const CONTROLLER_ONLY = {
type: "ram",
available: true,
gpus: [],
aggregate: { total_memory: 8 * GB, used_memory: 2 * GB, free_memory: 6 * GB, gpu_count: 0 },
};
const WITH_CLUSTER = {
...CONTROLLER_ONLY,
cluster: {
enabled: true,
node_id: "n-1",
node_name: "dgx-01",
total_memory: 80 * GB,
is_gpu: true,
node_count: 4,
},
};
async function mockModels(page, resources) {
await page.route("**/api/models*", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
models: MODELS,
allBackends: ["vllm"],
allTags: ["chat"],
availableModels: MODELS.length,
installedModels: 3,
totalPages: 1,
currentPage: 1,
}),
}),
);
await page.route("**/api/models/estimate/*", (route) => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split("/").pop());
return route.fulfill({ contentType: "application/json", body: JSON.stringify(ESTIMATES[name] || {}) });
});
await page.route("**/api/resources", (route) =>
route.fulfill({ contentType: "application/json", body: JSON.stringify(resources) }),
);
}
const railItems = (page) => page.locator('[data-testid="discover-rail-item"]');
const railItem = (page, name) => page.locator(`[data-entity="${name}"]`);
const railReady = (page) => expect(railItems(page).first()).toBeVisible({ timeout: 20_000 });
const PANE = '[data-testid="discover-pane"]';
test.describe("Models gallery - cluster-aware fit", () => {
test("a model that only a worker can hold is not called too large", async ({ page }) => {
await mockModels(page, WITH_CLUSTER);
await page.goto("/app/models");
await railReady(page);
// The whole defect in one assertion: 40GB against a 4-node cluster whose
// largest card holds 80GB.
await expect(railItem(page, "big-gpu-model")).toContainText("fits", { timeout: 20_000 });
await expect(railItem(page, "big-gpu-model")).not.toContainText("too large");
});
test("the fit verdict names the node it belongs to", async ({ page }) => {
await mockModels(page, WITH_CLUSTER);
await page.goto("/app/models");
await railReady(page);
await railItem(page, "big-gpu-model").click();
// Wait for the detail itself: until it renders, the pane still holds the
// zero-state hero, which names the node for its own reasons.
await expect(page.locator(PANE).getByText("40.0 GB")).toBeVisible({ timeout: 20_000 });
// The headroom this model has is headroom SOMEWHERE, and the stat says
// where rather than leaving it to read as this machine's.
await expect(page.locator(PANE)).toContainText(/headroom on dgx-01/i);
});
test("the host summary describes the cluster, not the controller", async ({ page }) => {
await mockModels(page, WITH_CLUSTER);
await page.goto("/app/models");
await railReady(page);
// 80 GB is the cluster's best node; 8 GB is this pod's own RAM and must
// not be what the page advertises.
await expect(page.locator(".zero-pane__title")).toContainText("80 GB");
await expect(page.locator(".zero-pane__title")).not.toContainText("8.00 GB");
});
// Single-node behavior is the fallback every degradation path lands on, so
// it has to stay exactly as it was.
test("without a cluster the verdict is still the local host's", async ({ page }) => {
await mockModels(page, CONTROLLER_ONLY);
await page.goto("/app/models");
await railReady(page);
await expect(railItem(page, "big-gpu-model")).toContainText("too large", { timeout: 20_000 });
});
});