Files
modelgrid/ts/cli/config-handler.ts
T

365 lines
10 KiB
TypeScript

/**
* 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;
const modelConfig = {
registryUrl: config.models.registryUrl ||
(config.models as { greenlistUrl?: string }).greenlistUrl ||
'https://list.modelgrid.com/catalog/models.json',
autoDeploy: config.models.autoDeploy ??
(config.models as { autoPull?: boolean }).autoPull ?? true,
defaultEngine: config.models.defaultEngine || 'vllm',
autoLoad: config.models.autoLoad || [],
};
const clusterConfig = config.cluster || {
enabled: false,
nodeName: 'modelgrid-local',
role: 'standalone',
bindHost: '0.0.0.0',
gossipPort: 7946,
sharedSecret: undefined,
advertiseUrl: undefined,
controlPlaneUrl: undefined,
heartbeatIntervalMs: 5000,
};
// 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 Deploy: ${
modelConfig.autoDeploy ? theme.success('Enabled') : theme.dim('Disabled')
}`,
`Default Engine: ${modelConfig.defaultEngine}`,
`Auto Load: ${modelConfig.autoLoad.length} model(s)`,
'',
theme.dim('Registry URL:'),
` ${modelConfig.registryUrl}`,
],
70,
'default',
);
logger.log('');
logger.logBox(
'Cluster',
[
`Enabled: ${clusterConfig.enabled ? theme.success('Yes') : theme.dim('No')}`,
`Node: ${clusterConfig.nodeName}`,
`Role: ${clusterConfig.role}`,
`Bind Host: ${clusterConfig.bindHost}:${clusterConfig.gossipPort}`,
`Shared Secret: ${
clusterConfig.sharedSecret ? theme.success('Configured') : theme.dim('Not set')
}`,
`Advertise URL: ${clusterConfig.advertiseUrl || theme.dim('Default loopback')}`,
`Control Plane: ${clusterConfig.controlPlaneUrl || theme.dim('Not configured')}`,
`Heartbeat: ${clusterConfig.heartbeatIntervalMs}ms`,
],
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: String(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: {
registryUrl: 'https://list.modelgrid.com/catalog/models.json',
autoDeploy: true,
defaultEngine: 'vllm',
autoLoad: [],
},
cluster: {
enabled: false,
nodeName: 'modelgrid-local',
role: 'standalone',
bindHost: '0.0.0.0',
gossipPort: 7946,
sharedSecret: '',
advertiseUrl: 'http://127.0.0.1:8080',
heartbeatIntervalMs: 5000,
seedNodes: [],
},
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;
}
}