initial
Some checks failed
CI / Type Check & Lint (push) Failing after 5s
CI / Build Test (Current Platform) (push) Failing after 5s
CI / Build All Platforms (push) Successful in 49s

This commit is contained in:
2026-01-30 03:16:57 +00:00
commit daaf6559e3
80 changed files with 14430 additions and 0 deletions

314
ts/cli/config-handler.ts Normal file
View File

@@ -0,0 +1,314 @@
/**
* Config Handler
*
* CLI commands for configuration management.
*/
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { PATHS } from '../constants.ts';
import type { IModelGridConfig } from '../interfaces/config.ts';
import type { ITableColumn } from '../logger.ts';
import * as fs from 'node:fs/promises';
/**
* Handler for configuration-related CLI commands
*/
export class ConfigHandler {
/**
* Show current configuration
*/
public async show(): Promise<void> {
logger.log('');
try {
const configPath = PATHS.CONFIG_FILE;
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as IModelGridConfig;
// Overview
logger.logBox(
'ModelGrid Configuration',
[
`Version: ${theme.highlight(config.version)}`,
`Check Interval: ${theme.info(String(config.checkInterval / 1000))} seconds`,
'',
theme.dim('Configuration File:'),
` ${theme.path(configPath)}`,
],
60,
'info',
);
// API Configuration
logger.log('');
logger.logBox(
'API Server',
[
`Host: ${theme.info(config.api.host)}`,
`Port: ${theme.highlight(String(config.api.port))}`,
`API Keys: ${config.api.apiKeys.length} configured`,
...(config.api.rateLimit
? [`Rate Limit: ${config.api.rateLimit} req/min`]
: []),
'',
theme.dim('Endpoint:'),
` http://${config.api.host}:${config.api.port}/v1/chat/completions`,
],
60,
'info',
);
// Docker Configuration
logger.log('');
logger.logBox(
'Docker',
[
`Runtime: ${theme.info(config.docker.runtime)}`,
`Network: ${config.docker.networkName}`,
],
60,
'default',
);
// GPU Configuration
logger.log('');
logger.logBox(
'GPU',
[
`Auto Detect: ${config.gpus.autoDetect ? theme.success('Yes') : theme.dim('No')}`,
`Assignments: ${Object.keys(config.gpus.assignments).length} GPU(s)`,
],
60,
'default',
);
// Model Configuration
logger.log('');
logger.logBox(
'Models',
[
`Auto Pull: ${config.models.autoPull ? theme.success('Enabled') : theme.dim('Disabled')}`,
`Default Container: ${config.models.defaultContainer}`,
`Auto Load: ${config.models.autoLoad.length} model(s)`,
'',
theme.dim('Greenlist URL:'),
` ${config.models.greenlistUrl}`,
],
70,
'default',
);
// Containers
if (config.containers.length > 0) {
logger.log('');
logger.info(`Containers (${config.containers.length}):`);
logger.log('');
const rows = config.containers.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
image: c.image.length > 40 ? c.image.substring(0, 37) + '...' : c.image,
port: c.port,
gpus: c.gpuIds.length > 0 ? c.gpuIds.join(',') : theme.dim('None'),
}));
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Type', key: 'type', align: 'left' },
{ header: 'Image', key: 'image', align: 'left', color: theme.dim },
{ header: 'Port', key: 'port', align: 'right' },
{ header: 'GPUs', key: 'gpus', align: 'left' },
];
logger.logTable(columns, rows);
}
logger.log('');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
logger.logBox(
'No Configuration',
[
'No configuration file found.',
'',
theme.dim('Create configuration with:'),
` ${theme.command('modelgrid service enable')}`,
'',
theme.dim('Or manually create:'),
` ${PATHS.CONFIG_FILE}`,
],
60,
'warning',
);
} else {
logger.error(`Failed to read configuration: ${(error as Error).message}`);
}
}
}
/**
* Initialize default configuration
*/
public async init(): Promise<void> {
const configPath = PATHS.CONFIG_FILE;
// Check if config already exists
try {
await fs.access(configPath);
logger.warn('Configuration file already exists');
logger.dim(` ${configPath}`);
return;
} catch {
// File doesn't exist, continue
}
// Create config directory
const configDir = PATHS.CONFIG_DIR;
await fs.mkdir(configDir, { recursive: true });
// Create default config
const defaultConfig: IModelGridConfig = {
version: '1.0.0',
api: {
port: 8080,
host: '0.0.0.0',
apiKeys: [],
cors: true,
corsOrigins: ['*'],
},
docker: {
networkName: 'modelgrid',
runtime: 'docker',
},
gpus: {
autoDetect: true,
assignments: {},
},
containers: [],
models: {
greenlistUrl: 'https://code.foss.global/modelgrid.com/model_lists/raw/branch/main/greenlit.json',
autoPull: true,
defaultContainer: 'ollama',
autoLoad: [],
},
checkInterval: 30000,
};
await fs.writeFile(configPath, JSON.stringify(defaultConfig, null, 2));
logger.success('Configuration initialized');
logger.dim(` ${configPath}`);
}
/**
* Add an API key
*/
public async addApiKey(key?: string): Promise<void> {
const configPath = PATHS.CONFIG_FILE;
try {
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as IModelGridConfig;
// Generate key if not provided
const apiKey = key || this.generateApiKey();
if (config.api.apiKeys.includes(apiKey)) {
logger.warn('API key already exists');
return;
}
config.api.apiKeys.push(apiKey);
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
logger.success('API key added:');
logger.log(` ${theme.highlight(apiKey)}`);
logger.log('');
logger.dim('Use with Authorization header:');
logger.dim(` curl -H "Authorization: Bearer ${apiKey}" ...`);
} catch (error) {
logger.error(`Failed to add API key: ${(error as Error).message}`);
}
}
/**
* Remove an API key
*/
public async removeApiKey(key: string): Promise<void> {
if (!key) {
logger.error('API key is required');
return;
}
const configPath = PATHS.CONFIG_FILE;
try {
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as IModelGridConfig;
const index = config.api.apiKeys.indexOf(key);
if (index === -1) {
logger.warn('API key not found');
return;
}
config.api.apiKeys.splice(index, 1);
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
logger.success('API key removed');
} catch (error) {
logger.error(`Failed to remove API key: ${(error as Error).message}`);
}
}
/**
* List API keys
*/
public async listApiKeys(): Promise<void> {
const configPath = PATHS.CONFIG_FILE;
try {
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as IModelGridConfig;
if (config.api.apiKeys.length === 0) {
logger.warn('No API keys configured');
logger.dim('Add a key with: modelgrid config apikey add');
return;
}
logger.info(`API Keys (${config.api.apiKeys.length}):`);
logger.log('');
for (const key of config.api.apiKeys) {
// Show partial key for security
const masked = key.substring(0, 8) + '...' + key.substring(key.length - 4);
logger.log(` ${masked}`);
}
logger.log('');
} catch (error) {
logger.error(`Failed to list API keys: ${(error as Error).message}`);
}
}
/**
* Generate a random API key
*/
private generateApiKey(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const length = 48;
let key = 'sk-';
for (let i = 0; i < length; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
return key;
}
}

