331 lines · 10.8 KB · TypeScript
RawDownload
import { cpus, freemem, totalmem, platform, arch, release } from "node:os";import {  existsSync,  readdirSync,  rmSync,  renameSync,  mkdirSync,  readFileSync,  writeFileSync,  statfsSync,} from "node:fs";import { basename, resolve, sep } from "node:path";import { badRequest, notFound } from "../../core/errors";import { parseJsonObjectBody } from "../../core/validation";import type { RouteRegistrar } from "../../http/route-registrar";import { registerStudioProviderRoutes } from "./provider-routes";import { registerStudioRigRoutes } from "./rig-routes";import { getGpuInfo } from "../system/platform/gpu";import type { GpuInfo } from "../models/types";import { discoverModelDirectories, estimateWeightsSizeBytes } from "../models/model-browser";import { STUDIO_MODEL_RECOMMENDATIONS, STUDIO_STARTER_PRESETS } from "./configs";import {  getPersistedConfigPath,  loadPersistedConfig,  savePersistedConfig,  type PersistedConfig,} from "../../config/persisted-config";import { getVllmRuntimeInfo } from "../engines/runtimes/vllm-runtime"; const getDiskInfo = (  path: string,): {  path: string;  total_bytes: number | null;  free_bytes: number | null;  available_bytes: number | null;} => {  try {    const stats = statfsSync(path);    const total = stats.blocks * stats.bsize;    const free = stats.bfree * stats.bsize;    const available = stats.bavail * stats.bsize;    return {      path,      total_bytes: total,      free_bytes: free,      available_bytes: available,    };  } catch {    return {      path,      total_bytes: null,      free_bytes: null,      available_bytes: null,    };  }}; const copyDirectory = (source: string, target: string): void => {  const entries = readdirSync(source, { withFileTypes: true });  for (const entry of entries) {    const from = resolve(source, entry.name);    const to = resolve(target, entry.name);    if (entry.isDirectory()) {      if (!existsSync(to)) {        mkdirSync(to, { recursive: true });      }      copyDirectory(from, to);    } else if (entry.isFile()) {      const buffer = readFileSync(from);      writeFileSync(to, buffer);    }  }}; // Resolve a caller-supplied path and confirm it is inside models_dir, defeating// `../` traversal. `allowRoot` permits the models_dir itself (move targets).const resolveInsideModelsRoot = (  modelsDirectory: string,  target: string,  label: string,  allowRoot = false,): string => {  const resolved = resolve(target);  const modelsRoot = resolve(modelsDirectory);  const rootPrefix = modelsRoot.endsWith(sep) ? modelsRoot : modelsRoot + sep;  if (!resolved.startsWith(rootPrefix) && !(allowRoot && resolved === modelsRoot)) {    throw badRequest(`${label} must be inside models_dir`);  }  return resolved;}; export const deriveRecommendationVramGb = (gpus: GpuInfo[]): number =>  gpus.reduce((sum, gpu) => sum + gpu.memory_total_mb / 1024, 0); const parseOptionalStringUpdate = (value: unknown): string | null | undefined => {  if (value === undefined) return undefined;  if (value === null) return null;  if (typeof value !== "string") {    throw badRequest("Expected string or null");  }  const trimmed = value.trim();  return trimmed.length > 0 ? trimmed : null;}; const parseUiPreferencesUpdate = (value: unknown): Record<string, string> | null | undefined => {  if (value === undefined) return undefined;  if (value === null) return null;  if (!value || typeof value !== "object" || Array.isArray(value)) {    throw badRequest("ui_preferences must be an object or null");  }  const clean: Record<string, string> = {};  for (const [key, entry] of Object.entries(value)) {    if (!key) continue;    if (typeof entry !== "string") {      throw badRequest("ui_preferences values must be strings");    }    clean[key] = entry;  }  return clean;}; export const registerStudioRoutes: RouteRegistrar = (app, context) => {  const buildSettingsPayload = (): {    config_path: string;    persisted: {      models_dir: string | undefined;      ui_preferences: Record<string, string>;    };    effective: {      models_dir: string;    };  } => {    const persisted = loadPersistedConfig(context.config.data_dir);    const legacyUiPreferences = (      persisted as PersistedConfig & { ui_preferences?: Record<string, string> }    ).ui_preferences;    const dbUiPreferences = context.stores.controllerSettingsStore.getUiPreferences();    const uiPreferences =      Object.keys(dbUiPreferences).length > 0        ? dbUiPreferences        : legacyUiPreferences && typeof legacyUiPreferences === "object"          ? legacyUiPreferences          : {};    if (Object.keys(dbUiPreferences).length === 0 && Object.keys(uiPreferences).length > 0) {      context.stores.controllerSettingsStore.saveUiPreferences(uiPreferences);    }    return {      config_path: getPersistedConfigPath(context.config.data_dir),      persisted: {        models_dir: persisted.models_dir,        ui_preferences: uiPreferences,      },      effective: {        models_dir: context.config.models_dir,      },    };  };   app.get("/studio/settings", async (ctx) => {    return ctx.json(buildSettingsPayload());  });   app.post("/studio/settings", async (ctx) => {    const body = await parseJsonObjectBody(ctx);     const modelsDirectory = parseOptionalStringUpdate(body["models_dir"]);    const uiPreferences = parseUiPreferencesUpdate(body["ui_preferences"]);     const hasAnyUpdate = modelsDirectory !== undefined || uiPreferences !== undefined;     if (!hasAnyUpdate) {      throw badRequest("No supported settings provided");    }     const saved =      modelsDirectory !== undefined        ? savePersistedConfig(context.config.data_dir, { models_dir: modelsDirectory })        : loadPersistedConfig(context.config.data_dir);     if (uiPreferences !== undefined) {      context.stores.controllerSettingsStore.saveUiPreferences(uiPreferences ?? {});    }     if (saved.models_dir) {      context.config.models_dir = resolve(saved.models_dir);    }     return ctx.json({      success: true,      ...buildSettingsPayload(),    });  });   app.get("/studio/diagnostics", async (ctx) => {    const cpuList = cpus();    const cpuModel = cpuList[0]?.model ?? null;    const gpus = getGpuInfo();    const runtime = await getVllmRuntimeInfo();    const disks = [getDiskInfo(context.config.data_dir), getDiskInfo(context.config.models_dir)];    return ctx.json({      app_version: process.env["LOCAL_STUDIO_VERSION"] ?? "dev",      timestamp: new Date().toISOString(),      platform: platform(),      arch: arch(),      release: release(),      cpu_model: cpuModel,      cpu_cores: cpuList.length,      memory_total: totalmem(),      memory_free: freemem(),      gpus,      runtime: {        vllm_installed: runtime.installed,        vllm_version: runtime.version,        python_path: runtime.python_path,        vllm_bin: runtime.vllm_bin,      },      disks,      config: {        host: context.config.host,        port: context.config.port,        inference_port: context.config.inference_port,        api_key_configured: Boolean(context.config.api_key),        models_dir: context.config.models_dir,        data_dir: context.config.data_dir,        db_path: context.config.db_path,        sglang_python: context.config.sglang_python ?? null,        llama_bin: context.config.llama_bin ?? null,        mlx_python: context.config.mlx_python ?? null,      },    });  });   app.get("/studio/storage", async (ctx) => {    const modelRoots = [context.config.models_dir];    const directories = discoverModelDirectories(modelRoots, 2, 200);    const sizes = directories.map((directory) => estimateWeightsSizeBytes(directory, false) ?? 0);    const totalModelBytes = sizes.reduce((total, value) => total + value, 0);    return ctx.json({      models_dir: context.config.models_dir,      model_count: directories.length,      model_bytes: totalModelBytes,      disk: getDiskInfo(context.config.models_dir),    });  });   app.get("/studio/recommendations", async (ctx) => {    const gpus = getGpuInfo();    const maxVramGb = deriveRecommendationVramGb(gpus);    const recommendations = STUDIO_MODEL_RECOMMENDATIONS.filter((model) => {      if (!model.min_vram_gb) return true;      if (maxVramGb === 0) {        return model.min_vram_gb <= 8;      }      return model.min_vram_gb <= maxVramGb;    });    return ctx.json({ recommendations, max_vram_gb: maxVramGb });  });   app.get("/studio/presets", async (ctx) => {    const gpus = getGpuInfo();    const maxVramGb = deriveRecommendationVramGb(gpus);    const presets = STUDIO_STARTER_PRESETS.map((preset) => ({      ...preset,      fits: preset.min_vram_gb === null || maxVramGb === 0 || preset.min_vram_gb <= maxVramGb,    }));    return ctx.json({ presets, max_vram_gb: maxVramGb });  });   app.post("/studio/models/delete", async (ctx) => {    const body = await parseJsonObjectBody(ctx);    const target = typeof body["path"] === "string" ? body["path"] : "";    if (!target) {      throw badRequest("path is required");    }    const resolved = resolveInsideModelsRoot(context.config.models_dir, target, "path");    if (!existsSync(resolved)) {      throw notFound("Model path not found");    }    rmSync(resolved, { recursive: true, force: true });    return ctx.json({ success: true });  });   app.post("/studio/models/move", async (ctx) => {    const body = await parseJsonObjectBody(ctx);    const source = typeof body["source_path"] === "string" ? body["source_path"] : "";    const targetRoot = typeof body["target_root"] === "string" ? body["target_root"] : "";    if (!source || !targetRoot) {      throw badRequest("source_path and target_root are required");    }    const resolvedSource = resolveInsideModelsRoot(      context.config.models_dir,      source,      "source_path",    );    const resolvedTargetRoot = resolveInsideModelsRoot(      context.config.models_dir,      targetRoot,      "target_root",      true,    );    if (!existsSync(resolvedSource)) {      throw notFound("source_path not found");    }    if (!existsSync(resolvedTargetRoot)) {      mkdirSync(resolvedTargetRoot, { recursive: true });    }    const target = resolve(resolvedTargetRoot, basename(resolvedSource));    if (existsSync(target)) {      throw badRequest("Target path already exists");    }    if (resolvedSource === target) {      return ctx.json({ success: true, target });    }    try {      renameSync(resolvedSource, target);    } catch (error) {      if ((error as NodeJS.ErrnoException).code === "EXDEV") {        mkdirSync(target, { recursive: true });        copyDirectory(resolvedSource, target);        rmSync(resolvedSource, { recursive: true, force: true });      } else {        throw error;      }    }    return ctx.json({ success: true, target });  });   registerStudioProviderRoutes(app, context);  registerStudioRigRoutes(app, context);};