Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: remote engine management #4364

Merged
merged 31 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b7a86a0
feat: remote engine management
urmauur Dec 30, 2024
68e05ee
chore: fix linter issue
urmauur Dec 30, 2024
323700f
chore: remove unused imports
louis-menlo Jan 2, 2025
22e9db0
fix: populate engines, models and legacy settings (#4403)
louis-menlo Jan 6, 2025
b62db41
fix: check exist path before reading
louis-menlo Jan 6, 2025
3eb4295
fix: engines and models persist - race condition
louis-menlo Jan 6, 2025
3e48008
chore: update issue state
urmauur Jan 6, 2025
dc84557
test: update test cases
louis-menlo Jan 6, 2025
7fd8950
chore: bring back Cortex extension settings
louis-menlo Jan 6, 2025
e108f49
chore: setup button gear / plus based apikey
urmauur Jan 6, 2025
3fa8f41
chore: fix remote engine from welcome screen
urmauur Jan 8, 2025
7fd0dd6
chore: resolve linter issue
urmauur Jan 8, 2025
0df0983
chore: support request headers template
louis-menlo Jan 8, 2025
db75c9b
chore: update engines using header_template instead of api_key_template
louis-menlo Jan 8, 2025
a50582a
chore: update models on changes
louis-menlo Jan 8, 2025
5d8d4b6
fix: anthropic response template
louis-menlo Jan 8, 2025
f47392b
chore: fix welcome screen and debounce update value input
urmauur Jan 8, 2025
0468afb
chore: update engines list on changes
louis-menlo Jan 8, 2025
8e6ed11
chore: update engines list on change
louis-menlo Jan 8, 2025
67625df
chore: update desc form add modal remote engines
urmauur Jan 8, 2025
c11ec3c
chore: bump cortex version to latest RC
louis-menlo Jan 13, 2025
06188a2
chore: fix linter
louis-menlo Jan 13, 2025
a15d2ba
fix: transform payload of Anthropic and OpenAI
louis-menlo Jan 13, 2025
9094d02
fix: typo
urmauur Jan 14, 2025
5d56d1e
fix: openrouter model id for auto routing
louis-menlo Jan 14, 2025
dffb37d
chore: remove remote engine URL setting
louis-menlo Jan 14, 2025
fe67931
chore: add cohere engine and model support
louis-menlo Jan 14, 2025
a54e0f6
fix: should not clean on app launch - models list display issue
louis-menlo Jan 14, 2025
3511583
fix: local engine check logic
louis-menlo Jan 14, 2025
ca55df3
chore: bump app version to latest release 0.5.13
louis-menlo Jan 14, 2025
a63322b
test: fix failed tests
louis-menlo Jan 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions core/src/browser/extensions/engines/helpers/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export function requestInference(
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Accept': model.parameters?.stream ? 'text/event-stream' : 'application/json',
'Accept': model.parameters?.stream
? 'text/event-stream'
: 'application/json',
...headers,
},
body: JSON.stringify(requestBody),
Expand All @@ -47,12 +49,24 @@ export function requestInference(
}
// There could be overriden stream parameter in the model
// that is set in request body (transformed payload)
if (requestBody?.stream === false || model.parameters?.stream === false) {
if (
requestBody?.stream === false ||
model.parameters?.stream === false
) {
const data = await response.json()
if (data.error || data.message) {
subscriber.error(data.error ?? data)
subscriber.complete()
return
}
if (transformResponse) {
subscriber.next(transformResponse(data))
} else {
subscriber.next(data.choices[0]?.message?.content ?? '')
subscriber.next(
data.choices
? data.choices[0]?.message?.content
: (data.content[0]?.text ?? '')
)
}
} else {
const stream = response.body
Expand Down
31 changes: 25 additions & 6 deletions core/src/browser/extensions/enginesManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Engines,
EngineVariant,
EngineReleased,
EngineConfig,
DefaultEngineVariant,
} from '../../types'
import { BaseExtension, ExtensionTypeEnum } from '../extension'
Expand Down Expand Up @@ -55,8 +56,16 @@ export abstract class EngineManagementExtension extends BaseExtension {
* @returns A Promise that resolves to intall of engine.
*/
abstract installEngine(
name: InferenceEngine,
engineConfig: { variant: string; version?: string }
name: string,
engineConfig: EngineConfig
): Promise<{ messages: string }>

/**
* Add a new remote engine
* @returns A Promise that resolves to intall of engine.
*/
abstract addRemoteEngine(
engineConfig: EngineConfig
): Promise<{ messages: string }>

/**
Expand All @@ -65,14 +74,16 @@ export abstract class EngineManagementExtension extends BaseExtension {
*/
abstract uninstallEngine(
name: InferenceEngine,
engineConfig: { variant: string; version: string }
engineConfig: EngineConfig
): Promise<{ messages: string }>

/**
* @param name - Inference engine name.
* @returns A Promise that resolves to an object of default engine.
*/
abstract getDefaultEngineVariant(name: InferenceEngine): Promise<DefaultEngineVariant>
abstract getDefaultEngineVariant(
name: InferenceEngine
): Promise<DefaultEngineVariant>

/**
* @body variant - string
Expand All @@ -81,11 +92,19 @@ export abstract class EngineManagementExtension extends BaseExtension {
*/
abstract setDefaultEngineVariant(
name: InferenceEngine,
engineConfig: { variant: string; version: string }
engineConfig: EngineConfig
): Promise<{ messages: string }>

/**
* @returns A Promise that resolves to update engine.
*/
abstract updateEngine(name: InferenceEngine): Promise<{ messages: string }>
abstract updateEngine(
name: InferenceEngine,
engineConfig?: EngineConfig
): Promise<{ messages: string }>

/**
* @returns A Promise that resolves to an object of remote models list .
*/
abstract getRemoteModels(name: InferenceEngine | string): Promise<any>
}
33 changes: 12 additions & 21 deletions core/src/node/helper/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,19 @@
import { getEngineConfiguration } from './config';
import { getAppConfigurations, defaultAppConfig } from './config';

import { getJanExtensionsPath } from './config';
import { getJanDataFolderPath } from './config';
it('should return undefined for invalid engine ID', async () => {
const config = await getEngineConfiguration('invalid_engine');
expect(config).toBeUndefined();
});
import { getAppConfigurations, defaultAppConfig } from './config'

import { getJanExtensionsPath, getJanDataFolderPath } from './config'

it('should return default config when CI is e2e', () => {
process.env.CI = 'e2e';
const config = getAppConfigurations();
expect(config).toEqual(defaultAppConfig());
});

process.env.CI = 'e2e'
const config = getAppConfigurations()
expect(config).toEqual(defaultAppConfig())
})