317
ts/cli/container-handler.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* Container Handler
*
* CLI commands for container management.
*/
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { ContainerManager } from '../containers/container-manager.ts';
import { DockerManager } from '../docker/docker-manager.ts';
import type { IContainerConfig } from '../interfaces/container.ts';
import type { ITableColumn } from '../logger.ts';
import * as helpers from '../helpers/index.ts';
/**
* Handler for container-related CLI commands
*/
export class ContainerHandler {
private containerManager: ContainerManager;
private dockerManager: DockerManager;
constructor(containerManager: ContainerManager) {
this.containerManager = containerManager;
this.dockerManager = new DockerManager();
}
/**
* List all configured containers
*/
public async list(): Promise<void> {
logger.log('');
logger.info('Containers');
logger.log('');
const containers = this.containerManager.getAllContainers();
if (containers.length === 0) {
logger.logBox(
'No Containers',
[
'No containers are configured.',
'',
theme.dim('Add a container with:'),
` ${theme.command('modelgrid container add')}`,
],
60,
'warning',
);
return;
}
const rows = [];
for (const container of containers) {
const status = await container.getStatus();
const config = container.getConfig();
rows.push({
id: config.id,
name: config.name,
type: this.formatContainerType(container.type),
status: status.running
? theme.success('Running')
: theme.dim('Stopped'),
health: status.running
? this.formatHealth(status.health)
: theme.dim('N/A'),
port: config.externalPort || config.port,
models: status.loadedModels.length,
gpus: config.gpuIds.length > 0 ? config.gpuIds.join(',') : theme.dim('None'),
});
}
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Type', key: 'type', align: 'left' },
{ header: 'Status', key: 'status', align: 'left' },
{ header: 'Health', key: 'health', align: 'left' },
{ header: 'Port', key: 'port', align: 'right', color: theme.info },
{ header: 'Models', key: 'models', align: 'right' },
{ header: 'GPUs', key: 'gpus', align: 'left' },
];
logger.logTable(columns, rows);
logger.log('');
}
/**
* Add a new container interactively
*/
public async add(): Promise<void> {
const { prompt, close, select } = await helpers.createPrompt();
try {
logger.log('');
logger.highlight('Add Container');
logger.dim('Configure a new AI model container');
logger.log('');
// Select container type
const typeIndex = await select('Select container type:', [
'Ollama - Easy to use, good for local models',
'vLLM - High performance, OpenAI compatible',
'TGI - HuggingFace Text Generation Inference',
]);
const types = ['ollama', 'vllm', 'tgi'] as const;
const containerType = types[typeIndex];
// Container name
const name = await prompt('Container name: ');
if (!name.trim()) {
logger.error('Container name is required');
return;
}
// Generate ID from name
const id = name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Port
const defaultPorts = { ollama: 11434, vllm: 8000, tgi: 8080 };
const portStr = await prompt(`Port [${defaultPorts[containerType]}]: `);
const port = portStr ? parseInt(portStr, 10) : defaultPorts[containerType];
// GPU assignment
const gpuStr = await prompt('GPU IDs (comma-separated, or "all", or empty for none): ');
let gpuIds: string[] = [];
if (gpuStr.trim().toLowerCase() === 'all') {
const { GpuDetector } = await import('../hardware/gpu-detector.ts');
const detector = new GpuDetector();
const gpus = await detector.detectGpus();
gpuIds = gpus.map((g) => g.id);
} else if (gpuStr.trim()) {
gpuIds = gpuStr.split(',').map((s) => s.trim());
}
// Build config
const config: IContainerConfig = {
id,
type: containerType,
name,
image: this.getDefaultImage(containerType),
port,
gpuIds,
models: [],
};
// Add container
await this.containerManager.addContainer(config);
logger.log('');
logger.success(`Container "${name}" added successfully`);
logger.log('');
logger.dim('Start the container with:');
logger.log(` ${theme.command(`modelgrid container start ${id}`)}`);
logger.log('');
} finally {
close();
}
}
/**
* Remove a container
*/
public async remove(containerId: string): Promise<void> {
if (!containerId) {
logger.error('Container ID is required');
return;
}
const { prompt, close } = await helpers.createPrompt();
try {
const confirm = await prompt(`Remove container "${containerId}"? (y/N): `);
if (confirm.toLowerCase() !== 'y') {
logger.log('Aborted');
return;
}
const success = await this.containerManager.removeContainer(containerId);
if (success) {
logger.success(`Container "${containerId}" removed`);
} else {
logger.error(`Failed to remove container "${containerId}"`);
}
} finally {
close();
}
}
/**
* Start a container
*/
public async start(containerId?: string): Promise<void> {
if (containerId) {
// Start specific container
const container = this.containerManager.getContainer(containerId);
if (!container) {
logger.error(`Container "${containerId}" not found`);
return;
}
logger.info(`Starting container "${containerId}"...`);
const success = await container.start();
if (success) {
logger.success(`Container "${containerId}" started`);
} else {
logger.error(`Failed to start container "${containerId}"`);
}
} else {
// Start all containers
logger.info('Starting all containers...');
await this.containerManager.startAll();
logger.success('All containers started');
}
}
/**
* Stop a container
*/
public async stop(containerId?: string): Promise<void> {
if (containerId) {
// Stop specific container
const container = this.containerManager.getContainer(containerId);
if (!container) {
logger.error(`Container "${containerId}" not found`);
return;
}
logger.info(`Stopping container "${containerId}"...`);
const success = await container.stop();
if (success) {
logger.success(`Container "${containerId}" stopped`);
} else {
logger.error(`Failed to stop container "${containerId}"`);
}
} else {
// Stop all containers
logger.info('Stopping all containers...');
await this.containerManager.stopAll();
logger.success('All containers stopped');
}
}
/**
* Show container logs
*/
public async logs(containerId: string, lines: number = 100): Promise<void> {
if (!containerId) {
logger.error('Container ID is required');
return;
}
const container = this.containerManager.getContainer(containerId);
if (!container) {
logger.error(`Container "${containerId}" not found`);
return;
}
const logs = await container.getLogs(lines);
console.log(logs);
}
/**
* Format container type for display
*/
private formatContainerType(type: string): string {
switch (type) {
case 'ollama':
return theme.containerOllama('Ollama');
case 'vllm':
return theme.containerVllm('vLLM');
case 'tgi':
return theme.containerTgi('TGI');
default:
return type;
}
}
/**
* Format health status
*/
private formatHealth(health: string): string {
switch (health) {
case 'healthy':
return theme.success('Healthy');
case 'unhealthy':
return theme.error('Unhealthy');
case 'starting':
return theme.warning('Starting');
default:
return theme.dim(health);
}
}
/**
* Get default image for container type
*/
private getDefaultImage(type: string): string {
switch (type) {
case 'ollama':
return 'ollama/ollama:latest';
case 'vllm':
return 'vllm/vllm-openai:latest';
case 'tgi':
return 'ghcr.io/huggingface/text-generation-inference:latest';
default:
return '';
}
}
}

