import { cp, mkdir, rename, rm, statfs } from "node:fs/promises"; import { cpus, freemem, totalmem, platform, arch, release } from "node:os"; import { basename, resolve, sep } from "node:path"; import { Effect, Schema } from "effect"; import { badRequest, notFound } from "../../core/errors"; import { decodeJsonBody } from "../../core/validation"; import { effectHandler } from "../../http/effect-handler"; import { documentRoute, defineRoutes, mergeRoutes } from "../../http/route-registrar"; import { registerStudioModelIndexRoutes } from "./model-index"; 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_STARTER_PRESETS } from "./configs"; import { getPersistedConfigPath, loadPersistedConfig, savePersistedConfig, type PersistedConfig, } from "../../config/persisted-config"; import { getVllmRuntimeInfo } from "../engines/runtimes/vllm-runtime"; const SettingsUpdateSchema = Schema.Struct({ models_dir: Schema.optional(Schema.NullOr(Schema.String)), ui_preferences: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.String))), }); const ModelDeleteSchema = Schema.Struct({ path: Schema.String }); const ModelMoveSchema = Schema.Struct({ source_path: Schema.String, target_root: Schema.String }); class StudioOperationError extends Schema.TaggedErrorClass()( "StudioOperationError", { operation: Schema.Literals(["disk", "settings", "delete", "move"]), message: Schema.String, source: Schema.optional(Schema.Unknown), }, ) {} interface StudioDiskInfo { path: string; total_bytes: number | null; free_bytes: number | null; available_bytes: number | null; } const diskInfo = (path: string): Effect.Effect => Effect.tryPromise({ try: () => statfs(path), catch: (source) => new StudioOperationError({ operation: "disk", message: "Disk unavailable", source }), }).pipe( Effect.map((stats) => ({ path, total_bytes: stats.blocks * stats.bsize, free_bytes: stats.bfree * stats.bsize, available_bytes: stats.bavail * stats.bsize, })), Effect.catchTag("StudioOperationError", () => Effect.succeed({ path, total_bytes: null, free_bytes: null, available_bytes: null }), ), ); const insideModelsRoot = ( modelsDirectory: string, target: string, label: string, allowRoot = false, ): Effect.Effect> => { const resolved = resolve(target); const modelsRoot = resolve(modelsDirectory); const rootPrefix = modelsRoot.endsWith(sep) ? modelsRoot : `${modelsRoot}${sep}`; return resolved.startsWith(rootPrefix) || (allowRoot && resolved === modelsRoot) ? Effect.succeed(resolved) : Effect.fail(badRequest(`${label} must be inside models_dir`)); }; const pathExists = (path: string): Effect.Effect => Effect.tryPromise({ try: () => statfs(path), catch: (source) => source }).pipe( Effect.as(true), Effect.catch(() => Effect.succeed(false)), ); const normalizedOptionalString = (value: string | null | undefined): string | null | undefined => { if (value === undefined || value === null) return value; const trimmed = value.trim(); return trimmed ? trimmed : null; }; export const deriveRecommendationVramGb = (gpus: GpuInfo[]): number => gpus.reduce((sum, gpu) => sum + gpu.memory_total_mb / 1024, 0); export const registerStudioRoutes = defineRoutes((app, context) => { const buildSettingsPayload = Effect.gen(function* () { const persisted = yield* Effect.try({ try: () => loadPersistedConfig(context.config.data_dir), catch: (source) => new StudioOperationError({ operation: "settings", message: "Could not load settings", source, }), }); const legacyUiPreferences = ( persisted as PersistedConfig & { ui_preferences?: Record } ).ui_preferences; const dbUiPreferences = yield* context.stores.controllerSettingsStore.getUiPreferencesEffect(); const uiPreferences = Object.keys(dbUiPreferences).length > 0 ? dbUiPreferences : legacyUiPreferences && typeof legacyUiPreferences === "object" ? legacyUiPreferences : {}; if (Object.keys(dbUiPreferences).length === 0 && Object.keys(uiPreferences).length > 0) { yield* context.stores.controllerSettingsStore.saveUiPreferencesEffect(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 }, }; }); return mergeRoutes( app.get( "/studio/settings", documentRoute, effectHandler((ctx) => buildSettingsPayload.pipe(Effect.map((payload) => ctx.json(payload)))), ), app.post( "/studio/settings", documentRoute, effectHandler((ctx) => Effect.gen(function* () { const body = yield* decodeJsonBody(ctx, SettingsUpdateSchema); const modelsDirectory = normalizedOptionalString(body.models_dir); const uiPreferences = body.ui_preferences; if (modelsDirectory === undefined && uiPreferences === undefined) { return yield* Effect.fail(badRequest("No supported settings provided")); } const saved = yield* Effect.try({ try: () => modelsDirectory !== undefined ? savePersistedConfig(context.config.data_dir, { models_dir: modelsDirectory }) : loadPersistedConfig(context.config.data_dir), catch: (source) => new StudioOperationError({ operation: "settings", message: "Could not save settings", source, }), }); if (uiPreferences !== undefined) { yield* context.stores.controllerSettingsStore.saveUiPreferencesEffect( uiPreferences ?? {}, ); } if (saved.models_dir) context.config.models_dir = resolve(saved.models_dir); const payload = yield* buildSettingsPayload; return ctx.json({ success: true, ...payload }); }), ), ), app.get( "/studio/diagnostics", documentRoute, effectHandler((ctx) => Effect.gen(function* () { const cpuList = cpus(); const [gpus, runtime, disks] = yield* Effect.all([ getGpuInfo(), getVllmRuntimeInfo(), Effect.all([diskInfo(context.config.data_dir), diskInfo(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: cpuList[0]?.model ?? null, 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", documentRoute, effectHandler((ctx) => Effect.gen(function* () { const directories = yield* discoverModelDirectories([context.config.models_dir], 2, 200); const sizes = yield* Effect.forEach( directories, (directory) => estimateWeightsSizeBytes(directory, false).pipe( Effect.map((size) => size ?? 0), Effect.orElseSucceed(() => 0), ), { concurrency: "unbounded" }, ); return ctx.json({ models_dir: context.config.models_dir, model_count: directories.length, model_bytes: sizes.reduce((total, value) => total + value, 0), disk: yield* diskInfo(context.config.models_dir), }); }), ), ), app.get( "/studio/presets", documentRoute, effectHandler((ctx) => getGpuInfo().pipe( Effect.map((gpus) => { const maxVramGb = deriveRecommendationVramGb(gpus); const appleSilicon = platform() === "darwin" && arch() === "arm64"; const presets = STUDIO_STARTER_PRESETS.filter( (preset) => !appleSilicon || preset.backend !== "vllm", ).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", documentRoute, effectHandler((ctx) => Effect.gen(function* () { const body = yield* decodeJsonBody(ctx, ModelDeleteSchema); if (!body.path.trim()) return yield* Effect.fail(badRequest("path is required")); const target = yield* insideModelsRoot(context.config.models_dir, body.path, "path"); if (!(yield* pathExists(target))) return yield* Effect.fail(notFound("Model path not found")); yield* Effect.tryPromise({ try: () => rm(target, { recursive: true, force: true }), catch: (source) => new StudioOperationError({ operation: "delete", message: "Could not delete model", source, }), }); return ctx.json({ success: true }); }), ), ), app.post( "/studio/models/move", documentRoute, effectHandler((ctx) => Effect.gen(function* () { const body = yield* decodeJsonBody(ctx, ModelMoveSchema); if (!body.source_path.trim() || !body.target_root.trim()) { return yield* Effect.fail(badRequest("source_path and target_root are required")); } const source = yield* insideModelsRoot( context.config.models_dir, body.source_path, "source_path", ); const targetRoot = yield* insideModelsRoot( context.config.models_dir, body.target_root, "target_root", true, ); if (!(yield* pathExists(source))) return yield* Effect.fail(notFound("source_path not found")); yield* Effect.tryPromise({ try: () => mkdir(targetRoot, { recursive: true }), catch: (sourceError) => new StudioOperationError({ operation: "move", message: "Could not create target", source: sourceError, }), }); const target = resolve(targetRoot, basename(source)); if (yield* pathExists(target)) return yield* Effect.fail(badRequest("Target path already exists")); if (source !== target) { yield* Effect.tryPromise({ try: () => rename(source, target), catch: (sourceError) => sourceError, }).pipe( Effect.catch((sourceError) => (sourceError as NodeJS.ErrnoException).code === "EXDEV" ? Effect.tryPromise({ try: () => cp(source, target, { recursive: true, force: false, errorOnExist: true }), catch: (copyError) => copyError, }).pipe( Effect.andThen( Effect.tryPromise({ try: () => rm(source, { recursive: true, force: true }), catch: (removeError) => removeError, }), ), ) : Effect.fail(sourceError), ), Effect.mapError( (sourceError) => new StudioOperationError({ operation: "move", message: "Could not move model", source: sourceError, }), ), ); } return ctx.json({ success: true, target }); }), ), ), registerStudioModelIndexRoutes(app, context), registerStudioProviderRoutes(app, context), registerStudioRigRoutes(app, context), ); });