Skip to content
This repository was archived by the owner on Jul 4, 2025. It is now read-only.

Commit 41c1861

Browse files
committed
feat: pull model from jan hub
1 parent d31a788 commit 41c1861

File tree

5 files changed

+114
-42
lines changed

5 files changed

+114
-42
lines changed

cortex-js/src/infrastructure/commanders/usecases/models.cli.usecases.ts

Lines changed: 102 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,9 @@ export class ModelsCliUsecases {
9999
}
100100

101101
async pullModel(modelId: string) {
102-
if (modelId.includes('/')) {
102+
if (modelId.includes('/') || modelId.includes(':')) {
103103
await this.pullHuggingFaceModel(modelId);
104104
}
105-
106105
const bar = new SingleBar({}, Presets.shades_classic);
107106
bar.start(100, 0);
108107
const callback = (progress: number) => {
@@ -111,21 +110,41 @@ export class ModelsCliUsecases {
111110
await this.modelsUsecases.downloadModel(modelId, callback);
112111
}
113112

113+
/**
114+
* It's to pull model from HuggingFace repository
115+
* It could be a model from Jan's repo or other authors
116+
* @param modelId HuggingFace model id. e.g. "janhq/llama-3 or llama3:7b"
117+
*/
114118
private async pullHuggingFaceModel(modelId: string) {
115-
const data = await this.fetchHuggingFaceRepoData(modelId);
116-
const { quantization } = await this.inquirerService.inquirer.prompt({
117-
type: 'list',
118-
name: 'quantization',
119-
message: 'Select quantization',
120-
choices: data.siblings
121-
.map((e) => e.quantization)
122-
.filter((e) => e != null),
123-
});
124-
125-
const sibling = data.siblings
126-
.filter((e) => !!e.quantization)
127-
.find((e: any) => e.quantization === quantization);
119+
let data: HuggingFaceRepoData;
120+
if (modelId.includes('/'))
121+
data = await this.fetchHuggingFaceRepoData(modelId);
122+
else data = await this.fetchJanRepoData(modelId);
123+
124+
let sibling;
125+
126+
const listChoices = data.siblings
127+
.filter((e) => e.quantization != null)
128+
.map((e) => {
129+
return {
130+
name: e.quantization,
131+
value: e.quantization,
132+
};
133+
});
128134

135+
if (listChoices.length > 1) {
136+
const { quantization } = await this.inquirerService.inquirer.prompt({
137+
type: 'list',
138+
name: 'quantization',
139+
message: 'Select quantization',
140+
choices: listChoices,
141+
});
142+
sibling = data.siblings
143+
.filter((e) => !!e.quantization)
144+
.find((e: any) => e.quantization === quantization);
145+
} else {
146+
sibling = data.siblings.find((e) => e.rfilename.includes('.gguf'));
147+
}
129148
if (!sibling) throw 'No expected quantization found';
130149

131150
let stopWord = '';
@@ -141,9 +160,7 @@ export class ModelsCliUsecases {
141160

142161
// @ts-expect-error "tokenizer.ggml.tokens"
143162
stopWord = metadata['tokenizer.ggml.tokens'][index] ?? '';
144-
} catch (err) {
145-
console.log('Failed to get stop word: ', err);
146-
}
163+
} catch (err) {}
147164

148165
const stopWords: string[] = [];
149166
if (stopWord.length > 0) {
@@ -163,6 +180,7 @@ export class ModelsCliUsecases {
163180
description: '',
164181
settings: {
165182
prompt_template: promptTemplate,
183+
llama_model_path: sibling.rfilename,
166184
},
167185
parameters: {
168186
stop: stopWords,
@@ -209,8 +227,71 @@ export class ModelsCliUsecases {
209227
}
210228
}
211229

230+
/**
231+
* Fetch the model data from Jan's repo
232+
* @param modelId HuggingFace model id. e.g. "llama-3:7b"
233+
* @returns
234+
*/
235+
private async fetchJanRepoData(modelId: string) {
236+
const repo = modelId.split(':')[0];
237+
const tree = modelId.split(':')[1];
238+
const url = this.getRepoModelsUrl(`janhq/${repo}`, tree);
239+
const res = await fetch(url);
240+
const response:
241+
| {
242+
path: string;
243+
size: number;
244+
}[]
245+
| { error: string } = await res.json();
246+
247+
if ('error' in response && response.error != null) {
248+
throw new Error(response.error);
249+
}
250+
251+
const data: HuggingFaceRepoData = {
252+
siblings: Array.isArray(response)
253+
? response.map((e) => {
254+
return {
255+
rfilename: e.path,
256+
downloadUrl: `https://huggingface.co/janhq/${repo}/resolve/${tree}/${e.path}`,
257+
fileSize: e.size ?? 0,
258+
};
259+
})
260+
: [],
261+
tags: ['gguf'],
262+
id: modelId,
263+
modelId: modelId,
264+
author: 'janhq',
265+
sha: '',
266+
downloads: 0,
267+
lastModified: '',
268+
private: false,
269+
disabled: false,
270+
gated: false,
271+
pipeline_tag: 'text-generation',
272+
cardData: {},
273+
createdAt: '',
274+
};
275+
276+
AllQuantizations.forEach((quantization) => {
277+
data.siblings.forEach((sibling: any) => {
278+
if (!sibling.quantization && sibling.rfilename.includes(quantization)) {
279+
sibling.quantization = quantization;
280+
}
281+
});
282+
});
283+
284+
data.modelUrl = url;
285+
return data;
286+
}
287+
288+
/**
289+
* Fetches the model data from HuggingFace API
290+
* @param repoId HuggingFace model id. e.g. "janhq/llama-3"
291+
* @returns
292+
*/
212293
private async fetchHuggingFaceRepoData(repoId: string) {
213-
const sanitizedUrl = this.toHuggingFaceUrl(repoId);
294+
const sanitizedUrl = this.getRepoModelsUrl(repoId);
214295

215296
const res = await fetch(sanitizedUrl);
216297
const response = await res.json();
@@ -245,24 +326,7 @@ export class ModelsCliUsecases {
245326
return data;
246327
}
247328

248-
private toHuggingFaceUrl(repoId: string): string {
249-
try {
250-
const url = new URL(`https://huggingface.co/${repoId}`);
251-
if (url.host !== 'huggingface.co') {
252-
throw `Invalid Hugging Face repo URL: ${repoId}`;
253-
}
254-
255-
const paths = url.pathname.split('/').filter((e) => e.trim().length > 0);
256-
if (paths.length < 2) {
257-
throw `Invalid Hugging Face repo URL: ${repoId}`;
258-
}
259-
260-
return `${url.origin}/api/models/${paths[0]}/${paths[1]}`;
261-
} catch (err) {
262-
if (repoId.startsWith('https')) {
263-
throw new Error(`Cannot parse url: ${repoId}`);
264-
}
265-
throw err;
266-
}
329+
private getRepoModelsUrl(repoId: string, tree?: string): string {
330+
return `https://huggingface.co/api/models/${repoId}${tree ? `/tree/${tree}` : ''}`;
267331
}
268332
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const normalizeModelId = (modelId: string): string => {
2+
return modelId.replace(':', '%3A');
3+
};

cortex-js/src/infrastructure/providers/cortex/cortex.provider.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Model, ModelSettingParams } from '@/domain/models/model.interface';
66
import { HttpService } from '@nestjs/axios';
77
import { defaultCortexCppHost, defaultCortexCppPort } from 'constant';
88
import { readdirSync } from 'node:fs';
9+
import { normalizeModelId } from '@/infrastructure/commanders/utils/normalize-model-id';
910

1011
/**
1112
* A class that implements the InferenceExtension interface from the @janhq/core package.
@@ -32,7 +33,10 @@ export default class CortexProvider extends OAIEngineExtension {
3233
): Promise<void> {
3334
const modelsContainerDir = this.modelDir();
3435

35-
const modelFolderFullPath = join(modelsContainerDir, model.id);
36+
const modelFolderFullPath = join(
37+
modelsContainerDir,
38+
normalizeModelId(model.id),
39+
);
3640
const ggufFiles = readdirSync(modelFolderFullPath).filter((file) => {
3741
return file.endsWith('.gguf');
3842
});

cortex-js/src/usecases/cortex/cortex.usecases.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class CortexUsecases {
3232
);
3333

3434
if (!existsSync(cortexCppPath)) {
35-
throw new Error('Cortex binary not found');
35+
throw new Error('The engine is not available, please run "cortex init".');
3636
}
3737

3838
// go up one level to get the binary folder, have to also work on windows

cortex-js/src/usecases/models/models.usecases.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { ExtensionRepository } from '@/domain/repositories/extension.interface';
2323
import { EngineExtension } from '@/domain/abstracts/engine.abstract';
2424
import { HttpService } from '@nestjs/axios';
2525
import { ModelSettingParamsDto } from '@/infrastructure/dtos/models/model-setting-params.dto';
26+
import { normalizeModelId } from '@/infrastructure/commanders/utils/normalize-model-id';
2627

2728
@Injectable()
2829
export class ModelsUsecases {
@@ -106,7 +107,7 @@ export class ModelsUsecases {
106107
return;
107108
}
108109

109-
const modelFolder = join(modelsContainerDir, id);
110+
const modelFolder = join(modelsContainerDir, normalizeModelId(id));
110111

111112
return this.modelRepository
112113
.delete(id)
@@ -205,7 +206,7 @@ export class ModelsUsecases {
205206
mkdirSync(modelsContainerDir, { recursive: true });
206207
}
207208

208-
const modelFolder = join(modelsContainerDir, model.id);
209+
const modelFolder = join(modelsContainerDir, normalizeModelId(model.id));
209210
await promises.mkdir(modelFolder, { recursive: true });
210211
const destination = join(modelFolder, fileName);
211212

0 commit comments

Comments
 (0)