365 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|