it('should return extensions path when retrieved successfully', () => {
const extensionsPath = getJanExtensionsPath();
expect(extensionsPath).not.toBeUndefined();
});

const extensionsPath = getJanExtensionsPath()
expect(extensionsPath).not.toBeUndefined()
})

it('should return data folder path when retrieved successfully', () => {
const dataFolderPath = getJanDataFolderPath();
expect(dataFolderPath).not.toBeUndefined();
});
const dataFolderPath = getJanDataFolderPath()
expect(dataFolderPath).not.toBeUndefined()
})
108 changes: 20 additions & 88 deletions core/src/node/helper/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { AppConfiguration, SettingComponentProps } from '../../types'
import { AppConfiguration } from '../../types'
import { join, resolve } from 'path'
import fs from 'fs'
import os from 'os'
import childProcess from 'child_process'
const configurationFileName = 'settings.json'

/**
Expand All @@ -19,7 +18,9 @@ export const getAppConfigurations = (): AppConfiguration => {

if (!fs.existsSync(configurationFile)) {
// create default app config if we don't have one
console.debug(`App config not found, creating default config at ${configurationFile}`)
console.debug(
`App config not found, creating default config at ${configurationFile}`
)
fs.writeFileSync(configurationFile, JSON.stringify(appDefaultConfiguration))
return appDefaultConfiguration
}
Expand All @@ -30,20 +31,28 @@ export const getAppConfigurations = (): AppConfiguration => {
)
return appConfigurations
} catch (err) {
console.error(`Failed to read app config, return default config instead! Err: ${err}`)
console.error(
`Failed to read app config, return default config instead! Err: ${err}`
)
return defaultAppConfig()
}
}

const getConfigurationFilePath = () =>
join(
global.core?.appPath() || process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME'],
global.core?.appPath() ||
process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME'],
configurationFileName
)

export const updateAppConfiguration = (configuration: AppConfiguration): Promise<void> => {
export const updateAppConfiguration = (
configuration: AppConfiguration
): Promise<void> => {
const configurationFile = getConfigurationFilePath()
console.debug('updateAppConfiguration, configurationFile: ', configurationFile)
console.debug(
'updateAppConfiguration, configurationFile: ',
configurationFile
)

fs.writeFileSync(configurationFile, JSON.stringify(configuration))
return Promise.resolve()
Expand All @@ -69,86 +78,6 @@ export const getJanExtensionsPath = (): string => {
return join(appConfigurations.data_folder, 'extensions')
}

/**
* Utility function to physical cpu count
*
* @returns {number} The physical cpu count.
*/
export const physicalCpuCount = async (): Promise<number> => {
const platform = os.platform()
try {
if (platform === 'linux') {
const output = await exec('lscpu -p | egrep -v "^#" | sort -u -t, -k 2,4 | wc -l')
return parseInt(output.trim(), 10)
} else if (platform === 'darwin') {
const output = await exec('sysctl -n hw.physicalcpu_max')
return parseInt(output.trim(), 10)
} else if (platform === 'win32') {
const output = await exec('WMIC CPU Get NumberOfCores')
return output
.split(os.EOL)
.map((line: string) => parseInt(line))
.filter((value: number) => !isNaN(value))
.reduce((sum: number, number: number) => sum + number, 1)
} else {
const cores = os.cpus().filter((cpu: any, index: number) => {
const hasHyperthreading = cpu.model.includes('Intel')
const isOdd = index % 2 === 1
return !hasHyperthreading || isOdd
})
return cores.length
}
} catch (err) {
console.warn('Failed to get physical CPU count', err)
// Divide by 2 to get rid of hyper threading
const coreCount = Math.ceil(os.cpus().length / 2)
console.debug('Using node API to get physical CPU count:', coreCount)
return coreCount
}
}

