* add llamacpp plugin * Refactor llamacpp plugin * add utils plugin * remove utils folder * add hardware implementation * add utils folder + move utils function * organize cargo files * refactor utils src * refactor util * apply fmt * fmt * Update gguf + reformat * add permission for gguf commands * fix cargo test windows * revert yarn lock * remove cargo.lock for hardware plugin * ignore cargo.lock file * Fix hardware invoke + refactor hardware + refactor tests, constants * use api wrapper in extension to invoke hardware call + api wrapper build integration * add newline at EOF (per Akarshan) * add vi mock for getSystemInfo
406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import llamacpp_extension from '../index'
|
|
|
|
// Mock fetch globally
|
|
global.fetch = vi.fn()
|
|
|
|
// Mock backend functions
|
|
vi.mock('../backend', () => ({
|
|
isBackendInstalled: vi.fn(),
|
|
getBackendExePath: vi.fn(),
|
|
downloadBackend: vi.fn(),
|
|
listSupportedBackends: vi.fn(),
|
|
getBackendDir: vi.fn(),
|
|
}))
|
|
|
|
describe('llamacpp_extension', () => {
|
|
let extension: llamacpp_extension
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
extension = new llamacpp_extension()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('constructor', () => {
|
|
it('should initialize with correct default values', () => {
|
|
expect(extension.provider).toBe('llamacpp')
|
|
expect(extension.providerId).toBe('llamacpp')
|
|
expect(extension.autoUnload).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('getProviderPath', () => {
|
|
it('should return correct provider path', async () => {
|
|
const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockResolvedValue('/path/to/jan/llamacpp')
|
|
|
|
const result = await extension.getProviderPath()
|
|
|
|
expect(result).toBe('/path/to/jan/llamacpp')
|
|
})
|
|
})
|
|
|
|
describe('list', () => {
|
|
it('should return empty array when models directory does not exist', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockResolvedValue('/path/to/jan/llamacpp/models')
|
|
vi.mocked(fs.existsSync)
|
|
.mockResolvedValueOnce(false) // models directory doesn't exist initially
|
|
.mockResolvedValue(false) // no model.yml files exist
|
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
|
vi.mocked(fs.readdirSync).mockResolvedValue([]) // empty directory after creation
|
|
|
|
const result = await extension.list()
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('should return model list when models exist', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
|
|
// Set up providerPath first
|
|
extension['providerPath'] = '/path/to/jan/llamacpp'
|
|
|
|
const modelsDir = '/path/to/jan/llamacpp/models'
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
|
|
// Mock joinPath to handle the directory traversal logic
|
|
vi.mocked(joinPath).mockImplementation((paths) => {
|
|
if (paths.length === 1) {
|
|
return Promise.resolve(paths[0])
|
|
}
|
|
return Promise.resolve(paths.join('/'))
|
|
})
|
|
|
|
vi.mocked(fs.existsSync)
|
|
.mockResolvedValueOnce(true) // modelsDir exists
|
|
.mockResolvedValueOnce(false) // model.yml doesn't exist at modelsDir level
|
|
.mockResolvedValueOnce(true) // model.yml exists in test-model dir
|
|
|
|
vi.mocked(fs.readdirSync).mockResolvedValue(['test-model'])
|
|
vi.mocked(fs.fileStat).mockResolvedValue({ isDirectory: true, size: 1000 })
|
|
|
|
vi.mocked(invoke).mockResolvedValue({
|
|
model_path: 'test-model/model.gguf',
|
|
name: 'Test Model',
|
|
size_bytes: 1000000
|
|
})
|
|
|
|
const result = await extension.list()
|
|
|
|
// Note: There's a bug in the original code where it pushes just the child name
|
|
// instead of the full path, causing the model ID to be empty
|
|
expect(result).toEqual([
|
|
{
|
|
id: '', // This should be 'test-model' but the original code has a bug
|
|
name: 'Test Model',
|
|
quant_type: undefined,
|
|
providerId: 'llamacpp',
|
|
port: 0,
|
|
sizeBytes: 1000000
|
|
}
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('import', () => {
|
|
it('should throw error for invalid modelId', async () => {
|
|
await expect(extension.import('invalid/model/../id', { modelPath: '/path/to/model' }))
|
|
.rejects.toThrow('Invalid modelId')
|
|
})
|
|
|
|
it('should throw error if model already exists', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockResolvedValue('/path/to/jan/llamacpp/models/test-model/model.yml')
|
|
vi.mocked(fs.existsSync).mockResolvedValue(true)
|
|
|
|
await expect(extension.import('test-model', { modelPath: '/path/to/model' }))
|
|
.rejects.toThrow('Model test-model already exists')
|
|
})
|
|
|
|
it('should import model from URL', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
|
|
const mockDownloadManager = {
|
|
downloadFiles: vi.fn().mockResolvedValue(undefined)
|
|
}
|
|
|
|
window.core.extensionManager.getByName = vi.fn().mockReturnValue(mockDownloadManager)
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockImplementation((paths) => Promise.resolve(paths.join('/')))
|
|
vi.mocked(fs.existsSync).mockResolvedValue(false)
|
|
vi.mocked(fs.fileStat).mockResolvedValue({ size: 1000000 })
|
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
|
|
vi.mocked(invoke).mockResolvedValue(undefined)
|
|
|
|
await extension.import('test-model', {
|
|
modelPath: 'https://example.com/model.gguf'
|
|
})
|
|
|
|
expect(mockDownloadManager.downloadFiles).toHaveBeenCalled()
|
|
expect(fs.mkdir).toHaveBeenCalled()
|
|
expect(invoke).toHaveBeenCalledWith('write_yaml', expect.any(Object))
|
|
})
|
|
})
|
|
|
|
describe('load', () => {
|
|
it('should throw error if model is already loaded', async () => {
|
|
// Mock that model is already loaded
|
|
extension['activeSessions'].set(123, {
|
|
model_id: 'test-model',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'test-key'
|
|
})
|
|
|
|
await expect(extension.load('test-model')).rejects.toThrow('Model already loaded!!')
|
|
})
|
|
|
|
it('should load model successfully', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
|
|
// Mock system info for getBackendExePath
|
|
const getSystemInfo = vi.fn().mockResolvedValue({
|
|
os_type: 'linux'
|
|
})
|
|
|
|
// Mock backend functions to avoid download
|
|
const backendModule = await import('../backend')
|
|
vi.mocked(backendModule.isBackendInstalled).mockResolvedValue(true)
|
|
vi.mocked(backendModule.getBackendExePath).mockResolvedValue('/path/to/backend/executable')
|
|
|
|
// Mock fs for backend check
|
|
vi.mocked(fs.existsSync).mockResolvedValue(true)
|
|
|
|
// Mock configuration
|
|
extension['config'] = {
|
|
version_backend: 'v1.0.0/win-avx2-x64',
|
|
ctx_size: 2048,
|
|
n_gpu_layers: 10,
|
|
threads: 4,
|
|
chat_template: '',
|
|
threads_batch: 0,
|
|
n_predict: 0,
|
|
batch_size: 0,
|
|
ubatch_size: 0,
|
|
device: '',
|
|
split_mode: '',
|
|
main_gpu: 0,
|
|
flash_attn: false,
|
|
cont_batching: false,
|
|
no_mmap: false,
|
|
mlock: false,
|
|
no_kv_offload: false,
|
|
cache_type_k: 'f16',
|
|
cache_type_v: 'f16',
|
|
defrag_thold: 0.1,
|
|
rope_scaling: 'linear',
|
|
rope_scale: 1.0,
|
|
rope_freq_base: 10000,
|
|
rope_freq_scale: 1.0,
|
|
reasoning_budget: 0,
|
|
auto_update_engine: false,
|
|
auto_unload: true
|
|
}
|
|
|
|
// Set up providerPath
|
|
extension['providerPath'] = '/path/to/jan/llamacpp'
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockImplementation((paths) => Promise.resolve(paths.join('/')))
|
|
|
|
// Mock model config
|
|
vi.mocked(invoke)
|
|
.mockResolvedValueOnce({ // read_yaml
|
|
model_path: 'test-model/model.gguf',
|
|
name: 'Test Model',
|
|
size_bytes: 1000000
|
|
})
|
|
.mockResolvedValueOnce('test-api-key') // generate_api_key
|
|
.mockResolvedValueOnce({ // load_llama_model
|
|
model_id: 'test-model',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'test-api-key'
|
|
})
|
|
|
|
// Mock successful health check
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: vi.fn().mockResolvedValue({ status: 'ok' })
|
|
})
|
|
|
|
const result = await extension.load('test-model')
|
|
|
|
expect(result).toEqual({
|
|
model_id: 'test-model',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'test-api-key'
|
|
})
|
|
|
|
expect(extension['activeSessions'].get(123)).toEqual({
|
|
model_id: 'test-model',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'test-api-key'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('unload', () => {
|
|
it('should throw error if no active session found', async () => {
|
|
await expect(extension.unload('nonexistent-model')).rejects.toThrow('No active session found')
|
|
})
|
|
|
|
it('should unload model successfully', async () => {
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
|
|
// Set up active session
|
|
extension['activeSessions'].set(123, {
|
|
model_id: 'test-model',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'test-key'
|
|
})
|
|
|
|
vi.mocked(invoke).mockResolvedValue({
|
|
success: true,
|
|
error: null
|
|
})
|
|
|
|
const result = await extension.unload('test-model')
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
error: null
|
|
})
|
|
|
|
expect(extension['activeSessions'].has(123)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('chat', () => {
|
|
it('should throw error if no active session found', async () => {
|
|
const request = {
|
|
model: 'nonexistent-model',
|
|
messages: [{ role: 'user', content: 'Hello' }]
|
|
}
|
|
|
|
await expect(extension.chat(request)).rejects.toThrow('No active session found')
|
|
})
|
|
|
|
it('should handle non-streaming chat request', async () => {
|
|
const { invoke } = await import('@tauri-apps/api/core')
|
|
|
|
// Set up active session
|
|
extension['activeSessions'].set(123, {
|
|
model_id: 'test-model',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'test-key'
|
|
})
|
|
|
|
vi.mocked(invoke).mockResolvedValue(true) // is_process_running
|
|
|
|
const mockResponse = {
|
|
id: 'test-id',
|
|
object: 'chat.completion',
|
|
created: Date.now(),
|
|
model: 'test-model',
|
|
choices: [{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'Hello!' },
|
|
finish_reason: 'stop'
|
|
}]
|
|
}
|
|
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockResponse)
|
|
})
|
|
|
|
const request = {
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
stream: false
|
|
}
|
|
|
|
const result = await extension.chat(request)
|
|
|
|
expect(result).toEqual(mockResponse)
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
'http://localhost:3000/v1/chat/completions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer test-key'
|
|
}
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('delete', () => {
|
|
it('should throw error if model does not exist', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockImplementation((paths) => Promise.resolve(paths.join('/')))
|
|
vi.mocked(fs.existsSync).mockResolvedValue(false)
|
|
|
|
await expect(extension.delete('nonexistent-model')).rejects.toThrow('Model nonexistent-model does not exist')
|
|
})
|
|
|
|
it('should delete model successfully', async () => {
|
|
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
|
|
|
|
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
|
|
vi.mocked(joinPath).mockImplementation((paths) => Promise.resolve(paths.join('/')))
|
|
vi.mocked(fs.existsSync).mockResolvedValue(true)
|
|
vi.mocked(fs.rm).mockResolvedValue(undefined)
|
|
|
|
await extension.delete('test-model')
|
|
|
|
expect(fs.rm).toHaveBeenCalledWith('/path/to/jan/llamacpp/models/test-model')
|
|
})
|
|
})
|
|
|
|
describe('getLoadedModels', () => {
|
|
it('should return list of loaded models', async () => {
|
|
extension['activeSessions'].set(123, {
|
|
model_id: 'model1',
|
|
pid: 123,
|
|
port: 3000,
|
|
api_key: 'key1'
|
|
})
|
|
|
|
extension['activeSessions'].set(456, {
|
|
model_id: 'model2',
|
|
pid: 456,
|
|
port: 3001,
|
|
api_key: 'key2'
|
|
})
|
|
|
|
const result = await extension.getLoadedModels()
|
|
|
|
expect(result).toEqual(['model1', 'model2'])
|
|
})
|
|
})
|
|
}) |