Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions packages/ui/src/services/local-inference/active-model.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join as pathJoin } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
isForkOnlyKvCacheType,
isStockKvCacheType,
resolveLocalInferenceLoadArgs,
validateLocalInferenceLoadArgs,
} from "./active-model";
import { ELIZA_1_MTP_TIER_IDS } from "./catalog";
import {
ELIZA_1_HOSTED_MTP_TIER_IDS,
ELIZA_1_MTP_TIER_IDS,
findCatalogModel,
} from "./catalog";
import type { InstalledModel } from "./types";

function makeInstalledModel(
Expand All @@ -35,8 +39,9 @@ function makeTempElizaBundle(
const bundleRoot = mkdtempSync(pathJoin(tmpdir(), "eliza-ui-mtp-"));
mkdirSync(pathJoin(bundleRoot, "text"), { recursive: true });
const textPath = pathJoin(bundleRoot, "text", `eliza-1-${tier}-32k.gguf`);
// Shape a would-be separate-drafter MTP file. The resolver should ignore it
// until the shared catalog says that hosted Gemma drafter GGUFs are present.
// Shape a separate-drafter MTP file. The resolver wires it only for tiers
// whose drafter is hosted in the shared catalog (ELIZA_1_HOSTED_MTP_TIER_IDS)
// and ignores stray on-disk drafters for every other tier.
const drafterPath = pathJoin(bundleRoot, "mtp", `drafter-${tier}.gguf`);
writeFileSync(textPath, "fake-text-gguf");
if (options.hasMtp !== false) {
Expand Down Expand Up @@ -121,8 +126,31 @@ describe("resolveLocalInferenceLoadArgs", () => {
expect(args.kvOffload).toEqual({ gpuLayers: 10 });
});

it("does not enable MTP args until hosted Gemma drafter GGUFs are cataloged", async () => {
for (const id of ELIZA_1_MTP_TIER_IDS) {
it("enables MTP args for hosted-drafter tiers whose drafter GGUF is bundled", async () => {
expect(ELIZA_1_HOSTED_MTP_TIER_IDS.length).toBeGreaterThan(0);
for (const id of ELIZA_1_HOSTED_MTP_TIER_IDS) {
const tier = id.replace("eliza-1-", "");
const bundle = makeTempElizaBundle(tier);
const target = makeInstalledModel(id, bundle.textPath, bundle.bundleRoot);
const mtp = findCatalogModel(id)?.runtime?.mtp;
expect(mtp?.specType).toBe("draft-mtp");
try {
const args = await resolveLocalInferenceLoadArgs(target);
expect(args.draftModelPath).toBe(bundle.drafterPath);
expect(args.draftMin).toBe(mtp?.draftMin);
expect(args.draftMax).toBe(mtp?.draftMax);
expect(args.mobileSpeculative).toBe(true);
} finally {
rmSync(bundle.bundleRoot, { recursive: true, force: true });
}
}
});

it("ignores a stray on-disk drafter for tiers without a hosted Gemma drafter", async () => {
const hosted = new Set<string>(ELIZA_1_HOSTED_MTP_TIER_IDS);
const unhosted = ELIZA_1_MTP_TIER_IDS.filter((id) => !hosted.has(id));
expect(unhosted.length).toBeGreaterThan(0);
for (const id of unhosted) {
const tier = id.replace("eliza-1-", "");
const bundle = makeTempElizaBundle(tier);
const target = makeInstalledModel(id, bundle.textPath, bundle.bundleRoot);
Expand All @@ -138,7 +166,12 @@ describe("resolveLocalInferenceLoadArgs", () => {
}
});

it("does not require a drafter GGUF while hosted Gemma MTP is unavailable", async () => {
it("falls back to a non-speculative load when a pre-cutover bundle is missing the drafter GGUF", async () => {
// Back-compat (#11517): a bundle installed BEFORE the Gemma-4 MTP cutover
// has no `mtp/drafter-*.gguf` on disk. The drafter is a perf-only
// speculative-decoding artifact, so the model must still load (warn +
// plain decode) — never hard-throw and brick the install.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const bundle = makeTempElizaBundle("2b", { hasMtp: false });
try {
const target = makeInstalledModel(
Expand All @@ -147,9 +180,18 @@ describe("resolveLocalInferenceLoadArgs", () => {
bundle.bundleRoot,
);
const args = await resolveLocalInferenceLoadArgs(target);
expect(args.modelPath).toBe(bundle.textPath);
expect(args.draftModelPath).toBeUndefined();
expect(args.draftMin).toBeUndefined();
expect(args.draftMax).toBeUndefined();
expect(args.mobileSpeculative).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Re-download the model to enable the MTP drafter",
),
);
} finally {
warnSpy.mockRestore();
rmSync(bundle.bundleRoot, { recursive: true, force: true });
}
});
Expand Down
33 changes: 21 additions & 12 deletions packages/ui/src/services/local-inference/active-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,24 +371,33 @@ export async function resolveLocalInferenceLoadArgs(
if (mtp) {
// Same-file MTP (no `drafterFile`) embeds the NextN head in the text
// GGUF and runs with no separate draft model; separate-drafter MTP
// declares a `drafterFile` and requires it present on disk.
// declares a `drafterFile` and needs the bundled drafter GGUF on disk.
const sameFileMtp = !mtp.drafterFile;
const drafterPath = sameFileMtp
? undefined
: resolveMtpDrafterPath(installed, catalog);
if (!sameFileMtp && installed.bundleRoot && !drafterPath) {
throw new Error(
`[local-inference] ${installed.id} declares a separate-drafter MTP but no bundled drafter GGUF was found under ${installed.bundleRoot}`,
if (!sameFileMtp && !drafterPath) {
// Back-compat with pre-MTP-cutover installs (#11517): bundles
// downloaded before the tier's gemma4-assistant drafter was hosted
// (and single-file installs with no bundleRoot) have no
// `mtp/drafter-<tier>.gguf` on disk. The drafter is a perf-only
// speculative-decoding artifact — never brick an installed model over
// it. Load without MTP; re-downloading the bundle picks the drafter up.
console.warn(
`[local-inference] ${installed.id} declares a separate-drafter MTP but no drafter GGUF was found${
installed.bundleRoot ? ` under ${installed.bundleRoot}` : ""
}; loading without speculative decoding. Re-download the model to enable the MTP drafter.`,
);
} else {
args.useGpu = true;
args.draftModelPath = drafterPath;
args.draftContextSize = args.contextSize;
args.draftMin = mtp.draftMin;
args.draftMax = mtp.draftMax;
args.speculativeSamples = mtp.draftMax;
args.mobileSpeculative = true;
args.disableThinking = true;
}
args.useGpu = true;
args.draftModelPath = drafterPath;
args.draftContextSize = args.contextSize;
args.draftMin = mtp.draftMin;
args.draftMax = mtp.draftMax;
args.speculativeSamples = mtp.draftMax;
args.mobileSpeculative = true;
args.disableThinking = true;
}

mergeOverrides(args, overrides);
Expand Down
36 changes: 35 additions & 1 deletion plugins/plugin-local-inference/__tests__/mmproj-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join as pathJoin } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
resolveLocalInferenceLoadArgs,
resolveMmprojPath,
Expand Down Expand Up @@ -185,6 +185,40 @@ describe("WS2 mmproj routing", () => {
expect(resolved.mobileSpeculative).toBeUndefined();
});

it("falls back to a non-speculative load when a pre-cutover bundle is missing the drafter GGUF", async () => {
// Back-compat (#11517): a 2b/4b bundle installed BEFORE the Gemma-4 MTP
// cutover has no `mtp/drafter-<tier>.gguf` on disk even though the
// catalog now advertises runtime.mtp for the tier. The drafter is a
// perf-only speculative-decoding artifact — the text model must still
// load (warn + plain decode), never hard-throw and brick the install.
const tier = "2b";
expect(findCatalogModel(`eliza-1-${tier}`)?.runtime?.mtp?.specType).toBe(
"draft-mtp",
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const bundle = makeTempBundle({ hasMmproj: true, hasMtp: false, tier });
const installed = installedModel({
id: `eliza-1-${tier}`,
bundleRoot: bundle.bundleRoot,
path: bundle.textPath,
});
const resolved = await resolveLocalInferenceLoadArgs(installed);
expect(resolved.modelPath).toBe(bundle.textPath);
expect(resolved.draftModelPath).toBeUndefined();
expect(resolved.draftMin).toBeUndefined();
expect(resolved.draftMax).toBeUndefined();
expect(resolved.mobileSpeculative).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Re-download the model to enable the MTP drafter",
),
);
} finally {
warnSpy.mockRestore();
}
});

it("leaves mmprojPath undefined when bundleRoot is absent", async () => {
// External-scan models (LM Studio, Jan) have a path but no
// bundleRoot. They never carry mmproj — even if the catalog has
Expand Down
30 changes: 20 additions & 10 deletions plugins/plugin-local-inference/src/services/active-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,22 +667,32 @@ export async function resolveLocalInferenceLoadArgs(
// Two MTP shapes: embedded-draft-head MTP embeds the draft head in
// the text GGUF (no `drafterFile` in the catalog) and runs with no
// separate draft model; separate-drafter MTP declares a `drafterFile`
// and requires the bundled drafter GGUF to be present on disk.
// and needs the bundled drafter GGUF to be present on disk.
const sameFileMtp = !mtp.drafterFile;
const drafterPath = sameFileMtp
? undefined
: resolveMtpDrafterPath(installed, catalog, manifestLoader);
if (!sameFileMtp && installed.bundleRoot && !drafterPath) {
throw new Error(
`[local-inference] ${installed.id} declares a separate-drafter MTP but no bundled drafter GGUF was found under ${installed.bundleRoot}`,
if (!sameFileMtp && !drafterPath) {
// Back-compat with pre-MTP-cutover installs (#11517): bundles
// downloaded before the tier's gemma4-assistant drafter was hosted
// (and single-file installs with no bundleRoot) have no
// `mtp/drafter-<tier>.gguf` on disk. The drafter is a perf-only
// speculative-decoding artifact — never brick an installed model
// over it. Load without MTP; re-downloading the bundle picks the
// drafter up.
console.warn(
`[local-inference] ${installed.id} declares a separate-drafter MTP but no drafter GGUF was found${
installed.bundleRoot ? ` under ${installed.bundleRoot}` : ""
}; loading without speculative decoding. Re-download the model to enable the MTP drafter.`,
);
} else {
args.useGpu = true;
args.draftModelPath = drafterPath;
args.draftMin = mtp.draftMin;
args.draftMax = mtp.draftMax;
args.speculativeSamples = mtp.draftMax;
args.mobileSpeculative = true;
}
args.useGpu = true;
args.draftModelPath = drafterPath;
args.draftMin = mtp.draftMin;
args.draftMax = mtp.draftMax;
args.speculativeSamples = mtp.draftMax;
args.mobileSpeculative = true;
}

mergeOverrides(args, overrides);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
//
// - `resolveLocalInferenceLoadArgs` — catalog + manifest + overrides merge,
// including the separate-drafter MTP rule: a hosted-MTP tier (eliza-1-2b
// declares `runtime.mtp.drafterFile`) with a bundleRoot but NO drafter
// GGUF on disk must throw, never silently load without speculation.
// declares `runtime.mtp.drafterFile`) with NO drafter GGUF on disk falls
// back to a plain non-speculative load (warn, no MTP args) — a
// pre-cutover install must never be bricked over the perf-only drafter
// (#11517 back-compat).
// - `validateLocalInferenceLoadArgs` — differential fuzz against an oracle
// mirroring the documented acceptance rules (stock vs fork KV cache
// types, contextSize/gpuLayers integrality, kvOffload shapes).
Expand All @@ -23,7 +25,7 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join as pathJoin } from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
isForkOnlyKvCacheType,
isStockKvCacheType,
Expand Down Expand Up @@ -149,29 +151,53 @@ describe("resolveLocalInferenceLoadArgs — separate-drafter MTP resolution", ()
expect(catalog2b?.runtime?.mtp?.drafterFile).toMatch(/drafter-2b\.gguf$/);
});

it("throws when the declared drafter GGUF is missing under bundleRoot", async () => {
const bundle = makeTempBundle({ tier: "2b", hasDrafter: false });
const installed = installedModel({
id: "eliza-1-2b",
path: bundle.textPath,
bundleRoot: bundle.bundleRoot,
});
await expect(resolveLocalInferenceLoadArgs(installed)).rejects.toThrow(
/separate-drafter MTP but no bundled drafter GGUF/,
);
it("falls back to a non-speculative load when the declared drafter GGUF is missing under bundleRoot", async () => {
// #11517 back-compat: a bundle installed before the Gemma-4 MTP cutover
// has no mtp/drafter-2b.gguf. The drafter is perf-only — the text model
// must still load (warn + plain decode), never throw.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const bundle = makeTempBundle({ tier: "2b", hasDrafter: false });
const installed = installedModel({
id: "eliza-1-2b",
path: bundle.textPath,
bundleRoot: bundle.bundleRoot,
});
const resolved = await resolveLocalInferenceLoadArgs(installed);
expect(resolved.modelPath).toBe(bundle.textPath);
expect(resolved.draftModelPath).toBeUndefined();
expect(resolved.draftMin).toBeUndefined();
expect(resolved.draftMax).toBeUndefined();
expect(resolved.mobileSpeculative).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Re-download the model to enable the MTP drafter",
),
);
} finally {
warnSpy.mockRestore();
}
});

it("does not throw for an external-scan install (no bundleRoot); drafter stays unset", async () => {
const bundle = makeTempBundle({ tier: "2b", hasDrafter: false });
const installed = installedModel({
id: "eliza-1-2b",
path: bundle.textPath,
});
const resolved = await resolveLocalInferenceLoadArgs(installed);
expect(resolved.draftModelPath).toBeUndefined();
// The MTP block still applies the catalog draft window defaults.
expect(resolved.draftMin).toBe(catalog2b?.runtime?.mtp?.draftMin);
expect(resolved.draftMax).toBe(catalog2b?.runtime?.mtp?.draftMax);
it("does not throw for an external-scan install (no bundleRoot); MTP stays fully unset", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const bundle = makeTempBundle({ tier: "2b", hasDrafter: false });
const installed = installedModel({
id: "eliza-1-2b",
path: bundle.textPath,
});
const resolved = await resolveLocalInferenceLoadArgs(installed);
expect(resolved.draftModelPath).toBeUndefined();
// No drafter on disk ⇒ the whole MTP block is skipped — a
// half-configured speculative state (draft window without a draft
// model) must never leak into the loader.
expect(resolved.draftMin).toBeUndefined();
expect(resolved.draftMax).toBeUndefined();
expect(resolved.mobileSpeculative).toBeUndefined();
} finally {
warnSpy.mockRestore();
}
});

it("prefers a manifest files.mtp entry that exists on disk over the catalog path", async () => {
Expand Down
Loading