255
ts/cli/gpu-handler.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* GPU Handler
*
* CLI commands for GPU management.
*/
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { GpuDetector } from '../hardware/gpu-detector.ts';
import { SystemInfo } from '../hardware/system-info.ts';
import { DriverManager } from '../drivers/driver-manager.ts';
import type { ITableColumn } from '../logger.ts';
/**
* Handler for GPU-related CLI commands
*/
export class GpuHandler {
private gpuDetector: GpuDetector;
private systemInfo: SystemInfo;
private driverManager: DriverManager;
constructor() {
this.gpuDetector = new GpuDetector();
this.systemInfo = new SystemInfo();
this.driverManager = new DriverManager();
}
/**
* List detected GPUs
*/
public async list(): Promise<void> {
logger.log('');
logger.info('Detecting GPUs...');
logger.log('');
const gpus = await this.gpuDetector.detectGpus();
if (gpus.length === 0) {
logger.logBox(
'No GPUs Detected',
[
'No GPUs were found on this system.',
'',
theme.dim('Possible reasons:'),
' - No discrete GPU installed',
' - GPU drivers not installed',
' - GPU not properly connected',
],
60,
'warning',
);
return;
}
const rows = gpus.map((gpu) => ({
id: gpu.id,
vendor: this.formatVendor(gpu.vendor),
model: gpu.model,
vram: `${Math.round(gpu.vram / 1024)} GB`,
driver: gpu.driverVersion || theme.dim('N/A'),
cuda: gpu.cudaVersion || theme.dim('N/A'),
pci: gpu.pciSlot,
}));
const columns: ITableColumn[] = [
{ header: 'ID', key: 'id', align: 'left' },
{ header: 'Vendor', key: 'vendor', align: 'left' },
{ header: 'Model', key: 'model', align: 'left', color: theme.highlight },
{ header: 'VRAM', key: 'vram', align: 'right', color: theme.info },
{ header: 'Driver', key: 'driver', align: 'left' },
{ header: 'CUDA', key: 'cuda', align: 'left' },
{ header: 'PCI', key: 'pci', align: 'left', color: theme.dim },
];
logger.info(`Found ${gpus.length} GPU(s):`);
logger.log('');
logger.logTable(columns, rows);
logger.log('');
}
/**
* Show GPU status and utilization
*/
public async status(): Promise<void> {
logger.log('');
logger.info('GPU Status');
logger.log('');
const gpuStatus = await this.gpuDetector.getGpuStatus();
if (gpuStatus.length === 0) {
logger.warn('No GPUs detected');
return;
}
for (const gpu of gpuStatus) {
const utilizationBar = this.createProgressBar(gpu.utilization, 30);
const memoryBar = this.createProgressBar(gpu.memoryUsed / gpu.memoryTotal * 100, 30);
logger.logBoxTitle(`GPU ${gpu.id}: ${gpu.name}`, 70, 'info');
logger.logBoxLine(`Utilization: ${utilizationBar} ${gpu.utilization.toFixed(1)}%`);
logger.logBoxLine(`Memory: ${memoryBar} ${Math.round(gpu.memoryUsed)}/${Math.round(gpu.memoryTotal)} MB`);
logger.logBoxLine(`Temperature: ${this.formatTemperature(gpu.temperature)}`);
logger.logBoxLine(`Power: ${gpu.powerDraw.toFixed(0)}W / ${gpu.powerLimit.toFixed(0)}W`);
logger.logBoxEnd();
logger.log('');
}
}
/**
* Check and install GPU drivers
*/
public async drivers(): Promise<void> {
logger.log('');
logger.info('GPU Driver Status');
logger.log('');
// Get system info first
const sysInfo = await this.systemInfo.getSystemInfo();
// Detect GPUs
const gpus = await this.gpuDetector.detectGpus();
if (gpus.length === 0) {
logger.warn('No GPUs detected');
return;
}
// Check driver status for each vendor
const vendors = new Set(gpus.map((g) => g.vendor));
for (const vendor of vendors) {
const driver = this.driverManager.getDriver(vendor);
if (!driver) {
logger.warn(`No driver support for ${vendor}`);
continue;
}
const status = await driver.getStatus();
logger.logBoxTitle(`${this.formatVendor(vendor)} Driver`, 60, status.installed ? 'success' : 'warning');
logger.logBoxLine(`Installed: ${status.installed ? theme.success('Yes') : theme.error('No')}`);
if (status.installed) {
logger.logBoxLine(`Version: ${status.version || 'Unknown'}`);
logger.logBoxLine(`Runtime: ${status.runtimeVersion || 'Unknown'}`);
logger.logBoxLine(`Container Support: ${status.containerSupport ? theme.success('Yes') : theme.warning('No')}`);
} else {
logger.logBoxLine('');
logger.logBoxLine(theme.dim('Run `modelgrid gpu install` to install drivers'));
}
logger.logBoxEnd();
logger.log('');
}
}
/**
* Install GPU drivers
*/
public async install(): Promise<void> {
logger.log('');
logger.info('Installing GPU Drivers');
logger.log('');
// Detect GPUs
const gpus = await this.gpuDetector.detectGpus();
if (gpus.length === 0) {
logger.error('No GPUs detected - cannot install drivers');
return;
}
// Install drivers for each vendor
const vendors = new Set(gpus.map((g) => g.vendor));
for (const vendor of vendors) {
const driver = this.driverManager.getDriver(vendor);
if (!driver) {
logger.warn(`No driver installer for ${vendor}`);
continue;
}
logger.info(`Installing ${this.formatVendor(vendor)} drivers...`);
const success = await driver.install();
if (success) {
logger.success(`${this.formatVendor(vendor)} drivers installed successfully`);
// Setup container support
logger.info('Setting up container support...');
const containerSuccess = await driver.setupContainer();
if (containerSuccess) {
logger.success('Container support configured');
} else {
logger.warn('Container support setup failed - GPU passthrough may not work');
}
} else {
logger.error(`Failed to install ${this.formatVendor(vendor)} drivers`);
}
logger.log('');
}
}
/**
* Format vendor name for display
*/
private formatVendor(vendor: string): string {
switch (vendor) {
case 'nvidia':
return theme.gpuNvidia('NVIDIA');
case 'amd':
return theme.gpuAmd('AMD');
case 'intel':
return theme.gpuIntel('Intel');
default:
return vendor;
}
}
/**
* Create a progress bar
*/
private createProgressBar(percent: number, width: number): string {
const filled = Math.round((percent / 100) * width);
const empty = width - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
if (percent >= 90) {
return theme.error(bar);
} else if (percent >= 70) {
return theme.warning(bar);
} else {
return theme.success(bar);
}
}
/**
* Format temperature with color coding
*/
private formatTemperature(temp: number): string {
const tempStr = `${temp}°C`;
if (temp >= 85) {
return theme.error(tempStr);
} else if (temp >= 70) {
return theme.warning(tempStr);
} else {
return theme.success(tempStr);
}
}
}

