diff --git a/src/index.ts b/src/index.ts index 74fa715d..b4fe78ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ import { log } from "./subcommands/log.js"; import { login } from "./subcommands/login.js"; import { logout } from "./subcommands/logout.js"; import { push } from "./subcommands/push.js"; +import { remove } from "./subcommands/remove.js"; import { runtime } from "./subcommands/runtime/index.js"; import { server } from "./subcommands/server.js"; import { status } from "./subcommands/status.js"; @@ -165,7 +166,7 @@ Learn more: ${chalk.blue("https://lmstudio.ai/docs/developer")} Join our Discord: ${chalk.blue("https://discord.gg/lmstudio")}`, ); -addCommandsGroup("Local models", [chat, get, load, unload, ls, ps, importCmd], "#22D3EE"); +addCommandsGroup("Local models", [chat, get, load, unload, remove, ls, ps, importCmd], "#22D3EE"); addCommandsGroup("Serve", [server, log], "#34D399"); addCommandsGroup("Remote Instances", [link], "#818CF8"); addCommandsGroup("Runtime", [runtime], "#C084FC"); diff --git a/src/modelsFolder.ts b/src/modelsFolder.ts new file mode 100644 index 00000000..b7a4a4e4 --- /dev/null +++ b/src/modelsFolder.ts @@ -0,0 +1,68 @@ +import { type SimpleLogger } from "@lmstudio/lms-common"; +import { findLMStudioHome } from "@lmstudio/lms-common-server"; +import { access, mkdir, readFile } from "fs/promises"; +import { join } from "path"; +import { defaultModelsFolder } from "./lmstudioPaths.js"; + +/** + * Locate the settings.json file of LM Studio. + * + * @param logger - The logger to use. + * @returns A promise that resolves with the path to the settings.json file, or null if it does not + * exist. + */ +export async function locateSettingsJson(logger: SimpleLogger) { + logger.debug("Locating settings.json"); + const lmstudioHome = findLMStudioHome(); + const settingsJsonFilePath = join(lmstudioHome, "settings.json"); + logger.debug("Settings.json file path", settingsJsonFilePath); + try { + await access(settingsJsonFilePath); + return settingsJsonFilePath; + } catch (error) { + logger.debug("settings.json does not exist", error); + return null; + } +} + +/** + * Resolve the path to the models folder. If the settings.json file exists, use the downloadsFolder + * field. Otherwise, fall back to the default models folder. + * + * @param logger - The logger to use. + * @param opts - Options. Set `ensureExists` to `false` to skip creating the folder if it does not + * exist (useful for read-only operations such as listing or removing models). + * @returns A promise that resolves with the path to the models folder. + */ +export async function resolveModelsFolderPath( + logger: SimpleLogger, + { ensureExists = true }: { ensureExists?: boolean } = {}, +) { + const settingsJsonPath = await locateSettingsJson(logger); + let modelsFolderPath = defaultModelsFolder; + if (settingsJsonPath === null) { + logger.warn( + "Could not locate LM Studio configuration file, using default path:", + modelsFolderPath, + ); + } else { + try { + const content = await readFile(settingsJsonPath, "utf8"); + const settings = JSON.parse(content); + modelsFolderPath = settings.downloadsFolder; + if (typeof modelsFolderPath !== "string") { + throw new Error("downloadsFolder is not a string"); + } + } catch (error) { + logger.warn( + "Could not parse LM Studio configuration file, using default path:", + modelsFolderPath, + ); + logger.debug(error); + } + } + if (ensureExists) { + await mkdir(modelsFolderPath, { recursive: true }); + } + return modelsFolderPath; +} diff --git a/src/subcommands/importCmd.ts b/src/subcommands/importCmd.ts index 2ca07e70..fe92b0e4 100644 --- a/src/subcommands/importCmd.ts +++ b/src/subcommands/importCmd.ts @@ -7,18 +7,17 @@ import { type SimpleLogger, text, } from "@lmstudio/lms-common"; -import { findLMStudioHome } from "@lmstudio/lms-common-server"; import { terminalSize } from "@lmstudio/lms-isomorphic"; import chalk from "chalk"; import { existsSync, statSync } from "fs"; -import { access, copyFile, link, mkdir, readFile, rename, symlink } from "fs/promises"; +import { access, copyFile, link, mkdir, rename, symlink } from "fs/promises"; import fuzzy from "fuzzy"; import { homedir } from "os"; import { basename, dirname, join } from "path"; import { z } from "zod"; import { getCliPref } from "../cliPref.js"; -import { defaultModelsFolder } from "../lmstudioPaths.js"; import { addLogLevelOptions, createLogger, type LogLevelArgs } from "../logLevel.js"; +import { resolveModelsFolderPath } from "../modelsFolder.js"; import { runPromptWithExitHandling } from "../prompt.js"; import { fuzzyHighlightOptions, searchTheme } from "../inquirerTheme.js"; @@ -340,62 +339,6 @@ function getUserAppDataPath() { } } -/** - * Locate the settings.json file of LM Studio. - * - * @param logger - The logger to use. - * @returns A promise that resolves with the path to the settings.json file, or null if it does not - * exist. - */ -async function locateSettingsJson(logger: SimpleLogger) { - logger.debug("Locating settings.json"); - const lmstudioHome = findLMStudioHome(); - const settingsJsonFilePath = join(lmstudioHome, "settings.json"); - logger.debug("Settings.json file path", settingsJsonFilePath); - try { - await access(settingsJsonFilePath); - return settingsJsonFilePath; - } catch (error) { - logger.debug("settings.json does not exist", error); - return null; - } -} - -/** - * Resolve the path to the models folder. If the settings.json file exists, use the downloadsFolder - * field. - * - * @param logger - The logger to use. - * @returns A promise that resolves with the path to the models folder. - */ -async function resolveModelsFolderPath(logger: SimpleLogger) { - const settingsJsonPath = await locateSettingsJson(logger); - let modelsFolderPath = defaultModelsFolder; - if (settingsJsonPath === null) { - logger.warn( - "Could not locate LM Studio configuration file, using default path:", - modelsFolderPath, - ); - } else { - try { - const content = await readFile(settingsJsonPath, "utf8"); - const settings = JSON.parse(content); - modelsFolderPath = settings.downloadsFolder; - if (typeof modelsFolderPath !== "string") { - throw new Error("downloadsFolder is not a string"); - } - } catch (error) { - logger.warn( - "Could not parse LM Studio configuration file, using default path:", - modelsFolderPath, - ); - logger.debug(error); - } - } - await mkdir(modelsFolderPath, { recursive: true }); - return modelsFolderPath; -} - /** * Warn the user about moving the file to the models folder if they have not been warned before. * diff --git a/src/subcommands/list.ts b/src/subcommands/list.ts index 86a2be07..7f590d9f 100644 --- a/src/subcommands/list.ts +++ b/src/subcommands/list.ts @@ -40,7 +40,7 @@ function formatModelKeyWithVariantCount(model: ModelInfo) { return `${model.modelKey}${chalk.dim(` (${variantCount} ${variantLabel})`)}`; } -type LoadedModelInfo = { +export type LoadedModelInfo = { modelKey: string; identifier: string; deviceIdentifier: string | null; @@ -74,7 +74,7 @@ function countLoadedOnDevice( ).length; } -function printDownloadedModelsTable( +export function printDownloadedModelsTable( title: string, downloadedModels: Array, loadedModels: Array, diff --git a/src/subcommands/remove.test.ts b/src/subcommands/remove.test.ts new file mode 100644 index 00000000..48f1478a --- /dev/null +++ b/src/subcommands/remove.test.ts @@ -0,0 +1,35 @@ +import { join } from "path"; +import { pathIsAtOrInside } from "./remove.js"; + +describe("remove", () => { + describe("pathIsAtOrInside", () => { + const modelsFolder = join("/home", "user", ".lmstudio", "models"); + + it("returns true when the child is the same as the parent", () => { + expect(pathIsAtOrInside(modelsFolder, modelsFolder)).toBe(true); + }); + + it("returns true for a directly nested path", () => { + expect(pathIsAtOrInside(modelsFolder, join(modelsFolder, "publisher"))).toBe(true); + }); + + it("returns true for a deeply nested path", () => { + expect( + pathIsAtOrInside(modelsFolder, join(modelsFolder, "publisher", "repo", "model.gguf")), + ).toBe(true); + }); + + it("returns false for a sibling whose name shares a prefix", () => { + // "/.../models-backup" must NOT be considered inside "/.../models". + expect(pathIsAtOrInside(modelsFolder, `${modelsFolder}-backup`)).toBe(false); + }); + + it("returns false for a parent of the parent", () => { + expect(pathIsAtOrInside(modelsFolder, join("/home", "user", ".lmstudio"))).toBe(false); + }); + + it("returns false for a completely unrelated path", () => { + expect(pathIsAtOrInside(modelsFolder, join("/tmp", "something"))).toBe(false); + }); + }); +}); diff --git a/src/subcommands/remove.ts b/src/subcommands/remove.ts new file mode 100644 index 00000000..c266557f --- /dev/null +++ b/src/subcommands/remove.ts @@ -0,0 +1,357 @@ +import { Command, type OptionValues } from "@commander-js/extra-typings"; +import { search, select } from "@inquirer/prompts"; +import { makeTitledPrettyError, text } from "@lmstudio/lms-common"; +import { terminalSize } from "@lmstudio/lms-isomorphic"; +import { type EmbeddingModel, type LLM, type LMStudioClient, type ModelInfo } from "@lmstudio/sdk"; +import chalk from "chalk"; +import { readdir, rm, rmdir } from "fs/promises"; +import fuzzy from "fuzzy"; +import { dirname, isAbsolute, join, relative } from "path"; +import { askQuestion } from "../confirm.js"; +import { addCreateClientOptions, createClient, type CreateClientArgs } from "../createClient.js"; +import { createDeviceNameResolver, type DeviceNameResolver } from "../deviceNameLookup.js"; +import { formatSizeBytes1000 } from "../formatBytes.js"; +import { fuzzyHighlightOptions, searchTheme } from "../inquirerTheme.js"; +import { addLogLevelOptions, createLogger, type LogLevelArgs } from "../logLevel.js"; +import { resolveModelsFolderPath } from "../modelsFolder.js"; +import { runPromptWithExitHandling } from "../prompt.js"; +import { printDownloadedModelsTable } from "./list.js"; + +type RemoveCommandOptions = OptionValues & + CreateClientArgs & + LogLevelArgs & { + yes?: boolean; + }; + +const removeCommand = new Command<[], RemoveCommandOptions>() + .name("remove") + .alias("rm") + .description("Remove a downloaded model from disk") + .argument( + "[modelKey]", + text` + The model key of the model to remove. If not provided, you will be prompted to select a model + interactively from a list. Run ${chalk.yellow("lms ls")} to see your downloaded models. + `, + ) + .option( + "-y, --yes", + text` + Skip the confirmation prompt. Deletion is permanent, so use this with caution. + `, + ); + +addCreateClientOptions(removeCommand); +addLogLevelOptions(removeCommand); + +removeCommand.action(async (modelKey, options: RemoveCommandOptions) => { + const logger = createLogger(options); + const { yes = false } = options; + + // `lms remove` deletes files from the local models folder, so it can only operate on the local + // LM Studio installation. If the user points the CLI at a remote instance via --host, the listed + // models live on that machine while deletion would target local paths — refuse instead. + if (options.host !== undefined) { + logger.errorWithoutPrefix( + makeTitledPrettyError( + "Remote Removal Not Supported", + text` + ${chalk.yellow("lms remove")} deletes model files from the local models folder and cannot + remove models from a remote LM Studio instance + (${chalk.yellow(`--host ${options.host}`)}). + + Run ${chalk.yellow("lms remove")} directly on the machine where the model is stored. + `, + ).message, + ); + process.exit(1); + } + + await using client = await createClient(logger, options); + const deviceNameResolver = await createDeviceNameResolver(client, logger); + + // Only models stored on this machine can be removed by deleting files. Remote models (available + // via LM Link on another device) are filtered out. + const downloadedModels = (await client.system.listDownloadedModels()).filter(model => + deviceNameResolver.isLocal(model.deviceIdentifier), + ); + + if (downloadedModels.length === 0) { + logger.errorWithoutPrefix( + makeTitledPrettyError( + "No Models Found", + text` + You don't have any local models to remove. To download one, run: + + ${chalk.yellow("lms get ")} + `, + ).message, + ); + process.exit(1); + } + + // Resolve which downloaded model the user wants to remove. + let baseModel: ModelInfo; + if (modelKey !== undefined) { + const matchingModels = downloadedModels.filter(model => model.modelKey === modelKey); + if (matchingModels.length === 0) { + logger.errorWithoutPrefix( + makeTitledPrettyError( + "Model Not Found", + text` + Cannot find a downloaded model with the key "${chalk.yellow(modelKey)}". + + To see a list of downloaded models, run: + + ${chalk.yellow("lms ls")} + `, + ).message, + ); + process.exit(1); + } + baseModel = matchingModels[0]; + } else { + baseModel = await promptForModel(downloadedModels); + } + + // A model key can have multiple downloaded variants (e.g. different quantizations) that live in + // the same folder. Let the user choose a single variant or the entire model. + const target = await resolveRemovalTarget(client, deviceNameResolver, baseModel); + + const modelsFolderPath = await resolveModelsFolderPath(logger, { ensureExists: false }); + const absolutePath = join(modelsFolderPath, target.path); + + // Refuse to delete a model whose files are currently loaded in memory. + const blockingLoaded = await findLoadedModelsAtPath( + client, + deviceNameResolver, + modelsFolderPath, + absolutePath, + ); + if (blockingLoaded.length > 0) { + const identifiers = blockingLoaded + .map(identifier => ` ${chalk.yellow(identifier)}`) + .join("\n"); + logger.errorWithoutPrefix( + makeTitledPrettyError( + "Model In Use", + text` + This model is currently loaded and cannot be removed: + + ${identifiers} + + Unload it first, then try again: + + ${chalk.yellow("lms unload")} + `, + ).message, + ); + process.exit(1); + } + + // Show what will be removed using the same table format as `lms ls`. + const title = target.type === "embedding" ? "EMBEDDING" : "LLM"; + console.info(); + console.info(chalk.yellow("The following model will be permanently removed:")); + console.info(); + printDownloadedModelsTable(title, [target], [], deviceNameResolver); + console.info(); + console.info(`${chalk.dim("Location:")} ${absolutePath}`); + console.info(); + + if (!yes) { + const confirmed = await askQuestion( + chalk.redBright( + `Permanently delete this model (${formatSizeBytes1000(target.sizeBytes)})? This cannot be undone.`, + ), + ); + if (!confirmed) { + logger.info("Aborted. No models were removed."); + return; + } + } + + await rm(absolutePath, { recursive: true, force: true }); + await pruneEmptyParents(absolutePath, modelsFolderPath); + + logger.info( + `Removed "${target.modelKey}", freeing ${formatSizeBytes1000(target.sizeBytes)} of disk space.`, + ); +}); + +/** + * Prompt the user to interactively select a downloaded model from a fuzzy-searchable list. + * + * @param models - The downloaded models to choose from. + * @returns A promise that resolves with the selected model. + */ +async function promptForModel(models: Array): Promise { + // Used to split the model key from a dimmed, non-searchable description suffix. + const searchDelimiter = ""; + const searchStrings = models.map( + model => + `${model.modelKey}${searchDelimiter} ${formatSizeBytes1000(model.sizeBytes)} · ${model.type}`, + ); + const pageSize = terminalSize().rows - 5; + return await runPromptWithExitHandling(() => + search( + { + message: chalk.green("Select a model to remove") + chalk.dim(" |"), + pageSize, + theme: searchTheme, + source: async (input: string | undefined, { signal }: { signal: AbortSignal }) => { + void signal; + const options = fuzzy.filter(input ?? "", searchStrings, fuzzyHighlightOptions); + return options.map(option => { + const model = models[option.index]; + if (model === undefined) { + throw new Error("Search results returned an invalid model index."); + } + const [keyPart, suffixPart] = option.string.split(searchDelimiter); + return { + value: model, + short: model.modelKey, + name: suffixPart === undefined ? keyPart : `${keyPart}${chalk.dim(suffixPart)}`, + }; + }); + }, + }, + { output: process.stderr }, + ), + ); +} + +/** + * Determine the exact target to remove. If the model has more than one downloaded variant, prompt + * the user to either remove a single variant or the entire model. + * + * @param client - The LM Studio client. + * @param deviceNameResolver - Resolver used to keep only locally-stored variants. + * @param baseModel - The model selected by the user. + * @returns A promise that resolves with the ModelInfo to remove (either a single variant or the + * base model representing all variants). + */ +async function resolveRemovalTarget( + client: LMStudioClient, + deviceNameResolver: DeviceNameResolver, + baseModel: ModelInfo, +): Promise { + if (baseModel.variants === undefined || baseModel.variants.length <= 1) { + return baseModel; + } + // The variant lookup is by model key only, so it can include variants downloaded on LM Link + // peers. Only locally-stored variants can be removed by deleting local files. + const variants = (await client.system.listDownloadedModelVariants(baseModel.modelKey)).filter( + variant => deviceNameResolver.isLocal(variant.deviceIdentifier), + ); + if (variants.length <= 1) { + return baseModel; + } + // `null` represents "remove the entire model" (i.e. the base model / whole folder). + const selected = await runPromptWithExitHandling(() => + select( + { + message: chalk.green( + `"${baseModel.modelKey}" has multiple variants. What do you want to remove?`, + ), + choices: [ + { + name: text` + All variants + ${chalk.dim(`(entire model, ${formatSizeBytes1000(baseModel.sizeBytes)})`)} + `, + value: null, + }, + ...variants.map(variant => ({ + name: `${variant.modelKey} ${chalk.dim(`(${formatSizeBytes1000(variant.sizeBytes)})`)}`, + value: variant, + })), + ], + }, + { output: process.stderr }, + ), + ); + return selected ?? baseModel; +} + +/** + * Find any currently-loaded models whose files live at (or inside) the given path. Used to prevent + * removing a model that is in use. Models loaded on remote devices (via LM Link) are ignored, since + * deleting local files does not affect them. + * + * @param client - The LM Studio client. + * @param deviceNameResolver - Resolver used to tell local models apart from remote ones. + * @param modelsFolderPath - The absolute path to the models folder. + * @param absolutePath - The absolute path that is about to be removed. + * @returns A promise that resolves with the identifiers of the blocking loaded models. + */ +async function findLoadedModelsAtPath( + client: LMStudioClient, + deviceNameResolver: DeviceNameResolver, + modelsFolderPath: string, + absolutePath: string, +): Promise> { + const loadedModels: Array = ( + await Promise.all([client.llm.listLoaded(), client.embedding.listLoaded()]) + ).flat(); + const blockingIdentifiers = await Promise.all( + loadedModels.map(async model => { + const modelInfo = await model.getModelInfo(); + // Only a model loaded on this machine can block deleting the local files. + if (!deviceNameResolver.isLocal(modelInfo.deviceIdentifier ?? null)) { + return null; + } + if (pathIsAtOrInside(absolutePath, join(modelsFolderPath, model.path))) { + return model.identifier; + } + return null; + }), + ); + return blockingIdentifiers.filter((identifier): identifier is string => identifier !== null); +} + +/** + * Remove now-empty parent directories left behind after deleting a model, walking up towards (but + * never removing) the models folder itself. This avoids leaving empty publisher/repo folders. + * + * @param absolutePath - The absolute path that was removed. + * @param modelsFolderPath - The absolute path to the models folder (the boundary to stop at). + */ +async function pruneEmptyParents(absolutePath: string, modelsFolderPath: string): Promise { + let directory = dirname(absolutePath); + while (pathIsAtOrInside(modelsFolderPath, directory) && directory !== modelsFolderPath) { + let entries: Array; + try { + entries = await readdir(directory); + } catch { + break; + } + if (entries.length > 0) { + break; + } + try { + await rmdir(directory); + } catch { + break; + } + directory = dirname(directory); + } +} + +/** + * Determine whether `childPath` is equal to or nested inside `parentPath`. Comparison is done on + * path segments so that, for example, "/models/ab" is not considered inside "/models/a". + * + * @param parentPath - The candidate parent (outer) path. + * @param childPath - The candidate child (inner) path. + * @returns `true` if `childPath` is `parentPath` or is contained within it. + */ +export function pathIsAtOrInside(parentPath: string, childPath: string): boolean { + if (parentPath === childPath) { + return true; + } + const relativePath = relative(parentPath, childPath); + return relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath); +} + +export const remove = removeCommand;