Merge pull request #84 from lmstudio-ai/ryan/service-ctl

Update lms to use LM Studio's new service architecture
This commit is contained in:
ryan-the-crayon
2024-10-15 16:26:31 -04:00
committed by GitHub
3 changed files with 114 additions and 303 deletions

View File

@ -1,34 +1,22 @@
import { SimpleLogger, text } from "@lmstudio/lms-common"; import { apiServerPorts, type SimpleLogger, text } from "@lmstudio/lms-common";
import { LMStudioClient } from "@lmstudio/sdk"; import { LMStudioClient } from "@lmstudio/sdk";
import chalk from "chalk"; import chalk from "chalk";
import { flag, option, optional, string } from "cmd-ts"; import { spawn } from "child_process";
import inquirer from "inquirer"; import { option, optional, string } from "cmd-ts";
import { platform } from "os"; import { readFile } from "fs/promises";
import { clearLine, moveCursor } from "readline"; import { homedir } from "os";
import { getCliPref } from "./cliPref"; import path from "path";
import { type LogLevelArgs, type LogLevelMap } from "./logLevel"; import { type LogLevelArgs } from "./logLevel";
import { import { checkHttpServer } from "./subcommands/server";
checkHttpServer,
getServerConfig,
startServer,
type StartServerOpts,
} from "./subcommands/server";
import { refinedNumber } from "./types/refinedNumber"; import { refinedNumber } from "./types/refinedNumber";
interface AppInstallLocation {
path: string;
argv: Array<string>;
cwd: string;
}
export const createClientArgs = { export const createClientArgs = {
yes: flag({
long: "yes",
short: "y",
description: text`
Suppress all confirmations and warnings. Useful for scripting.
`,
}),
noLaunch: flag({
long: "no-launch",
description: text`
Don't launch LM Studio if it's not running. Have no effect if auto start server is disabled.
`,
}),
host: option({ host: option({
type: optional(string), type: optional(string),
long: "host", long: "host",
@ -48,133 +36,67 @@ export const createClientArgs = {
interface CreateClientArgs { interface CreateClientArgs {
yes?: boolean; yes?: boolean;
noLaunch?: boolean;
host?: string; host?: string;
port?: number; port?: number;
} }
async function maybeTryStartServer(logger: SimpleLogger, startServerOpts: StartServerOpts) { async function isLocalServerAtPortLMStudioServerOrThrow(port: number) {
const { yes } = startServerOpts; const response = await fetch(`http://127.0.0.1:${port}/lmstudio-greeting`);
const pref = await getCliPref(logger); if (response.status !== 200) {
if (pref.get().autoStartServer === undefined && !yes) { throw new Error("Status is not 200.");
process.stderr.write(text`
${"\n"}${chalk.greenBright.underline("Server Auto Start")}
LM Studio needs to be running in server mode to perform this operation.${"\n\n"}
`);
const { cont } = await inquirer.createPromptModule({
output: process.stderr,
})([
{
type: "confirm",
name: "cont",
message: "Do you want to always start the server if it's not running? (will not ask again)",
default: true,
},
]);
if (cont) {
logger.info("lms will automatically start the server if it's not running.");
} else {
logger.info("lms WILL NOT automatically start the server if it's not running.");
}
if (platform() === "win32") {
logger.info(text`
To change this, edit the config file at
${chalk.greenBright("%USERPROFILE%\\.cache\\lm-studio\\.internal\\cli-pref.json")}
`);
} else {
logger.info(text`
To change this, edit the config file at
${chalk.greenBright("~/.cache/lm-studio/.internal/cli-pref.json")}
`);
}
pref.setWithProducer(draft => {
draft.autoStartServer = cont;
});
if (!cont) {
logger.error(text`
To start the server manually, run the following command:
${chalk.yellow("lms server start ")}${"\n"}
`);
return false;
}
logger.info("Starting the server...");
return await startServer(logger, startServerOpts);
} }
if (pref.get().autoStartServer === true) { const json = await response.json();
logger.info("LM Studio is not running in server mode. Starting the server..."); if (json?.lmstudio !== true) {
return await startServer(logger, startServerOpts); throw new Error("Not an LM Studio server.");
} else if (pref.get().autoStartServer === false) {
logger.error("LM Studio needs to be running in the server mode to perform this operation.");
if (platform() === "win32") {
logger.error(text`
To automatically start the server, edit the config file at
${chalk.yellowBright("%USERPROFILE%\\.cache\\lm-studio\\.internal\\cli-pref.json")}
`);
} else {
logger.error(text`
To automatically start the server, edit the config file at
${chalk.yellowBright("~/.cache/lm-studio/.internal/cli-pref.json")}
`);
}
logger.error(text`
To start the server manually, run the following command:
${chalk.yellow("lms server start ")}${"\n"}
`);
return false;
} else {
// If not true or false, it's undefined
// Meaning --yes is used
logger.info(text`
LM Studio is not running in server mode. Starting the server because
${chalk.yellowBright("--yes")} is set
`);
return await startServer(logger, startServerOpts);
} }
return port;
} }
/** async function tryFindLocalAPIServer(): Promise<number | null> {
* Creates a logger that will self delete messages at info level. return await Promise.any(apiServerPorts.map(isLocalServerAtPortLMStudioServerOrThrow)).then(
*/ port => port,
function createSelfDeletingLogger(logger: SimpleLogger, levelMap: LogLevelMap) { () => null,
return new SimpleLogger(
"",
{
debug: levelMap.debug
? (...messages) => {
clearLine(process.stderr, 0);
logger.debug(...messages);
}
: () => {},
info: levelMap.info
? (...messages) => {
clearLine(process.stderr, 0);
logger.info(...messages);
if (!levelMap.debug) {
moveCursor(process.stderr, 0, -1);
}
}
: () => {},
warn: levelMap.warn
? (...messages) => {
clearLine(process.stderr, 0);
logger.warn(...messages);
}
: () => {},
error: levelMap.error
? (...messages) => {
clearLine(process.stderr, 0);
logger.error(...messages);
}
: () => {},
},
{ useLogLevelPrefixes: false },
); );
} }
function getAppInstallLocationPath() {
return path.join(homedir(), ".cache/lm-studio/.internal/app-install-location.json");
}
export async function wakeUpService(logger: SimpleLogger): Promise<boolean> {
logger.info("Waking up LM Studio service...");
const appInstallLocationPath = getAppInstallLocationPath();
logger.debug(`Resolved appInstallLocationPath: ${appInstallLocationPath}`);
try {
const appInstallLocation = JSON.parse(
await readFile(appInstallLocationPath, "utf-8"),
) as AppInstallLocation;
logger.debug(`Read executable pointer:`, appInstallLocation);
const args: Array<string> = [];
const { path, argv, cwd } = appInstallLocation;
if (argv[1] === ".") {
// We are in development environment
args.push(".");
}
// Add the minimized flag
args.push("--minimized");
// Also add the headless flag
args.push("--run-as-service");
logger.debug(`Spawning process:`, { path, args, cwd });
const child = spawn(path, args, { cwd, detached: true, stdio: "ignore" });
child.unref();
logger.debug(`Process spawned`);
return true;
} catch (e) {
logger.debug(`Failed to launch application`, e);
return false;
}
}
export interface CreateClientOpts {} export interface CreateClientOpts {}
export async function createClient( export async function createClient(
@ -182,7 +104,6 @@ export async function createClient(
args: CreateClientArgs & LogLevelArgs, args: CreateClientArgs & LogLevelArgs,
_opts: CreateClientOpts = {}, _opts: CreateClientOpts = {},
) { ) {
const { noLaunch, yes } = args;
let { host, port } = args; let { host, port } = args;
if (host === undefined) { if (host === undefined) {
host = "127.0.0.1"; host = "127.0.0.1";
@ -195,37 +116,52 @@ export async function createClient(
); );
process.exit(1); process.exit(1);
} }
if (port === undefined) { if (port === undefined && host === "127.0.0.1") {
if (host === "127.0.0.1") { // We will now attempt to connect to the local API server.
try { const localPort = await tryFindLocalAPIServer();
const config = await getServerConfig(logger);
port = config.port; if (localPort !== null) {
} catch (e) { const baseUrl = `ws://${host}:${localPort}`;
logger.debug("Failed to get last server status", e); logger.debug(`Found local API server at ${baseUrl}`);
port = 1234; return new LMStudioClient({ baseUrl, logger, clientIdentifier: "lms-cli" });
}
} else {
port = 1234;
} }
// At this point, the user wants to access the local LM Studio, but it is not running. We will
// wake up the service and poll the API server until it is up.
await wakeUpService(logger);
// Polling
for (let i = 1; i <= 60; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
logger.debug(`Polling the API server... (attempt ${i})`);
const localPort = await tryFindLocalAPIServer();
if (localPort !== null) {
const baseUrl = `ws://${host}:${localPort}`;
logger.debug(`Found local API server at ${baseUrl}`);
return new LMStudioClient({ baseUrl, logger, clientIdentifier: "lms-cli" });
}
}
logger.error("");
} }
if (port === undefined) {
port = 1234;
}
logger.debug(`Connecting to server at ${host}:${port}`); logger.debug(`Connecting to server at ${host}:${port}`);
if (!(await checkHttpServer(logger, port, host))) { if (!(await checkHttpServer(logger, port, host))) {
if (host === "127.0.0.1") { logger.error(
if (!(await maybeTryStartServer(logger, { port, noLaunch, yes, useReducedLogging: true }))) { text`
process.exit(1); The server does not appear to be running at ${host}:${port}. Please make sure the server
} is running and accessible at the specified address.
} else { `,
logger.error( );
text`
The server does not appear to be running at ${host}:${port}. Please make sure the server
is running and accessible at the specified address.
`,
);
process.exit(1);
}
} }
const baseUrl = `ws://${host}:${port}`; const baseUrl = `ws://${host}:${port}`;
logger.debug(`Connecting to server with baseUrl ${port}`); logger.debug(`Found server at ${port}`);
return new LMStudioClient({ return new LMStudioClient({
baseUrl, baseUrl,
logger, logger,

View File

@ -1,12 +1,9 @@
import { text, type SimpleLogger } from "@lmstudio/lms-common"; import { text, type SimpleLogger } from "@lmstudio/lms-common";
import chalk from "chalk";
import { spawn } from "child_process";
import { command, flag, number, option, optional, subcommands } from "cmd-ts"; import { command, flag, number, option, optional, subcommands } from "cmd-ts";
import { mkdir, readFile, writeFile } from "fs/promises"; import { mkdir, readFile, writeFile } from "fs/promises";
import inquirer from "inquirer"; import os from "os";
import os, { platform } from "os";
import path from "path"; import path from "path";
import { getCliPref } from "../cliPref"; import { wakeUpService } from "../createClient";
import { createLogger, logLevelArgs } from "../logLevel"; import { createLogger, logLevelArgs } from "../logLevel";
type HttpServerCtl = type HttpServerCtl =
@ -23,12 +20,6 @@ interface HttpServerConfig {
port: number; port: number;
} }
interface AppInstallLocation {
path: string;
argv: Array<string>;
cwd: string;
}
function getServerCtlPath() { function getServerCtlPath() {
return path.join(os.homedir(), ".cache/lm-studio/.internal/http-server-ctl.json"); return path.join(os.homedir(), ".cache/lm-studio/.internal/http-server-ctl.json");
} }
@ -37,10 +28,6 @@ function getServerConfigPath() {
return path.join(os.homedir(), ".cache/lm-studio/.internal/http-server-config.json"); return path.join(os.homedir(), ".cache/lm-studio/.internal/http-server-config.json");
} }
function getAppInstallLocationPath() {
return path.join(os.homedir(), ".cache/lm-studio/.internal/app-install-location.json");
}
/** /**
* Write a control object to the server control file. * Write a control object to the server control file.
*/ */
@ -54,41 +41,6 @@ async function writeToServerCtl(logger: SimpleLogger, controlObject: HttpServerC
await writeFile(serverCtlPath, JSON.stringify(controlObject)); await writeFile(serverCtlPath, JSON.stringify(controlObject));
} }
/**
* Launches the LM Studio application.
*/
async function launchApplication(logger: SimpleLogger): Promise<boolean> {
logger.debug("Launching LM Studio application...");
const appInstallLocationPath = getAppInstallLocationPath();
logger.debug(`Resolved appInstallLocationPath: ${appInstallLocationPath}`);
try {
const appInstallLocation = JSON.parse(
await readFile(appInstallLocationPath, "utf-8"),
) as AppInstallLocation;
logger.debug(`Read executable pointer:`, appInstallLocation);
const args: Array<string> = [];
const { path, argv, cwd } = appInstallLocation;
if (argv[1] === ".") {
// We are in development environment
args.push(".");
}
// Add the minimized flag
args.push("--minimized");
logger.debug(`Spawning process:`, { path, args, cwd });
const child = spawn(path, args, { cwd, detached: true, stdio: "ignore" });
child.unref();
logger.debug(`Process spawned`);
return true;
} catch (e) {
logger.debug(`Failed to launch application`, e);
return false;
}
}
/** /**
* Waits for the server control file to be cleared. * Waits for the server control file to be cleared.
*/ */
@ -169,13 +121,11 @@ export async function getServerConfig(logger: SimpleLogger) {
export interface StartServerOpts { export interface StartServerOpts {
port?: number; port?: number;
cors?: boolean; cors?: boolean;
noLaunch?: boolean;
yes?: boolean;
useReducedLogging?: boolean; useReducedLogging?: boolean;
} }
export async function startServer( export async function startServer(
logger: SimpleLogger, logger: SimpleLogger,
{ port, cors, noLaunch, yes, useReducedLogging }: StartServerOpts = {}, { port, cors, useReducedLogging }: StartServerOpts = {},
): Promise<boolean> { ): Promise<boolean> {
if (port === undefined) { if (port === undefined) {
try { try {
@ -205,70 +155,9 @@ export async function startServer(
`Requested the server to be started on port ${port}.`, `Requested the server to be started on port ${port}.`,
); );
} else { } else {
if (platform() === "linux") { const launched = await wakeUpService(logger);
// Sorry, linux users :(
logger.errorText`
LM Studio is not running. Please start LM Studio and try again.
`;
return false;
}
if (noLaunch) {
logger.errorText`
LM Studio is not running. Since --no-launch is provided, LM Studio will not be launched.
`;
logger.errorText`
The server is not started. Please make sure LM Studio is running and try again.
`;
return false;
}
const cliPref = await getCliPref(logger);
if (!cliPref.get().autoLaunchMinimizedWarned) {
if (yes) {
logger.warn(`Auto-launch warning suppressed by ${chalk.yellowBright("--yes")} flag`);
} else {
process.stderr.write(text`
${"\n"}${chalk.bold.underline.greenBright("About to Launch LM Studio")}
By default, if LM Studio is not running, attempting to start the server will launch LM
Studio in minimized mode and then start the server.
${chalk.grey(text`
If you don't want LM Studio to launch automatically, please use the ${chalk.yellow(
"--no-launch",
)} flag.
`)}
${chalk.gray("This confirmation will not be shown again.")}${"\n\n"}
`);
await inquirer.createPromptModule({
output: process.stderr,
})([
{
type: "input",
name: "confirmation",
message: `Type "${chalk.greenBright("OK")}" to acknowledge:`,
validate: value => {
if (value.toLowerCase() === "ok") {
return true;
}
return 'You need to type "OK" to continue.';
},
},
]);
cliPref.setWithProducer(pref => {
pref.autoLaunchMinimizedWarned = true;
});
}
}
logger.infoText`
Launching LM Studio minimized... (Disable auto-launching via the
${chalk.yellow("--no-launch")} flag.)
`;
const launched = await launchApplication(logger);
if (launched) { if (launched) {
logger.debug(`LM Studio launched`); logger.debug(`LM Studio service is running.`);
// At this point, LM Studio is launching. Once it is ready, it will consume the control file // At this point, LM Studio is launching. Once it is ready, it will consume the control file
// and start the server. Let's wait for that to happen. // and start the server. Let's wait for that to happen.
if (await waitForCtlFileClear(logger, 1000, 10)) { if (await waitForCtlFileClear(logger, 1000, 10)) {
@ -282,8 +171,8 @@ export async function startServer(
} }
} else { } else {
logger.errorText` logger.errorText`
Failed to launch LM Studio. Please make sure it is installed and have run it at least Failed to start LM Studio service. Please make sure it is installed and have run it at
once. least once.
`; `;
return false; return false;
} }
@ -318,19 +207,6 @@ const start = command({
long: "port", long: "port",
short: "p", short: "p",
}), }),
noLaunch: flag({
description: text`
Do not launch LM Studio if it is not running. If LM Studio is not running, the server will
not be started.
`,
long: "no-launch",
}),
yes: flag({
description: text`
Suppress all confirmations and warnings. Useful for scripting.
`,
long: "yes",
}),
cors: flag({ cors: flag({
description: text` description: text`
Enable CORS on the server. Allows any website you visit to access the server. This is Enable CORS on the server. Allows any website you visit to access the server. This is
@ -341,9 +217,9 @@ const start = command({
...logLevelArgs, ...logLevelArgs,
}, },
handler: async args => { handler: async args => {
const { port, noLaunch, cors } = args; const { port, cors } = args;
const logger = createLogger(args); const logger = createLogger(args);
if (!(await startServer(logger, { port, noLaunch, cors }))) { if (!(await startServer(logger, { port, cors }))) {
process.exit(1); process.exit(1);
} }
}, },

View File

@ -2,8 +2,7 @@ import chalk from "chalk";
import { command, flag } from "cmd-ts"; import { command, flag } from "cmd-ts";
function getVersion() { function getVersion() {
// We are not using the package version, because we want the version to be the same as LM Studio. return "<LMS-CLI-CURRENT-VERSION>";
return "0.2.24";
} }
export function printVersion() { export function printVersion() {