202
ts/cli/model-handler.ts Normal file
View File

@@ -0,0 +1,202 @@
/**
* Model Handler
*
* CLI commands for model management.
*/
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { ContainerManager } from '../containers/container-manager.ts';
import { ModelRegistry } from '../models/registry.ts';
import { ModelLoader } from '../models/loader.ts';
import type { ITableColumn } from '../logger.ts';
/**
* Handler for model-related CLI commands
*/
export class ModelHandler {
private containerManager: ContainerManager;
private modelRegistry: ModelRegistry;
private modelLoader: ModelLoader;
constructor(
containerManager: ContainerManager,
modelRegistry: ModelRegistry,
) {
this.containerManager = containerManager;
this.modelRegistry = modelRegistry;
this.modelLoader = new ModelLoader(modelRegistry, containerManager);
}
/**
* List all available models
*/
public async list(): Promise<void> {
logger.log('');
logger.info('Models');
logger.log('');
// Get loaded models from containers
const loadedModels = await this.containerManager.getAllAvailableModels();
// Get greenlit models
const greenlitModels = await this.modelRegistry.getAllGreenlitModels();
if (loadedModels.size === 0 && greenlitModels.length === 0) {
logger.logBox(
'No Models',
[
'No models are loaded or greenlit.',
'',
theme.dim('Pull a model with:'),
` ${theme.command('modelgrid model pull <name>')}`,
],
60,
'warning',
);
return;
}
// Show loaded models
if (loadedModels.size > 0) {
logger.info(`Loaded Models (${loadedModels.size}):`);
logger.log('');
const rows = [];
for (const [name, info] of loadedModels) {
rows.push({
name,
container: info.container,
size: info.size ? this.formatSize(info.size) : theme.dim('N/A'),
format: info.format || theme.dim('N/A'),
modified: info.modifiedAt
? new Date(info.modifiedAt).toLocaleDateString()
: theme.dim('N/A'),
});
}
const columns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left', color: theme.highlight },
{ header: 'Container', key: 'container', align: 'left' },
{ header: 'Size', key: 'size', align: 'right', color: theme.info },
{ header: 'Format', key: 'format', align: 'left' },
{ header: 'Modified', key: 'modified', align: 'left', color: theme.dim },
];
logger.logTable(columns, rows);
logger.log('');
}
// Show greenlit models (not yet loaded)
const loadedNames = new Set(loadedModels.keys());
const unloadedGreenlit = greenlitModels.filter((m) => !loadedNames.has(m.name));
if (unloadedGreenlit.length > 0) {
logger.info(`Available to Pull (${unloadedGreenlit.length}):`);
logger.log('');
const rows = unloadedGreenlit.map((m) => ({
name: m.name,
container: m.container,
vram: `${m.minVram} GB`,
tags: m.tags?.join(', ') || theme.dim('None'),
}));
const columns: ITableColumn[] = [
{ header: 'Name', key: 'name', align: 'left' },
{ header: 'Container', key: 'container', align: 'left' },
{ header: 'Min VRAM', key: 'vram', align: 'right', color: theme.info },
{ header: 'Tags', key: 'tags', align: 'left', color: theme.dim },
];
logger.logTable(columns, rows);
logger.log('');
}
}
/**
* Pull a model
*/
public async pull(modelName: string): Promise<void> {
if (!modelName) {
logger.error('Model name is required');
return;
}
logger.log('');
logger.info(`Pulling model: ${modelName}`);
logger.log('');
const result = await this.modelLoader.loadModel(modelName);
if (result.success) {
if (result.alreadyLoaded) {
logger.success(`Model "${modelName}" is already loaded`);
} else {
logger.success(`Model "${modelName}" pulled successfully`);
}
if (result.container) {
logger.dim(`Container: ${result.container}`);
}
} else {
logger.error(`Failed to pull model: ${result.error}`);
}
logger.log('');
}
/**
* Remove a model
*/
public async remove(modelName: string): Promise<void> {
if (!modelName) {
logger.error('Model name is required');
return;
}
logger.info(`Removing model: ${modelName}`);
const success = await this.modelLoader.unloadModel(modelName);
if (success) {
logger.success(`Model "${modelName}" removed`);
} else {
logger.error(`Failed to remove model "${modelName}"`);
}
}
/**
* Show model loading status and recommendations
*/
public async status(): Promise<void> {
logger.log('');
await this.modelLoader.printStatus();
}
/**
* Refresh greenlist cache
*/
public async refresh(): Promise<void> {
logger.info('Refreshing greenlist...');
await this.modelRegistry.refreshGreenlist();
logger.success('Greenlist refreshed');
}
/**
* Format file size
*/
private formatSize(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
}

