Skip to content

Commit fe95031

Browse files
authored
feat: migrate cortex models to llamacpp extension (#5838)
* feat: migrate cortex models to new llama.cpp extension * test: add tests * clean: remove duplicated import
1 parent 5cbd79b commit fe95031

File tree

8 files changed

+553
-111
lines changed

8 files changed

+553
-111
lines changed

extensions/conversational-extension/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import {
66
} from '@janhq/core'
77

88
/**
9-
* JSONConversationalExtension is a ConversationalExtension implementation that provides
9+
* JanConversationalExtension is a ConversationalExtension implementation that provides
1010
* functionality for managing threads.
1111
*/
12-
export default class CortexConversationalExtension extends ConversationalExtension {
12+
export default class JanConversationalExtension extends ConversationalExtension {
1313
/**
1414
* Called when the extension is loaded.
1515
*/

extensions/llamacpp-extension/src/index.ts

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import {
3030
getBackendExePath,
3131
} from './backend'
3232
import { invoke } from '@tauri-apps/api/core'
33-
import { basename } from '@tauri-apps/api/path'
3433
import { getProxyConfig } from './util'
34+
import { basename } from '@tauri-apps/api/path'
3535

3636
type LlamacppConfig = {
3737
version_backend: string
@@ -538,9 +538,11 @@ export default class llamacpp_extension extends AIEngine {
538538
override async list(): Promise<modelInfo[]> {
539539
const modelsDir = await joinPath([await this.getProviderPath(), 'models'])
540540
if (!(await fs.existsSync(modelsDir))) {
541-
return []
541+
await fs.mkdir(modelsDir)
542542
}
543543

544+
await this.migrateLegacyModels()
545+
544546
let modelIds: string[] = []
545547

546548
// DFS
@@ -589,6 +591,94 @@ export default class llamacpp_extension extends AIEngine {
589591
return modelInfos
590592
}
591593

594+
private async migrateLegacyModels() {
595+
const janDataFolderPath = await getJanDataFolderPath()
596+
const modelsDir = await joinPath([janDataFolderPath, 'models'])
597+
if (!(await fs.existsSync(modelsDir))) return
598+
599+
// DFS
600+
let stack = [modelsDir]
601+
while (stack.length > 0) {
602+
const currentDir = stack.pop()
603+
604+
const files = await fs.readdirSync(currentDir)
605+
for (const child of files) {
606+
const childPath = await joinPath([currentDir, child])
607+
const stat = await fs.fileStat(childPath)
608+
if (
609+
files.some((e) => e.endsWith('model.yml')) &&
610+
!child.endsWith('model.yml')
611+
)
612+
continue
613+
if (!stat.isDirectory && child.endsWith('.yml')) {
614+
// check if model.yml exists
615+
const modelConfigPath = child
616+
if (await fs.existsSync(modelConfigPath)) {
617+
const legacyModelConfig = await invoke<{
618+
files: string[]
619+
model: string
620+
}>('read_yaml', {
621+
path: modelConfigPath,
622+
})
623+
const legacyModelPath = legacyModelConfig.files?.[0]
624+
if (!legacyModelPath) continue
625+
// +1 to remove the leading slash
626+
// NOTE: this does not handle Windows path \\
627+
let modelId = currentDir.slice(modelsDir.length + 1)
628+
629+
modelId =
630+
modelId !== 'imported'
631+
? modelId
632+
: (await basename(child)).replace('.yml', '')
633+
634+
const modelName = legacyModelConfig.model ?? modelId
635+
const configPath = await joinPath([
636+
await this.getProviderPath(),
637+
'models',
638+
modelId,
639+
'model.yml',
640+
])
641+
if (await fs.existsSync(configPath)) continue // Don't reimport
642+
643+
// this is relative to Jan's data folder
644+
const modelDir = `${this.providerId}/models/${modelId}`
645+
646+
let size_bytes = (
647+
await fs.fileStat(
648+
await joinPath([janDataFolderPath, legacyModelPath])
649+
)
650+
).size
651+
652+
const modelConfig = {
653+
model_path: legacyModelPath,
654+
mmproj_path: undefined, // legacy models do not have mmproj
655+
name: modelName,
656+
size_bytes,
657+
} as ModelConfig
658+
await fs.mkdir(await joinPath([janDataFolderPath, modelDir]))
659+
await invoke<void>('write_yaml', {
660+
data: modelConfig,
661+
savePath: configPath,
662+
})
663+
continue
664+
}
665+
}
666+
}
667+
668+
// otherwise, look into subdirectories
669+
const children = await fs.readdirSync(currentDir)
670+
for (const child of children) {
671+
// skip files
672+
const dirInfo = await fs.fileStat(child)
673+
if (!dirInfo.isDirectory) {
674+
continue
675+
}
676+
677+
stack.push(child)
678+
}
679+
}
680+
}
681+
592682
override async import(modelId: string, opts: ImportOptions): Promise<void> {
593683
const isValidModelId = (id: string) => {
594684
// only allow alphanumeric, underscore, hyphen, and dot characters in modelId

extensions/llamacpp-extension/src/test/backend.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,21 @@ describe('Backend functions', () => {
107107
os_type: 'windows',
108108
})
109109

110-
const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
110+
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
111111

112112
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
113113
vi.mocked(joinPath)
114114
.mockResolvedValueOnce(
115115
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64'
116116
)
117+
.mockResolvedValueOnce(
118+
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build'
119+
)
117120
.mockResolvedValueOnce(
118121
'/path/to/jan/llamacpp/backends/v1.0.0/win-avx2-x64/build/bin/llama-server.exe'
119122
)
123+
124+
vi.mocked(fs.existsSync).mockResolvedValue(true)
120125

121126
const result = await getBackendExePath('win-avx2-x64', 'v1.0.0')
122127

@@ -130,16 +135,21 @@ describe('Backend functions', () => {
130135
os_type: 'linux',
131136
})
132137

133-
const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
138+
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
134139

135140
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
136141
vi.mocked(joinPath)
137142
.mockResolvedValueOnce(
138143
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64'
139144
)
145+
.mockResolvedValueOnce(
146+
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build'
147+
)
140148
.mockResolvedValueOnce(
141149
'/path/to/jan/llamacpp/backends/v1.0.0/linux-avx2-x64/build/bin/llama-server'
142150
)
151+
152+
vi.mocked(fs.existsSync).mockResolvedValue(true)
143153

144154
const result = await getBackendExePath('linux-avx2-x64', 'v1.0.0')
145155

extensions/llamacpp-extension/src/test/index.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ import llamacpp_extension from '../index'
44
// Mock fetch globally
55
global.fetch = vi.fn()
66

7+
// Mock backend functions
8+
vi.mock('../backend', () => ({
9+
isBackendInstalled: vi.fn(),
10+
getBackendExePath: vi.fn(),
11+
downloadBackend: vi.fn(),
12+
listSupportedBackends: vi.fn(),
13+
getBackendDir: vi.fn(),
14+
}))
15+
716
describe('llamacpp_extension', () => {
817
let extension: llamacpp_extension
918

@@ -43,7 +52,11 @@ describe('llamacpp_extension', () => {
4352

4453
vi.mocked(getJanDataFolderPath).mockResolvedValue('/path/to/jan')
4554
vi.mocked(joinPath).mockResolvedValue('/path/to/jan/llamacpp/models')
46-
vi.mocked(fs.existsSync).mockResolvedValue(false)
55+
vi.mocked(fs.existsSync)
56+
.mockResolvedValueOnce(false) // models directory doesn't exist initially
57+
.mockResolvedValue(false) // no model.yml files exist
58+
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
59+
vi.mocked(fs.readdirSync).mockResolvedValue([]) // empty directory after creation
4760

4861
const result = await extension.list()
4962

@@ -158,14 +171,22 @@ describe('llamacpp_extension', () => {
158171
})
159172

160173
it('should load model successfully', async () => {
161-
const { getJanDataFolderPath, joinPath } = await import('@janhq/core')
174+
const { getJanDataFolderPath, joinPath, fs } = await import('@janhq/core')
162175
const { invoke } = await import('@tauri-apps/api/core')
163176

164177
// Mock system info for getBackendExePath
165178
window.core.api.getSystemInfo = vi.fn().mockResolvedValue({
166179
os_type: 'linux'
167180
})
168181

182+
// Mock backend functions to avoid download
183+
const backendModule = await import('../backend')
184+
vi.mocked(backendModule.isBackendInstalled).mockResolvedValue(true)
185+
vi.mocked(backendModule.getBackendExePath).mockResolvedValue('/path/to/backend/executable')
186+
187+
// Mock fs for backend check
188+
vi.mocked(fs.existsSync).mockResolvedValue(true)
189+
169190
// Mock configuration
170191
extension['config'] = {
171192
version_backend: 'v1.0.0/win-avx2-x64',
@@ -220,7 +241,8 @@ describe('llamacpp_extension', () => {
220241

221242
// Mock successful health check
222243
global.fetch = vi.fn().mockResolvedValue({
223-
ok: true
244+
ok: true,
245+
json: vi.fn().mockResolvedValue({ status: 'ok' })
224246
})
225247

226248
const result = await extension.load('test-model')

0 commit comments

Comments
 (0)