mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 14:56:11 -04:00
* 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>
121 lines
4.3 KiB
JavaScript
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 });
|
|
});
|
|
});
|