252
ts/cli/service-handler.ts Normal file
View File

@@ -0,0 +1,252 @@
/**
* Service Handler
*
* CLI commands for systemd service management.
*/
import process from 'node:process';
import { execSync } from 'node:child_process';
import { logger } from '../logger.ts';
import { theme } from '../colors.ts';
import { PATHS } from '../constants.ts';
import type { ModelGrid } from '../modelgrid.ts';
/**
* Handler for service-related CLI commands
*/
export class ServiceHandler {
private readonly modelgrid: ModelGrid;
constructor(modelgrid: ModelGrid) {
this.modelgrid = modelgrid;
}
/**
* Enable the service (requires root)
*/
public async enable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.modelgrid.getSystemd().install();
logger.log('ModelGrid service has been installed. Use "modelgrid service start" to start the service.');
}
/**
* Start the daemon directly
*/
public async daemonStart(debugMode: boolean = false): Promise<void> {
logger.log('Starting ModelGrid daemon...');
try {
if (debugMode) {
logger.log('Debug mode enabled');
}
await this.modelgrid.getDaemon().start();
} catch (error) {
logger.error(`Daemon start failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
/**
* Show logs of the systemd service
*/
public async logs(): Promise<void> {
try {
const { spawn } = await import('child_process');
logger.log('Tailing modelgrid service logs (Ctrl+C to exit)...\n');
const journalctl = spawn('journalctl', ['-u', 'modelgrid.service', '-n', '50', '-f'], {
stdio: ['ignore', 'inherit', 'inherit'],
});
process.on('SIGINT', () => {
journalctl.kill('SIGINT');
process.exit(0);
});
await new Promise<void>((resolve) => {
journalctl.on('exit', () => resolve());
});
} catch (error) {
logger.error(`Failed to retrieve logs: ${error}`);
process.exit(1);
}
}
/**
* Stop the systemd service
*/
public async stop(): Promise<void> {
await this.modelgrid.getSystemd().stop();
}
/**
* Start the systemd service
*/
public async start(): Promise<void> {
try {
await this.modelgrid.getSystemd().start();
} catch (error) {
process.exit(1);
}
}
/**
* Show status of the systemd service
*/
public async status(): Promise<void> {
await this.modelgrid.getSystemd().getStatus();
}
/**
* Disable the service (requires root)
*/
public async disable(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
await this.modelgrid.getSystemd().disable();
}
/**
* Check if the user has root access
*/
private checkRootAccess(errorMessage: string): void {
if (process.getuid && process.getuid() !== 0) {
logger.error(errorMessage);
process.exit(1);
}
}
/**
* Update ModelGrid from repository
*/
public async update(): Promise<void> {
try {
this.checkRootAccess('This command must be run as root to update ModelGrid.');
console.log('');
logger.info('Checking for updates...');
try {
const currentVersion = this.modelgrid.getVersion();
const apiUrl = 'https://code.foss.global/api/v1/repos/modelgrid.com/modelgrid/releases/latest';
const response = execSync(`curl -sSL ${apiUrl}`).toString();
const release = JSON.parse(response);
const latestVersion = release.tag_name;
const normalizedCurrent = currentVersion.startsWith('v') ? currentVersion : `v${currentVersion}`;
const normalizedLatest = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`;
logger.dim(`Current version: ${normalizedCurrent}`);
logger.dim(`Latest version: ${normalizedLatest}`);
console.log('');
if (normalizedCurrent === normalizedLatest) {
logger.success('Already up to date!');
console.log('');
return;
}
logger.info(`New version available: ${latestVersion}`);
logger.dim('Downloading and installing...');
console.log('');
const installUrl = 'https://code.foss.global/modelgrid.com/modelgrid/raw/branch/main/install.sh';
execSync(`curl -sSL ${installUrl} | bash`, {
stdio: 'inherit',
});
console.log('');
logger.success(`Updated to ${latestVersion}`);
console.log('');
} catch (error) {
console.log('');
logger.error('Update failed');
logger.dim(`${error instanceof Error ? error.message : String(error)}`);
console.log('');
process.exit(1);
}
} catch (error) {
logger.error(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
/**
* Completely uninstall ModelGrid from the system
*/
public async uninstall(): Promise<void> {
this.checkRootAccess('This command must be run as root.');
try {
const helpers = await import('../helpers/index.ts');
const { prompt, close } = await helpers.createPrompt();
logger.log('');
logger.highlight('ModelGrid Uninstaller');
logger.dim('=====================');
logger.log('This will completely remove ModelGrid from your system.');
logger.log('');
const removeConfig = await prompt('Do you want to remove configuration files? (y/N): ');
const removeContainers = await prompt('Do you want to remove Docker containers? (y/N): ');
close();
// Stop service first
try {
await this.modelgrid.getSystemd().stop();
} catch {
// Service might not be running
}
// Disable service
try {
await this.modelgrid.getSystemd().disable();
} catch {
// Service might not be installed
}
// Remove containers if requested
if (removeContainers.toLowerCase() === 'y') {
logger.info('Removing Docker containers...');
try {
execSync('docker rm -f $(docker ps -aq --filter "name=modelgrid")', { stdio: 'pipe' });
} catch {
// No containers to remove
}
}
// Remove configuration if requested
if (removeConfig.toLowerCase() === 'y') {
logger.info('Removing configuration...');
try {
const { rm } = await import('node:fs/promises');
await rm(PATHS.CONFIG_DIR, { recursive: true, force: true });
} catch {
// Config might not exist
}
}
// Run uninstall script
const { dirname, join } = await import('path');
const binPath = process.argv[1];
const modulePath = dirname(dirname(binPath));
const uninstallScriptPath = join(modulePath, 'uninstall.sh');
logger.log('');
logger.log(`Running uninstaller from ${uninstallScriptPath}...`);
execSync(`sudo bash ${uninstallScriptPath}`, {
env: {
...process.env,
REMOVE_CONFIG: removeConfig.toLowerCase() === 'y' ? 'yes' : 'no',
MODELGRID_CLI_CALL: 'true',
},
stdio: 'inherit',
});
} catch (error) {
logger.error(`Uninstall failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
}