diff --git a/core/src/browser/extensions/conversational.test.ts b/core/src/browser/extensions/conversational.test.ts new file mode 100644 index 000000000..8046383c9 --- /dev/null +++ b/core/src/browser/extensions/conversational.test.ts @@ -0,0 +1,252 @@ +import { ConversationalExtension } from './conversational' +import { ExtensionTypeEnum } from '../extension' +import { Thread, ThreadAssistantInfo, ThreadMessage } from '../../types' + +// Mock implementation of ConversationalExtension +class MockConversationalExtension extends ConversationalExtension { + private threads: Thread[] = [] + private messages: { [threadId: string]: ThreadMessage[] } = {} + private assistants: { [threadId: string]: ThreadAssistantInfo } = {} + + constructor() { + super('http://mock-url.com', 'mock-extension', 'Mock Extension', true, 'A mock extension', '1.0.0') + } + + onLoad(): void { + // Mock implementation + } + + onUnload(): void { + // Mock implementation + } + + async listThreads(): Promise { + return this.threads + } + + async createThread(thread: Partial): Promise { + const newThread: Thread = { + id: thread.id || `thread-${Date.now()}`, + name: thread.name || 'New Thread', + createdAt: thread.createdAt || new Date().toISOString(), + updatedAt: thread.updatedAt || new Date().toISOString(), + } + this.threads.push(newThread) + this.messages[newThread.id] = [] + return newThread + } + + async modifyThread(thread: Thread): Promise { + const index = this.threads.findIndex(t => t.id === thread.id) + if (index !== -1) { + this.threads[index] = thread + } + } + + async deleteThread(threadId: string): Promise { + this.threads = this.threads.filter(t => t.id !== threadId) + delete this.messages[threadId] + delete this.assistants[threadId] + } + + async createMessage(message: Partial): Promise { + if (!message.threadId) throw new Error('Thread ID is required') + + const newMessage: ThreadMessage = { + id: message.id || `message-${Date.now()}`, + threadId: message.threadId, + content: message.content || '', + role: message.role || 'user', + createdAt: message.createdAt || new Date().toISOString(), + } + + if (!this.messages[message.threadId]) { + this.messages[message.threadId] = [] + } + + this.messages[message.threadId].push(newMessage) + return newMessage + } + + async deleteMessage(threadId: string, messageId: string): Promise { + if (this.messages[threadId]) { + this.messages[threadId] = this.messages[threadId].filter(m => m.id !== messageId) + } + } + + async listMessages(threadId: string): Promise { + return this.messages[threadId] || [] + } + + async getThreadAssistant(threadId: string): Promise { + return this.assistants[threadId] || { modelId: '', threadId } + } + + async createThreadAssistant( + threadId: string, + assistant: ThreadAssistantInfo + ): Promise { + this.assistants[threadId] = assistant + return assistant + } + + async modifyThreadAssistant( + threadId: string, + assistant: ThreadAssistantInfo + ): Promise { + this.assistants[threadId] = assistant + return assistant + } + + async modifyMessage(message: ThreadMessage): Promise { + if (!this.messages[message.threadId]) return message + + const index = this.messages[message.threadId].findIndex(m => m.id === message.id) + if (index !== -1) { + this.messages[message.threadId][index] = message + } + + return message + } +} + +describe('ConversationalExtension', () => { + let extension: MockConversationalExtension + + beforeEach(() => { + extension = new MockConversationalExtension() + }) + + test('should return the correct extension type', () => { + expect(extension.type()).toBe(ExtensionTypeEnum.Conversational) + }) + + test('should create and list threads', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + expect(thread.name).toBe('Test Thread') + + const threads = await extension.listThreads() + expect(threads).toHaveLength(1) + expect(threads[0].id).toBe(thread.id) + }) + + test('should modify thread', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + const modifiedThread = { ...thread, name: 'Modified Thread' } + + await extension.modifyThread(modifiedThread) + + const threads = await extension.listThreads() + expect(threads[0].name).toBe('Modified Thread') + }) + + test('should delete thread', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + await extension.deleteThread(thread.id) + + const threads = await extension.listThreads() + expect(threads).toHaveLength(0) + }) + + test('should create and list messages', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + const message = await extension.createMessage({ + threadId: thread.id, + content: 'Test message', + role: 'user' + }) + + expect(message.content).toBe('Test message') + + const messages = await extension.listMessages(thread.id) + expect(messages).toHaveLength(1) + expect(messages[0].id).toBe(message.id) + }) + + test('should modify message', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + const message = await extension.createMessage({ + threadId: thread.id, + content: 'Test message', + role: 'user' + }) + + const modifiedMessage = { ...message, content: 'Modified message' } + + await extension.modifyMessage(modifiedMessage) + + const messages = await extension.listMessages(thread.id) + expect(messages[0].content).toBe('Modified message') + }) + + test('should delete message', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + const message = await extension.createMessage({ + threadId: thread.id, + content: 'Test message', + role: 'user' + }) + + await extension.deleteMessage(thread.id, message.id) + + const messages = await extension.listMessages(thread.id) + expect(messages).toHaveLength(0) + }) + + test('should create and get thread assistant', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + const assistant: ThreadAssistantInfo = { + threadId: thread.id, + modelId: 'test-model' + } + + await extension.createThreadAssistant(thread.id, assistant) + + const retrievedAssistant = await extension.getThreadAssistant(thread.id) + expect(retrievedAssistant.modelId).toBe('test-model') + }) + + test('should modify thread assistant', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + const assistant: ThreadAssistantInfo = { + threadId: thread.id, + modelId: 'test-model' + } + + await extension.createThreadAssistant(thread.id, assistant) + + const modifiedAssistant: ThreadAssistantInfo = { + threadId: thread.id, + modelId: 'modified-model' + } + + await extension.modifyThreadAssistant(thread.id, modifiedAssistant) + + const retrievedAssistant = await extension.getThreadAssistant(thread.id) + expect(retrievedAssistant.modelId).toBe('modified-model') + }) + + test('should delete thread assistant when thread is deleted', async () => { + const thread = await extension.createThread({ name: 'Test Thread' }) + + const assistant: ThreadAssistantInfo = { + threadId: thread.id, + modelId: 'test-model' + } + + await extension.createThreadAssistant(thread.id, assistant) + await extension.deleteThread(thread.id) + + // Creating a new thread with the same ID to test if assistant was deleted + const newThread = await extension.createThread({ id: thread.id, name: 'New Thread' }) + const retrievedAssistant = await extension.getThreadAssistant(newThread.id) + + expect(retrievedAssistant.modelId).toBe('') + }) +}) \ No newline at end of file diff --git a/core/src/browser/extensions/enginesManagement.test.ts b/core/src/browser/extensions/enginesManagement.test.ts new file mode 100644 index 000000000..2a7880992 --- /dev/null +++ b/core/src/browser/extensions/enginesManagement.test.ts @@ -0,0 +1,566 @@ +import { EngineManagementExtension } from './enginesManagement' +import { ExtensionTypeEnum } from '../extension' +import { + EngineConfig, + EngineReleased, + EngineVariant, + Engines, + InferenceEngine, + DefaultEngineVariant, + Model +} from '../../types' + +// Mock implementation of EngineManagementExtension +class MockEngineManagementExtension extends EngineManagementExtension { + private mockEngines: Engines = { + llama: { + name: 'llama', + variants: [ + { + variant: 'cpu', + version: '1.0.0', + path: '/engines/llama/cpu/1.0.0', + installed: true + }, + { + variant: 'cuda', + version: '1.0.0', + path: '/engines/llama/cuda/1.0.0', + installed: false + } + ], + default: { + variant: 'cpu', + version: '1.0.0' + } + }, + gpt4all: { + name: 'gpt4all', + variants: [ + { + variant: 'cpu', + version: '2.0.0', + path: '/engines/gpt4all/cpu/2.0.0', + installed: true + } + ], + default: { + variant: 'cpu', + version: '2.0.0' + } + } + } + + private mockReleases: { [key: string]: EngineReleased[] } = { + 'llama-1.0.0': [ + { + variant: 'cpu', + version: '1.0.0', + os: ['macos', 'linux', 'windows'], + url: 'https://example.com/llama/1.0.0/cpu' + }, + { + variant: 'cuda', + version: '1.0.0', + os: ['linux', 'windows'], + url: 'https://example.com/llama/1.0.0/cuda' + } + ], + 'llama-1.1.0': [ + { + variant: 'cpu', + version: '1.1.0', + os: ['macos', 'linux', 'windows'], + url: 'https://example.com/llama/1.1.0/cpu' + }, + { + variant: 'cuda', + version: '1.1.0', + os: ['linux', 'windows'], + url: 'https://example.com/llama/1.1.0/cuda' + } + ], + 'gpt4all-2.0.0': [ + { + variant: 'cpu', + version: '2.0.0', + os: ['macos', 'linux', 'windows'], + url: 'https://example.com/gpt4all/2.0.0/cpu' + } + ] + } + + private remoteModels: { [engine: string]: Model[] } = { + 'llama': [], + 'gpt4all': [] + } + + constructor() { + super('http://mock-url.com', 'mock-engine-extension', 'Mock Engine Extension', true, 'A mock engine extension', '1.0.0') + } + + onLoad(): void { + // Mock implementation + } + + onUnload(): void { + // Mock implementation + } + + async getEngines(): Promise { + return JSON.parse(JSON.stringify(this.mockEngines)) + } + + async getInstalledEngines(name: InferenceEngine): Promise { + if (!this.mockEngines[name]) { + return [] + } + + return this.mockEngines[name].variants.filter(variant => variant.installed) + } + + async getReleasedEnginesByVersion( + name: InferenceEngine, + version: string, + platform?: string + ): Promise { + const key = `${name}-${version}` + let releases = this.mockReleases[key] || [] + + if (platform) { + releases = releases.filter(release => release.os.includes(platform)) + } + + return releases + } + + async getLatestReleasedEngine( + name: InferenceEngine, + platform?: string + ): Promise { + // For mock, let's assume latest versions are 1.1.0 for llama and 2.0.0 for gpt4all + const latestVersions = { + 'llama': '1.1.0', + 'gpt4all': '2.0.0' + } + + if (!latestVersions[name]) { + return [] + } + + return this.getReleasedEnginesByVersion(name, latestVersions[name], platform) + } + + async installEngine( + name: string, + engineConfig: EngineConfig + ): Promise<{ messages: string }> { + if (!this.mockEngines[name]) { + this.mockEngines[name] = { + name, + variants: [], + default: { + variant: engineConfig.variant, + version: engineConfig.version + } + } + } + + // Check if variant already exists + const existingVariantIndex = this.mockEngines[name].variants.findIndex( + v => v.variant === engineConfig.variant && v.version === engineConfig.version + ) + + if (existingVariantIndex >= 0) { + this.mockEngines[name].variants[existingVariantIndex].installed = true + } else { + this.mockEngines[name].variants.push({ + variant: engineConfig.variant, + version: engineConfig.version, + path: `/engines/${name}/${engineConfig.variant}/${engineConfig.version}`, + installed: true + }) + } + + return { messages: `Successfully installed ${name} ${engineConfig.variant} ${engineConfig.version}` } + } + + async addRemoteEngine( + engineConfig: EngineConfig + ): Promise<{ messages: string }> { + const name = engineConfig.name || 'remote-engine' + + if (!this.mockEngines[name]) { + this.mockEngines[name] = { + name, + variants: [], + default: { + variant: engineConfig.variant, + version: engineConfig.version + } + } + } + + this.mockEngines[name].variants.push({ + variant: engineConfig.variant, + version: engineConfig.version, + path: engineConfig.path || `/engines/${name}/${engineConfig.variant}/${engineConfig.version}`, + installed: true, + url: engineConfig.url + }) + + return { messages: `Successfully added remote engine ${name}` } + } + + async uninstallEngine( + name: InferenceEngine, + engineConfig: EngineConfig + ): Promise<{ messages: string }> { + if (!this.mockEngines[name]) { + return { messages: `Engine ${name} not found` } + } + + const variantIndex = this.mockEngines[name].variants.findIndex( + v => v.variant === engineConfig.variant && v.version === engineConfig.version + ) + + if (variantIndex >= 0) { + this.mockEngines[name].variants[variantIndex].installed = false + + // If this was the default variant, reset default + if ( + this.mockEngines[name].default.variant === engineConfig.variant && + this.mockEngines[name].default.version === engineConfig.version + ) { + // Find another installed variant to set as default + const installedVariant = this.mockEngines[name].variants.find(v => v.installed) + if (installedVariant) { + this.mockEngines[name].default = { + variant: installedVariant.variant, + version: installedVariant.version + } + } else { + // No installed variants remain, clear default + this.mockEngines[name].default = { variant: '', version: '' } + } + } + + return { messages: `Successfully uninstalled ${name} ${engineConfig.variant} ${engineConfig.version}` } + } else { + return { messages: `Variant ${engineConfig.variant} ${engineConfig.version} not found for engine ${name}` } + } + } + + async getDefaultEngineVariant( + name: InferenceEngine + ): Promise { + if (!this.mockEngines[name]) { + return { variant: '', version: '' } + } + + return this.mockEngines[name].default + } + + async setDefaultEngineVariant( + name: InferenceEngine, + engineConfig: EngineConfig + ): Promise<{ messages: string }> { + if (!this.mockEngines[name]) { + return { messages: `Engine ${name} not found` } + } + + const variantExists = this.mockEngines[name].variants.some( + v => v.variant === engineConfig.variant && v.version === engineConfig.version && v.installed + ) + + if (!variantExists) { + return { messages: `Variant ${engineConfig.variant} ${engineConfig.version} not found or not installed` } + } + + this.mockEngines[name].default = { + variant: engineConfig.variant, + version: engineConfig.version + } + + return { messages: `Successfully set ${engineConfig.variant} ${engineConfig.version} as default for ${name}` } + } + + async updateEngine( + name: InferenceEngine, + engineConfig?: EngineConfig + ): Promise<{ messages: string }> { + if (!this.mockEngines[name]) { + return { messages: `Engine ${name} not found` } + } + + if (!engineConfig) { + // Assume we're updating to the latest version + return { messages: `Successfully updated ${name} to the latest version` } + } + + const variantIndex = this.mockEngines[name].variants.findIndex( + v => v.variant === engineConfig.variant && v.installed + ) + + if (variantIndex >= 0) { + // Update the version + this.mockEngines[name].variants[variantIndex].version = engineConfig.version + + // If this was the default variant, update default version too + if (this.mockEngines[name].default.variant === engineConfig.variant) { + this.mockEngines[name].default.version = engineConfig.version + } + + return { messages: `Successfully updated ${name} ${engineConfig.variant} to version ${engineConfig.version}` } + } else { + return { messages: `Installed variant ${engineConfig.variant} not found for engine ${name}` } + } + } + + async addRemoteModel(model: Model): Promise { + const engine = model.engine as string + + if (!this.remoteModels[engine]) { + this.remoteModels[engine] = [] + } + + this.remoteModels[engine].push(model) + } + + async getRemoteModels(name: InferenceEngine | string): Promise { + return this.remoteModels[name] || [] + } +} + +describe('EngineManagementExtension', () => { + let extension: MockEngineManagementExtension + + beforeEach(() => { + extension = new MockEngineManagementExtension() + }) + + test('should return the correct extension type', () => { + expect(extension.type()).toBe(ExtensionTypeEnum.Engine) + }) + + test('should get all engines', async () => { + const engines = await extension.getEngines() + + expect(engines).toBeDefined() + expect(engines.llama).toBeDefined() + expect(engines.gpt4all).toBeDefined() + expect(engines.llama.variants).toHaveLength(2) + expect(engines.gpt4all.variants).toHaveLength(1) + }) + + test('should get installed engines', async () => { + const llamaEngines = await extension.getInstalledEngines('llama') + + expect(llamaEngines).toHaveLength(1) + expect(llamaEngines[0].variant).toBe('cpu') + expect(llamaEngines[0].installed).toBe(true) + + const gpt4allEngines = await extension.getInstalledEngines('gpt4all') + + expect(gpt4allEngines).toHaveLength(1) + expect(gpt4allEngines[0].variant).toBe('cpu') + expect(gpt4allEngines[0].installed).toBe(true) + + // Test non-existent engine + const nonExistentEngines = await extension.getInstalledEngines('non-existent' as InferenceEngine) + expect(nonExistentEngines).toHaveLength(0) + }) + + test('should get released engines by version', async () => { + const llamaReleases = await extension.getReleasedEnginesByVersion('llama', '1.0.0') + + expect(llamaReleases).toHaveLength(2) + expect(llamaReleases[0].variant).toBe('cpu') + expect(llamaReleases[1].variant).toBe('cuda') + + // Test with platform filter + const llamaLinuxReleases = await extension.getReleasedEnginesByVersion('llama', '1.0.0', 'linux') + + expect(llamaLinuxReleases).toHaveLength(2) + + const llamaMacReleases = await extension.getReleasedEnginesByVersion('llama', '1.0.0', 'macos') + + expect(llamaMacReleases).toHaveLength(1) + expect(llamaMacReleases[0].variant).toBe('cpu') + + // Test non-existent version + const nonExistentReleases = await extension.getReleasedEnginesByVersion('llama', '9.9.9') + expect(nonExistentReleases).toHaveLength(0) + }) + + test('should get latest released engines', async () => { + const latestLlamaReleases = await extension.getLatestReleasedEngine('llama') + + expect(latestLlamaReleases).toHaveLength(2) + expect(latestLlamaReleases[0].version).toBe('1.1.0') + + // Test with platform filter + const latestLlamaMacReleases = await extension.getLatestReleasedEngine('llama', 'macos') + + expect(latestLlamaMacReleases).toHaveLength(1) + expect(latestLlamaMacReleases[0].variant).toBe('cpu') + expect(latestLlamaMacReleases[0].version).toBe('1.1.0') + + // Test non-existent engine + const nonExistentReleases = await extension.getLatestReleasedEngine('non-existent' as InferenceEngine) + expect(nonExistentReleases).toHaveLength(0) + }) + + test('should install engine', async () => { + // Install existing engine variant that is not installed + const result = await extension.installEngine('llama', { variant: 'cuda', version: '1.0.0' }) + + expect(result.messages).toContain('Successfully installed') + + const installedEngines = await extension.getInstalledEngines('llama') + expect(installedEngines).toHaveLength(2) + expect(installedEngines.some(e => e.variant === 'cuda')).toBe(true) + + // Install non-existent engine + const newEngineResult = await extension.installEngine('new-engine', { variant: 'cpu', version: '1.0.0' }) + + expect(newEngineResult.messages).toContain('Successfully installed') + + const engines = await extension.getEngines() + expect(engines['new-engine']).toBeDefined() + expect(engines['new-engine'].variants).toHaveLength(1) + expect(engines['new-engine'].variants[0].installed).toBe(true) + }) + + test('should add remote engine', async () => { + const result = await extension.addRemoteEngine({ + name: 'remote-llm', + variant: 'remote', + version: '1.0.0', + url: 'https://example.com/remote-llm-api' + }) + + expect(result.messages).toContain('Successfully added remote engine') + + const engines = await extension.getEngines() + expect(engines['remote-llm']).toBeDefined() + expect(engines['remote-llm'].variants).toHaveLength(1) + expect(engines['remote-llm'].variants[0].url).toBe('https://example.com/remote-llm-api') + }) + + test('should uninstall engine', async () => { + const result = await extension.uninstallEngine('llama', { variant: 'cpu', version: '1.0.0' }) + + expect(result.messages).toContain('Successfully uninstalled') + + const installedEngines = await extension.getInstalledEngines('llama') + expect(installedEngines).toHaveLength(0) + + // Test uninstalling non-existent variant + const nonExistentResult = await extension.uninstallEngine('llama', { variant: 'non-existent', version: '1.0.0' }) + + expect(nonExistentResult.messages).toContain('not found') + }) + + test('should handle default variant when uninstalling', async () => { + // First install cuda variant + await extension.installEngine('llama', { variant: 'cuda', version: '1.0.0' }) + + // Set cuda as default + await extension.setDefaultEngineVariant('llama', { variant: 'cuda', version: '1.0.0' }) + + // Check that cuda is now default + let defaultVariant = await extension.getDefaultEngineVariant('llama') + expect(defaultVariant.variant).toBe('cuda') + + // Uninstall cuda + await extension.uninstallEngine('llama', { variant: 'cuda', version: '1.0.0' }) + + // Check that default has changed to another installed variant + defaultVariant = await extension.getDefaultEngineVariant('llama') + expect(defaultVariant.variant).toBe('cpu') + + // Uninstall all variants + await extension.uninstallEngine('llama', { variant: 'cpu', version: '1.0.0' }) + + // Check that default is now empty + defaultVariant = await extension.getDefaultEngineVariant('llama') + expect(defaultVariant.variant).toBe('') + expect(defaultVariant.version).toBe('') + }) + + test('should get default engine variant', async () => { + const llamaDefault = await extension.getDefaultEngineVariant('llama') + + expect(llamaDefault.variant).toBe('cpu') + expect(llamaDefault.version).toBe('1.0.0') + + // Test non-existent engine + const nonExistentDefault = await extension.getDefaultEngineVariant('non-existent' as InferenceEngine) + expect(nonExistentDefault.variant).toBe('') + expect(nonExistentDefault.version).toBe('') + }) + + test('should set default engine variant', async () => { + // Install cuda variant + await extension.installEngine('llama', { variant: 'cuda', version: '1.0.0' }) + + const result = await extension.setDefaultEngineVariant('llama', { variant: 'cuda', version: '1.0.0' }) + + expect(result.messages).toContain('Successfully set') + + const defaultVariant = await extension.getDefaultEngineVariant('llama') + expect(defaultVariant.variant).toBe('cuda') + expect(defaultVariant.version).toBe('1.0.0') + + // Test setting non-existent variant as default + const nonExistentResult = await extension.setDefaultEngineVariant('llama', { variant: 'non-existent', version: '1.0.0' }) + + expect(nonExistentResult.messages).toContain('not found') + }) + + test('should update engine', async () => { + const result = await extension.updateEngine('llama', { variant: 'cpu', version: '1.1.0' }) + + expect(result.messages).toContain('Successfully updated') + + const engines = await extension.getEngines() + const cpuVariant = engines.llama.variants.find(v => v.variant === 'cpu') + expect(cpuVariant).toBeDefined() + expect(cpuVariant?.version).toBe('1.1.0') + + // Default should also be updated since cpu was default + expect(engines.llama.default.version).toBe('1.1.0') + + // Test updating non-existent variant + const nonExistentResult = await extension.updateEngine('llama', { variant: 'non-existent', version: '1.1.0' }) + + expect(nonExistentResult.messages).toContain('not found') + }) + + test('should add and get remote models', async () => { + const model: Model = { + id: 'remote-model-1', + name: 'Remote Model 1', + path: '/path/to/remote-model', + engine: 'llama', + format: 'gguf', + modelFormat: 'gguf', + source: 'remote', + status: 'ready', + contextLength: 4096, + sizeInGB: 4, + created: new Date().toISOString() + } + + await extension.addRemoteModel(model) + + const llamaModels = await extension.getRemoteModels('llama') + expect(llamaModels).toHaveLength(1) + expect(llamaModels[0].id).toBe('remote-model-1') + + // Test non-existent engine + const nonExistentModels = await extension.getRemoteModels('non-existent') + expect(nonExistentModels).toHaveLength(0) + }) +}) \ No newline at end of file diff --git a/core/src/browser/extensions/hardwareManagement.test.ts b/core/src/browser/extensions/hardwareManagement.test.ts new file mode 100644 index 000000000..6ada06862 --- /dev/null +++ b/core/src/browser/extensions/hardwareManagement.test.ts @@ -0,0 +1,146 @@ +import { HardwareManagementExtension } from './hardwareManagement' +import { ExtensionTypeEnum } from '../extension' +import { HardwareInformation } from '../../types' + +// Mock implementation of HardwareManagementExtension +class MockHardwareManagementExtension extends HardwareManagementExtension { + private activeGpus: number[] = [0] + private mockHardwareInfo: HardwareInformation = { + cpu: { + manufacturer: 'Mock CPU Manufacturer', + brand: 'Mock CPU', + cores: 8, + physicalCores: 4, + speed: 3.5, + }, + memory: { + total: 16 * 1024 * 1024 * 1024, // 16GB in bytes + free: 8 * 1024 * 1024 * 1024, // 8GB in bytes + }, + gpus: [ + { + id: 0, + vendor: 'Mock GPU Vendor', + model: 'Mock GPU Model 1', + memory: 8 * 1024 * 1024 * 1024, // 8GB in bytes + }, + { + id: 1, + vendor: 'Mock GPU Vendor', + model: 'Mock GPU Model 2', + memory: 4 * 1024 * 1024 * 1024, // 4GB in bytes + } + ], + active_gpus: [0], + } + + constructor() { + super('http://mock-url.com', 'mock-hardware-extension', 'Mock Hardware Extension', true, 'A mock hardware extension', '1.0.0') + } + + onLoad(): void { + // Mock implementation + } + + onUnload(): void { + // Mock implementation + } + + async getHardware(): Promise { + // Return a copy to prevent test side effects + return JSON.parse(JSON.stringify(this.mockHardwareInfo)) + } + + async setAvtiveGpu(data: { gpus: number[] }): Promise<{ + message: string + activated_gpus: number[] + }> { + // Validate GPUs exist + const validGpus = data.gpus.filter(gpuId => + this.mockHardwareInfo.gpus.some(gpu => gpu.id === gpuId) + ) + + if (validGpus.length === 0) { + throw new Error('No valid GPUs selected') + } + + // Update active GPUs + this.activeGpus = validGpus + this.mockHardwareInfo.active_gpus = validGpus + + return { + message: 'GPU activation successful', + activated_gpus: validGpus + } + } +} + +describe('HardwareManagementExtension', () => { + let extension: MockHardwareManagementExtension + + beforeEach(() => { + extension = new MockHardwareManagementExtension() + }) + + test('should return the correct extension type', () => { + expect(extension.type()).toBe(ExtensionTypeEnum.Hardware) + }) + + test('should get hardware information', async () => { + const hardwareInfo = await extension.getHardware() + + // Check CPU info + expect(hardwareInfo.cpu).toBeDefined() + expect(hardwareInfo.cpu.manufacturer).toBe('Mock CPU Manufacturer') + expect(hardwareInfo.cpu.cores).toBe(8) + + // Check memory info + expect(hardwareInfo.memory).toBeDefined() + expect(hardwareInfo.memory.total).toBe(16 * 1024 * 1024 * 1024) + + // Check GPU info + expect(hardwareInfo.gpus).toHaveLength(2) + expect(hardwareInfo.gpus[0].model).toBe('Mock GPU Model 1') + expect(hardwareInfo.gpus[1].model).toBe('Mock GPU Model 2') + + // Check active GPUs + expect(hardwareInfo.active_gpus).toEqual([0]) + }) + + test('should set active GPUs', async () => { + const result = await extension.setAvtiveGpu({ gpus: [1] }) + + expect(result.message).toBe('GPU activation successful') + expect(result.activated_gpus).toEqual([1]) + + // Verify the change in hardware info + const hardwareInfo = await extension.getHardware() + expect(hardwareInfo.active_gpus).toEqual([1]) + }) + + test('should set multiple active GPUs', async () => { + const result = await extension.setAvtiveGpu({ gpus: [0, 1] }) + + expect(result.message).toBe('GPU activation successful') + expect(result.activated_gpus).toEqual([0, 1]) + + // Verify the change in hardware info + const hardwareInfo = await extension.getHardware() + expect(hardwareInfo.active_gpus).toEqual([0, 1]) + }) + + test('should throw error for invalid GPU ids', async () => { + await expect(extension.setAvtiveGpu({ gpus: [999] })).rejects.toThrow('No valid GPUs selected') + }) + + test('should handle mix of valid and invalid GPU ids', async () => { + const result = await extension.setAvtiveGpu({ gpus: [0, 999] }) + + // Should only activate valid GPUs + expect(result.activated_gpus).toEqual([0]) + + // Verify the change in hardware info + const hardwareInfo = await extension.getHardware() + expect(hardwareInfo.active_gpus).toEqual([0]) + }) +}) \ No newline at end of file diff --git a/core/src/browser/extensions/model.test.ts b/core/src/browser/extensions/model.test.ts new file mode 100644 index 000000000..bc045419d --- /dev/null +++ b/core/src/browser/extensions/model.test.ts @@ -0,0 +1,286 @@ +import { ModelExtension } from './model' +import { ExtensionTypeEnum } from '../extension' +import { Model, OptionType, ModelSource } from '../../types' + +// Mock implementation of ModelExtension +class MockModelExtension extends ModelExtension { + private models: Model[] = [] + private sources: ModelSource[] = [] + private loadedModels: Set = new Set() + private modelsPulling: Set = new Set() + + constructor() { + super('http://mock-url.com', 'mock-model-extension', 'Mock Model Extension', true, 'A mock model extension', '1.0.0') + } + + onLoad(): void { + // Mock implementation + } + + onUnload(): void { + // Mock implementation + } + + async configurePullOptions(configs: { [key: string]: any }): Promise { + return configs + } + + async getModels(): Promise { + return this.models + } + + async pullModel(model: string, id?: string, name?: string): Promise { + const modelId = id || `model-${Date.now()}` + this.modelsPulling.add(modelId) + + // Simulate model pull by adding it to the model list + const newModel: Model = { + id: modelId, + path: `/models/${model}`, + name: name || model, + source: 'mock-source', + modelFormat: 'mock-format', + engine: 'mock-engine', + format: 'mock-format', + status: 'ready', + contextLength: 2048, + sizeInGB: 2, + created: new Date().toISOString(), + pullProgress: { + percent: 100, + transferred: 0, + total: 0 + } + } + + this.models.push(newModel) + this.loadedModels.add(modelId) + this.modelsPulling.delete(modelId) + } + + async cancelModelPull(modelId: string): Promise { + this.modelsPulling.delete(modelId) + // Remove the model if it's in the pulling state + this.models = this.models.filter(m => m.id !== modelId) + } + + async importModel( + model: string, + modelPath: string, + name?: string, + optionType?: OptionType + ): Promise { + const newModel: Model = { + id: `model-${Date.now()}`, + path: modelPath, + name: name || model, + source: 'local', + modelFormat: optionType?.format || 'mock-format', + engine: optionType?.engine || 'mock-engine', + format: optionType?.format || 'mock-format', + status: 'ready', + contextLength: optionType?.contextLength || 2048, + sizeInGB: 2, + created: new Date().toISOString(), + } + + this.models.push(newModel) + this.loadedModels.add(newModel.id) + } + + async updateModel(modelInfo: Partial): Promise { + if (!modelInfo.id) throw new Error('Model ID is required') + + const index = this.models.findIndex(m => m.id === modelInfo.id) + if (index === -1) throw new Error('Model not found') + + this.models[index] = { ...this.models[index], ...modelInfo } + return this.models[index] + } + + async deleteModel(modelId: string): Promise { + this.models = this.models.filter(m => m.id !== modelId) + this.loadedModels.delete(modelId) + } + + async isModelLoaded(modelId: string): Promise { + return this.loadedModels.has(modelId) + } + + async getSources(): Promise { + return this.sources + } + + async addSource(source: string): Promise { + const newSource: ModelSource = { + id: `source-${Date.now()}`, + url: source, + name: `Source ${this.sources.length + 1}`, + type: 'mock-type' + } + + this.sources.push(newSource) + } + + async deleteSource(sourceId: string): Promise { + this.sources = this.sources.filter(s => s.id !== sourceId) + } +} + +describe('ModelExtension', () => { + let extension: MockModelExtension + + beforeEach(() => { + extension = new MockModelExtension() + }) + + test('should return the correct extension type', () => { + expect(extension.type()).toBe(ExtensionTypeEnum.Model) + }) + + test('should configure pull options', async () => { + const configs = { apiKey: 'test-key', baseUrl: 'https://test-url.com' } + const result = await extension.configurePullOptions(configs) + expect(result).toEqual(configs) + }) + + test('should add and get models', async () => { + await extension.pullModel('test-model', 'test-id', 'Test Model') + + const models = await extension.getModels() + expect(models).toHaveLength(1) + expect(models[0].id).toBe('test-id') + expect(models[0].name).toBe('Test Model') + }) + + test('should pull model with default id and name', async () => { + await extension.pullModel('test-model') + + const models = await extension.getModels() + expect(models).toHaveLength(1) + expect(models[0].name).toBe('test-model') + }) + + test('should cancel model pull', async () => { + await extension.pullModel('test-model', 'test-id') + + // Verify model exists + let models = await extension.getModels() + expect(models).toHaveLength(1) + + // Cancel the pull + await extension.cancelModelPull('test-id') + + // Verify model was removed + models = await extension.getModels() + expect(models).toHaveLength(0) + }) + + test('should import model', async () => { + const optionType: OptionType = { + engine: 'test-engine', + format: 'test-format', + contextLength: 4096 + } + + await extension.importModel('test-model', '/path/to/model', 'Imported Model', optionType) + + const models = await extension.getModels() + expect(models).toHaveLength(1) + expect(models[0].name).toBe('Imported Model') + expect(models[0].engine).toBe('test-engine') + expect(models[0].format).toBe('test-format') + expect(models[0].contextLength).toBe(4096) + }) + + test('should import model with default values', async () => { + await extension.importModel('test-model', '/path/to/model') + + const models = await extension.getModels() + expect(models).toHaveLength(1) + expect(models[0].name).toBe('test-model') + expect(models[0].engine).toBe('mock-engine') + expect(models[0].format).toBe('mock-format') + }) + + test('should update model', async () => { + await extension.pullModel('test-model', 'test-id', 'Test Model') + + const updatedModel = await extension.updateModel({ + id: 'test-id', + name: 'Updated Model', + contextLength: 8192 + }) + + expect(updatedModel.name).toBe('Updated Model') + expect(updatedModel.contextLength).toBe(8192) + + // Verify changes persisted + const models = await extension.getModels() + expect(models[0].name).toBe('Updated Model') + expect(models[0].contextLength).toBe(8192) + }) + + test('should throw error when updating non-existent model', async () => { + await expect(extension.updateModel({ + id: 'non-existent', + name: 'Updated Model' + })).rejects.toThrow('Model not found') + }) + + test('should throw error when updating model without ID', async () => { + await expect(extension.updateModel({ + name: 'Updated Model' + })).rejects.toThrow('Model ID is required') + }) + + test('should delete model', async () => { + await extension.pullModel('test-model', 'test-id') + + // Verify model exists + let models = await extension.getModels() + expect(models).toHaveLength(1) + + // Delete the model + await extension.deleteModel('test-id') + + // Verify model was removed + models = await extension.getModels() + expect(models).toHaveLength(0) + }) + + test('should check if model is loaded', async () => { + await extension.pullModel('test-model', 'test-id') + + // Check if model is loaded + const isLoaded = await extension.isModelLoaded('test-id') + expect(isLoaded).toBe(true) + + // Check if non-existent model is loaded + const nonExistentLoaded = await extension.isModelLoaded('non-existent') + expect(nonExistentLoaded).toBe(false) + }) + + test('should add and get sources', async () => { + await extension.addSource('https://test-source.com') + + const sources = await extension.getSources() + expect(sources).toHaveLength(1) + expect(sources[0].url).toBe('https://test-source.com') + }) + + test('should delete source', async () => { + await extension.addSource('https://test-source.com') + + // Get the source ID + const sources = await extension.getSources() + const sourceId = sources[0].id + + // Delete the source + await extension.deleteSource(sourceId) + + // Verify source was removed + const updatedSources = await extension.getSources() + expect(updatedSources).toHaveLength(0) + }) +}) \ No newline at end of file diff --git a/extensions/engine-management-extension/package.json b/extensions/engine-management-extension/package.json index cf774f6a2..d08998ba8 100644 --- a/extensions/engine-management-extension/package.json +++ b/extensions/engine-management-extension/package.json @@ -1,7 +1,7 @@ { "name": "@janhq/engine-management-extension", "productName": "Engine Management", - "version": "1.0.2", + "version": "1.0.3", "description": "Manages AI engines and their configurations.", "main": "dist/index.js", "node": "dist/node/index.cjs.js", diff --git a/extensions/engine-management-extension/resources/cohere.json b/extensions/engine-management-extension/resources/cohere.json index 43cd0da5b..c6df45898 100644 --- a/extensions/engine-management-extension/resources/cohere.json +++ b/extensions/engine-management-extension/resources/cohere.json @@ -15,7 +15,7 @@ }, "transform_resp": { "chat_completions": { - "template": "{% if input_request.stream %} {\"object\": \"chat.completion.chunk\", \"model\": \"{{ input_request.model }}\", \"choices\": [{\"index\": 0, \"delta\": { {% if input_request.event_type == \"text-generation\" %} \"role\": \"assistant\", \"content\": \"{{ input_request.text }}\" {% else %} \"role\": \"assistant\", \"content\": null {% endif %} }, {% if input_request.event_type == \"stream-end\" %} \"finish_reason\": \"{{ input_request.finish_reason }}\" {% else %} \"finish_reason\": null {% endif %} }]} {% else %} {\"id\": \"{{ input_request.generation_id }}\", \"created\": null, \"object\": \"chat.completion\", \"model\": {% if input_request.model %} \"{{ input_request.model }}\" {% else %} \"command-r-plus-08-2024\" {% endif %}, \"choices\": [{ \"index\": 0, \"message\": { \"role\": \"assistant\", \"content\": {% if not input_request.text %} null {% else %} \"{{ input_request.text }}\" {% endif %}, \"refusal\": null }, \"logprobs\": null, \"finish_reason\": \"{{ input_request.finish_reason }}\" } ], \"usage\": { \"prompt_tokens\": {{ input_request.meta.tokens.input_tokens }}, \"completion_tokens\": {{ input_request.meta.tokens.output_tokens }},\"total_tokens\": {{ input_request.meta.tokens.input_tokens + input_request.meta.tokens.output_tokens }}, \"prompt_tokens_details\": { \"cached_tokens\": 0 },\"completion_tokens_details\": { \"reasoning_tokens\": 0, \"accepted_prediction_tokens\": 0, \"rejected_prediction_tokens\": 0 } }, \"system_fingerprint\": \"fp_6b68a8204b\"} {% endif %}" + "template": "{% if input_request.stream %} {\"object\": \"chat.completion.chunk\", \"model\": \"{{ input_request.model }}\", \"choices\": [{\"index\": 0, \"delta\": { {% if input_request.event_type == \"text-generation\" %} \"role\": \"assistant\", \"content\": {{ tojson(input_request.text) }} {% else %} \"role\": \"assistant\", \"content\": null {% endif %} }, {% if input_request.event_type == \"stream-end\" %} \"finish_reason\": \"{{ input_request.finish_reason }}\" {% else %} \"finish_reason\": null {% endif %} }]} {% else %} {\"id\": \"{{ input_request.generation_id }}\", \"created\": null, \"object\": \"chat.completion\", \"model\": {% if input_request.model %} \"{{ input_request.model }}\" {% else %} \"command-r-plus-08-2024\" {% endif %}, \"choices\": [{ \"index\": 0, \"message\": { \"role\": \"assistant\", \"content\": {% if not input_request.text %} null {% else %} {{ tojson(input_request.text) }} {% endif %}, \"refusal\": null }, \"logprobs\": null, \"finish_reason\": \"{{ input_request.finish_reason }}\" } ], \"usage\": { \"prompt_tokens\": {{ input_request.meta.tokens.input_tokens }}, \"completion_tokens\": {{ input_request.meta.tokens.output_tokens }},\"total_tokens\": {{ input_request.meta.tokens.input_tokens + input_request.meta.tokens.output_tokens }}, \"prompt_tokens_details\": { \"cached_tokens\": 0 },\"completion_tokens_details\": { \"reasoning_tokens\": 0, \"accepted_prediction_tokens\": 0, \"rejected_prediction_tokens\": 0 } }, \"system_fingerprint\": \"fp_6b68a8204b\"} {% endif %}" } }, "explore_models_url": "https://docs.cohere.com/v2/docs/models"