const exec = async (command: string): Promise<string> => {
return new Promise((resolve, reject) => {
childProcess.exec(command, { encoding: 'utf8' }, (error, stdout) => {
if (error) {
reject(error)
} else {
resolve(stdout)
}
})
})
}

// a hacky way to get the api key. we should comes up with a better
// way to handle this
export const getEngineConfiguration = async (engineId: string) => {
if (engineId !== 'openai' && engineId !== 'groq') return undefined

const settingDirectoryPath = join(
getJanDataFolderPath(),
'settings',
'@janhq',
engineId === 'openai' ? 'inference-openai-extension' : 'inference-groq-extension',
'settings.json'
)

const content = fs.readFileSync(settingDirectoryPath, 'utf-8')
const settings: SettingComponentProps[] = JSON.parse(content)
const apiKeyId = engineId === 'openai' ? 'openai-api-key' : 'groq-api-key'
const keySetting = settings.find((setting) => setting.key === apiKeyId)
let fullUrl = settings.find((setting) => setting.key === 'chat-completions-endpoint')
?.controllerProps.value

let apiKey = keySetting?.controllerProps.value
if (typeof apiKey !== 'string') apiKey = ''
if (typeof fullUrl !== 'string') fullUrl = ''

return {
api_key: apiKey,
full_url: fullUrl,
}
}

/**
* Default app configurations
* App Data Folder default to Electron's userData
Expand All @@ -158,7 +87,10 @@ export const getEngineConfiguration = async (engineId: string) => {
*/
export const defaultAppConfig = (): AppConfiguration => {
const { app } = require('electron')
const defaultJanDataFolder = join(app?.getPath('userData') ?? os?.homedir() ?? '', 'data')
const defaultJanDataFolder = join(
app?.getPath('userData') ?? os?.homedir() ?? '',
'data'
)
return {
data_folder:
process.env.CI === 'e2e'
Expand Down
14 changes: 4 additions & 10 deletions core/src/node/helper/resource.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import { getSystemResourceInfo } from './resource';
import { getSystemResourceInfo } from './resource'

it('should return the correct system resource information with a valid CPU count', async () => {
const mockCpuCount = 4;
jest.spyOn(require('./config'), 'physicalCpuCount').mockResolvedValue(mockCpuCount);
const logSpy = jest.spyOn(require('./logger'), 'log').mockImplementation(() => {});

const result = await getSystemResourceInfo();
const result = await getSystemResourceInfo()

expect(result).toEqual({
numCpuPhysicalCore: mockCpuCount,
memAvailable: 0,
});
expect(logSpy).toHaveBeenCalledWith(`[CORTEX]::CPU information - ${mockCpuCount}`);
});
})
})
6 changes: 0 additions & 6 deletions core/src/node/helper/resource.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { SystemResourceInfo } from '../../types'
import { physicalCpuCount } from './config'
import { log } from './logger'

export const getSystemResourceInfo = async (): Promise<SystemResourceInfo> => {
const cpu = await physicalCpuCount()
log(`[CORTEX]::CPU information - ${cpu}`)

return {
numCpuPhysicalCore: cpu,
memAvailable: 0, // TODO: this should not be 0
}
}
28 changes: 27 additions & 1 deletion core/src/types/engine/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { InferenceEngine } from '../../types'

export type Engines = {
[key in InferenceEngine]: EngineVariant[]
[key in InferenceEngine]: (EngineVariant & EngineConfig)[]
}

export type EngineMetadata = {
get_models_url?: string
header_template?: string
transform_req?: {
chat_completions?: {
url?: string
template?: string
}
}
transform_resp?: {
chat_completions?: {
template?: string
}
}
}

export type EngineVariant = {
Expand All @@ -23,6 +39,16 @@ export type EngineReleased = {
size: number
}

export type EngineConfig = {
engine?: string
version?: string
variant?: string
type?: string
url?: string
api_key?: string
metadata?: EngineMetadata
}

export enum EngineEvent {
OnEngineUpdate = 'OnEngineUpdate',
}
Loading
Loading