diff --git a/cortex-js/package.json b/cortex-js/package.json index d8ceb5063..aaf92f5d5 100644 --- a/cortex-js/package.json +++ b/cortex-js/package.json @@ -38,7 +38,7 @@ "preuninstall": "node ./uninstall.js" }, "dependencies": { - "@cortexso/cortex.js": "^0.1.5", + "@cortexso/cortex.js": "^0.1.7", "@nestjs/axios": "^3.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.2", diff --git a/cortex-js/src/domain/models/huggingface.interface.ts b/cortex-js/src/domain/models/huggingface.interface.ts index 2ab46dc79..2c2274b3e 100644 --- a/cortex-js/src/domain/models/huggingface.interface.ts +++ b/cortex-js/src/domain/models/huggingface.interface.ts @@ -10,6 +10,7 @@ export interface HuggingFaceRepoSibling { downloadUrl?: string; fileSize?: number; quantization?: Quantization; + lfs?: { oid?: string }; } export interface HuggingFaceRepoData { id: string; diff --git a/cortex-js/src/infrastructure/commanders/base.command.ts b/cortex-js/src/infrastructure/commanders/base.command.ts index e0839fcb2..5d3be2b98 100644 --- a/cortex-js/src/infrastructure/commanders/base.command.ts +++ b/cortex-js/src/infrastructure/commanders/base.command.ts @@ -34,4 +34,7 @@ export abstract class BaseCommand extends CommandRunner { } await this.runCommand(passedParam, options); } + protected setCortex(cortex: CortexClient) { + this.cortex = cortex; + } } diff --git a/cortex-js/src/infrastructure/commanders/chat.command.ts b/cortex-js/src/infrastructure/commanders/chat.command.ts index b23a9b956..35b2ef5ba 100644 --- a/cortex-js/src/infrastructure/commanders/chat.command.ts +++ b/cortex-js/src/infrastructure/commanders/chat.command.ts @@ -18,7 +18,7 @@ import { Cortex } from '@cortexso/cortex.js'; import { ChatClient } from './services/chat-client'; import { downloadProgress } from '@/utils/download-progress'; import { DownloadType } from '@/domain/models/download.interface'; -import { CortexClient } from './services/cortex.client'; +import { checkRequiredVersion } from '@/utils/model-check'; type ChatOptions = { threadId?: string; @@ -96,8 +96,20 @@ export class ChatCommand extends BaseCommand { await downloadProgress(this.cortex, undefined, DownloadType.Engine); } + const { version: engineVersion } = + await this.cortex.engines.retrieve(engine); + if ( + existingModel.engine_version && + !checkRequiredVersion(existingModel.engine_version, engineVersion) + ) { + console.log( + `Model engine version ${existingModel.engine_version} is not compatible with engine version ${engineVersion}`, + ); + process.exit(1); + } + if (!message) options.attach = true; - this.telemetryUsecases.sendEvent( + void this.telemetryUsecases.sendEvent( [ { name: EventName.CHAT, diff --git a/cortex-js/src/infrastructure/commanders/engines.command.ts b/cortex-js/src/infrastructure/commanders/engines.command.ts index 9f3e84b82..c4861f325 100644 --- a/cortex-js/src/infrastructure/commanders/engines.command.ts +++ b/cortex-js/src/infrastructure/commanders/engines.command.ts @@ -61,6 +61,7 @@ export class EnginesCommand extends BaseCommand { ) { const commandInstance = this.moduleRef.get(commandClass, { strict: false }); if (commandInstance) { + commandInstance.setCortex(this.cortex); await commandInstance.runCommand(params, options); } else { console.error('Command not found.'); diff --git a/cortex-js/src/infrastructure/commanders/models.command.ts b/cortex-js/src/infrastructure/commanders/models.command.ts index ab3f562b4..d66dd0989 100644 --- a/cortex-js/src/infrastructure/commanders/models.command.ts +++ b/cortex-js/src/infrastructure/commanders/models.command.ts @@ -67,6 +67,7 @@ export class ModelsCommand extends BaseCommand { ) { const commandInstance = this.moduleRef.get(commandClass, { strict: false }); if (commandInstance) { + commandInstance.setCortex(this.cortex); await commandInstance.runCommand(params, options); } else { console.error('Command not found.'); diff --git a/cortex-js/src/infrastructure/commanders/run.command.ts b/cortex-js/src/infrastructure/commanders/run.command.ts index 1ae5fc9a4..a0dcdcb84 100644 --- a/cortex-js/src/infrastructure/commanders/run.command.ts +++ b/cortex-js/src/infrastructure/commanders/run.command.ts @@ -5,7 +5,7 @@ import ora from 'ora'; import { existsSync } from 'fs'; import { join } from 'path'; import { Engines } from './types/engine.interface'; -import { checkModelCompatibility } from '@/utils/model-check'; +import { checkModelCompatibility, checkRequiredVersion } from '@/utils/model-check'; import { BaseCommand } from './base.command'; import { isRemoteEngine } from '@/utils/normalize-model-id'; import { ChatClient } from './services/chat-client'; @@ -98,6 +98,11 @@ export class RunCommand extends BaseCommand { await this.cortex.engines.init(engine); await downloadProgress(this.cortex, undefined, DownloadType.Engine); } + const { version: engineVersion } = await this.cortex.engines.retrieve(engine); + if(existingModel.engine_version && !checkRequiredVersion(existingModel.engine_version, engineVersion)) { + console.log(`Model engine version ${existingModel.engine_version} is not compatible with engine version ${engineVersion}`); + process.exit(1); + } const startingSpinner = ora('Loading model...').start(); diff --git a/cortex-js/src/infrastructure/services/download-manager/download-manager.service.ts b/cortex-js/src/infrastructure/services/download-manager/download-manager.service.ts index 8da2810bb..6ae6550fb 100644 --- a/cortex-js/src/infrastructure/services/download-manager/download-manager.service.ts +++ b/cortex-js/src/infrastructure/services/download-manager/download-manager.service.ts @@ -11,6 +11,7 @@ import { Presets, SingleBar } from 'cli-progress'; import { createWriteStream, unlinkSync } from 'node:fs'; import { basename } from 'node:path'; import { firstValueFrom } from 'rxjs'; +import crypto from 'crypto'; @Injectable() export class DownloadManagerService { @@ -51,7 +52,13 @@ export class DownloadManagerService { downloadId: string, title: string, downloadType: DownloadType, - urlToDestination: Record, + urlToDestination: Record< + string, + { + destination: string; + checksum?: string; + } + >, finishedCallback?: () => Promise, inSequence: boolean = true, ) { @@ -65,7 +72,7 @@ export class DownloadManagerService { const downloadItems: DownloadItem[] = Object.keys(urlToDestination).map( (url) => { - const destination = urlToDestination[url]; + const { destination, checksum } = urlToDestination[url]; const downloadItem: DownloadItem = { id: destination, time: { @@ -78,6 +85,7 @@ export class DownloadManagerService { }, progress: 0, status: DownloadStatus.Downloading, + checksum, }; return downloadItem; @@ -119,15 +127,15 @@ export class DownloadManagerService { if (!inSequence) { Promise.all( Object.keys(urlToDestination).map((url) => { - const destination = urlToDestination[url]; - return this.downloadFile(downloadId, url, destination); + const { destination, checksum } = urlToDestination[url]; + return this.downloadFile(downloadId, url, destination, checksum); }), ).then(callBack); } else { // Download model file in sequence for (const url of Object.keys(urlToDestination)) { - const destination = urlToDestination[url]; - await this.downloadFile(downloadId, url, destination); + const { destination, checksum } = urlToDestination[url]; + await this.downloadFile(downloadId, url, destination, checksum); } return callBack(); } @@ -137,7 +145,14 @@ export class DownloadManagerService { downloadId: string, url: string, destination: string, + checksum?: string, ) { + console.log('Downloading', { + downloadId, + url, + destination, + checksum, + }); const controller = new AbortController(); // adding to abort controllers this.abortControllers[downloadId][destination] = controller; @@ -155,6 +170,7 @@ export class DownloadManagerService { } const writer = createWriteStream(destination); + const hash = crypto.createHash('sha256'); const totalBytes = Number(response.headers['content-length']); // update download state @@ -214,8 +230,23 @@ export class DownloadManagerService { const downloadItem = currentDownloadState?.children.find( (downloadItem) => downloadItem.id === destination, ); + const isFileBroken = checksum && checksum === hash.digest('hex'); if (downloadItem) { - downloadItem.status = DownloadStatus.Downloaded; + downloadItem.status = isFileBroken + ? DownloadStatus.Error + : DownloadStatus.Downloaded; + if (isFileBroken) { + downloadItem.error = 'Checksum is not matched'; + this.handleError( + new Error('Checksum is not matched'), + downloadId, + destination, + ); + } + } + if (isFileBroken) { + currentDownloadState.status = DownloadStatus.Error; + currentDownloadState.error = 'Checksum is not matched'; } this.eventEmitter.emit('download.event', this.allDownloadStates); } finally { @@ -234,6 +265,7 @@ export class DownloadManagerService { }); response.data.on('data', (chunk: any) => { + hash.update(chunk); resetTimeout(); transferredBytes += chunk.length; diff --git a/cortex-js/src/infrastructure/services/file-manager/file-manager.service.ts b/cortex-js/src/infrastructure/services/file-manager/file-manager.service.ts index 071f95b12..5043028cd 100644 --- a/cortex-js/src/infrastructure/services/file-manager/file-manager.service.ts +++ b/cortex-js/src/infrastructure/services/file-manager/file-manager.service.ts @@ -28,7 +28,6 @@ const writeAsync = promisify(write); @Injectable() export class FileManagerService { - private configFile = '.cortexrc'; private cortexDirectoryName = 'cortex'; private modelFolderName = 'models'; private presetFolderName = 'presets'; @@ -44,7 +43,10 @@ export class FileManagerService { */ async getConfig(dataFolderPath?: string): Promise { const homeDir = os.homedir(); - const configPath = join(homeDir, this.configFile); + const configPath = join( + homeDir, + this.getConfigFileName(this.configProfile), + ); const config = this.defaultConfig(); const dataFolderPathUsed = dataFolderPath || config.dataFolderPath; if (!existsSync(configPath) || !existsSync(dataFolderPathUsed)) { @@ -55,8 +57,7 @@ export class FileManagerService { try { const content = await promises.readFile(configPath, 'utf8'); - const configs = yaml.load(content) as Record; - const config = configs?.[this.configProfile] ?? {}; + const config = yaml.load(content) as Config & object; return { ...this.defaultConfig(), ...config, @@ -72,17 +73,20 @@ export class FileManagerService { async writeConfigFile(config: Config & object): Promise { const homeDir = os.homedir(); - const configPath = join(homeDir, this.configFile); + const configPath = join( + homeDir, + this.getConfigFileName(this.configProfile), + ); // write config to file as yaml if (!existsSync(configPath)) { await promises.writeFile(configPath, '', 'utf8'); } const content = await promises.readFile(configPath, 'utf8'); - const currentConfig = yaml.load(content) as Record; + const currentConfig = yaml.load(content) as Config & object; const configString = yaml.dump({ ...currentConfig, - [this.configProfile]: config, + ...config, }); await promises.writeFile(configPath, configString, 'utf8'); } @@ -345,12 +349,17 @@ export class FileManagerService { */ getServerConfig(): { host: string; port: number } { const homeDir = os.homedir(); - const configPath = join(homeDir, this.configFile); + const configPath = join( + homeDir, + this.getConfigFileName(this.configProfile), + ); let config = this.defaultConfig(); try { const content = readFileSync(configPath, 'utf8'); - const configs = (yaml.load(content) as Record) ?? {}; - config = configs?.[this.configProfile] ?? config; + const currentConfig = (yaml.load(content) as Config & object) ?? {}; + if (currentConfig) { + config = currentConfig; + } } catch {} return { host: config.apiServerHost ?? '127.0.0.1', @@ -366,15 +375,22 @@ export class FileManagerService { } public profileConfigExists(profile: string): boolean { const homeDir = os.homedir(); - const configPath = join(homeDir, this.configFile); + const configPath = join(homeDir, this.getConfigFileName(profile)); try { const content = readFileSync(configPath, 'utf8'); - const configs = (yaml.load(content) as Record) ?? {}; - return !!configs[profile]; + const config = yaml.load(content) as Config & object; + return !!config; } catch { return false; } } + + private getConfigFileName(configProfile: string): string { + if (configProfile === 'default') { + return '.cortexrc'; + } + return `.${configProfile}rc`; + } } export const fileManagerService = new FileManagerService(); diff --git a/cortex-js/src/main.ts b/cortex-js/src/main.ts index 1a43ca318..988e55426 100644 --- a/cortex-js/src/main.ts +++ b/cortex-js/src/main.ts @@ -6,11 +6,10 @@ import { getApp } from './app'; import chalk from 'chalk'; async function bootstrap() { - const app = await getApp(); // getting port from env const host = process.env.CORTEX_JS_HOST || defaultCortexJsHost; const port = process.env.CORTEX_JS_PORT || defaultCortexJsPort; - + const app = await getApp(host, Number(port)); try { await app.listen(port, host); console.log(chalk.blue(`Started server at http://${host}:${port}`)); diff --git a/cortex-js/src/usecases/engines/engines.usecase.ts b/cortex-js/src/usecases/engines/engines.usecase.ts index 7df0a23e3..ae8d50f55 100644 --- a/cortex-js/src/usecases/engines/engines.usecase.ts +++ b/cortex-js/src/usecases/engines/engines.usecase.ts @@ -199,7 +199,7 @@ export class EnginesUsecases { url, 'Cuda Toolkit Dependencies', DownloadType.Engine, - { [url]: destination }, + { [url]: { destination } }, async () => { try { await decompress( @@ -274,7 +274,7 @@ export class EnginesUsecases { toDownloadAsset.browser_download_url, engine, DownloadType.Engine, - { [toDownloadAsset.browser_download_url]: destination }, + { [toDownloadAsset.browser_download_url]: { destination } }, // On completed - post processing async () => { try { diff --git a/cortex-js/src/usecases/models/models.usecases.ts b/cortex-js/src/usecases/models/models.usecases.ts index 3f45ec254..7c631e6f9 100644 --- a/cortex-js/src/usecases/models/models.usecases.ts +++ b/cortex-js/src/usecases/models/models.usecases.ts @@ -399,14 +399,34 @@ export class ModelsUsecases { } // Start downloading the model - const toDownloads: Record = files + const toDownloads: Record< + string, + { + destination: string; + checksum?: string; + } + > = files .filter((e) => this.validFileDownload(e)) - .reduce((acc: Record, file) => { - if (file.downloadUrl) - acc[file.downloadUrl] = join(modelFolder, file.rfilename); - return acc; - }, {}); - + .reduce( + ( + acc: Record< + string, + { + destination: string; + checksum?: string; + } + >, + file, + ) => { + if (file.downloadUrl) + acc[file.downloadUrl] = { + destination: join(modelFolder, file.rfilename), + checksum: file.lfs.oid, + }; + return acc; + }, + {}, + ); return this.downloadManagerService.submitDownloadRequest( modelId, modelId, diff --git a/cortex-js/src/utils/huggingface.ts b/cortex-js/src/utils/huggingface.ts index 9df80eeeb..38b642ad0 100644 --- a/cortex-js/src/utils/huggingface.ts +++ b/cortex-js/src/utils/huggingface.ts @@ -144,6 +144,7 @@ export async function fetchJanRepoData( | { path: string; size: number; + oid?: string; }[] | { error: string } = jsonData; @@ -159,6 +160,9 @@ export async function fetchJanRepoData( rfilename: e.path, downloadUrl: HUGGING_FACE_TREE_REF_URL(repo, tree, e.path), fileSize: e.size ?? 0, + lfs: { + oid: e.oid, + }, }; }) : [], @@ -186,7 +190,6 @@ export async function fetchJanRepoData( }); data.modelUrl = url; - return data; } diff --git a/cortex-js/src/utils/model-check.ts b/cortex-js/src/utils/model-check.ts index 78ca84b60..05cd8827f 100644 --- a/cortex-js/src/utils/model-check.ts +++ b/cortex-js/src/utils/model-check.ts @@ -45,3 +45,19 @@ export const checkModelCompatibility = async ( } } }; + +export const parseVersion = (version: string) => { + return version.split('.').map(Number); +}; + +export const checkRequiredVersion = (version: string, minVersion: string) => { + const [currentMajor, currentMinor, currentPatch] = parseVersion(version); + const [requiredMajor, requiredMinor, requiredPatch] = + parseVersion(minVersion); + return ( + currentMajor > requiredMajor || + (currentMajor === requiredMajor && + (currentMinor > requiredMinor || + (currentMinor === requiredMinor && currentPatch >= requiredPatch))) + ); +};