fix: HuggingFace provider should be non-deletable (#5856)

* fix: HuggingFace provider should be non-deletable

* refactor: rename const folder

* test: correct test case
This commit is contained in:
Louis 2025-07-22 23:32:37 +07:00 committed by GitHub
parent 8e9cd2566b
commit d347058d6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 122 additions and 97 deletions

View File

@ -12,12 +12,12 @@ import {
import { toast } from 'sonner' import { toast } from 'sonner'
import { CardItem } from '../Card' import { CardItem } from '../Card'
import { models } from 'token.js'
import { EngineManager } from '@janhq/core' import { EngineManager } from '@janhq/core'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { useRouter } from '@tanstack/react-router' import { useRouter } from '@tanstack/react-router'
import { route } from '@/constants/routes' import { route } from '@/constants/routes'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { predefinedProviders } from '@/consts/providers'
type Props = { type Props = {
provider?: ProviderObject provider?: ProviderObject
@ -28,7 +28,7 @@ const DeleteProvider = ({ provider }: Props) => {
const router = useRouter() const router = useRouter()
if ( if (
!provider || !provider ||
Object.keys(models).includes(provider.provider) || predefinedProviders.some((e) => e.provider === provider.provider) ||
EngineManager.instance().get(provider.provider) EngineManager.instance().get(provider.provider)
) )
return null return null

View File

@ -37,7 +37,7 @@ import { IconFolderPlus, IconLoader, IconRefresh } from '@tabler/icons-react'
import { getProviders } from '@/services/providers' import { getProviders } from '@/services/providers'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { predefinedProviders } from '@/mock/data' import { predefinedProviders } from '@/consts/providers'
import { useModelLoad } from '@/hooks/useModelLoad' import { useModelLoad } from '@/hooks/useModelLoad'
// as route.threadsDetail // as route.threadsDetail

View File

@ -22,7 +22,7 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { openAIProviderSettings } from '@/mock/data' import { openAIProviderSettings } from '@/consts/providers'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -38,7 +38,9 @@ function ModelProviders() {
const [name, setName] = useState('') const [name, setName] = useState('')
const createProvider = useCallback(() => { const createProvider = useCallback(() => {
if (providers.some((e) => e.provider.toLowerCase() === name.toLowerCase())) { if (
providers.some((e) => e.provider.toLowerCase() === name.toLowerCase())
) {
toast.error(t('providerAlreadyExists', { name })) toast.error(t('providerAlreadyExists', { name }))
return return
} }

View File

@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getProviders, fetchModelsFromProvider, updateSettings } from '../providers' import {
getProviders,
fetchModelsFromProvider,
updateSettings,
} from '../providers'
import { models as providerModels } from 'token.js' import { models as providerModels } from 'token.js'
import { predefinedProviders } from '@/mock/data' import { predefinedProviders } from '@/mock/data'
import { EngineManager } from '@janhq/core' import { EngineManager } from '@janhq/core'
@ -12,9 +16,9 @@ vi.mock('token.js', () => ({
models: { models: {
openai: { openai: {
models: ['gpt-3.5-turbo', 'gpt-4'], models: ['gpt-3.5-turbo', 'gpt-4'],
supportsToolCalls: ['gpt-3.5-turbo', 'gpt-4'] supportsToolCalls: ['gpt-3.5-turbo', 'gpt-4'],
} },
} },
})) }))
vi.mock('@/mock/data', () => ({ vi.mock('@/mock/data', () => ({
@ -27,73 +31,80 @@ vi.mock('@/mock/data', () => ({
settings: [], settings: [],
models: [ models: [
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' }, { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo' },
{ id: 'gpt-4', name: 'GPT-4' } { id: 'gpt-4', name: 'GPT-4' },
] ],
} },
] ],
})) }))
vi.mock('@janhq/core', () => ({ vi.mock('@janhq/core', () => ({
EngineManager: { EngineManager: {
instance: vi.fn(() => ({ instance: vi.fn(() => ({
engines: new Map([ engines: new Map([
['llamacpp', { [
inferenceUrl: 'http://localhost:1337/chat/completions', 'llamacpp',
getSettings: vi.fn(() => Promise.resolve([ {
{ inferenceUrl: 'http://localhost:1337/chat/completions',
key: 'apiKey', getSettings: vi.fn(() =>
title: 'API Key', Promise.resolve([
description: 'Your API key', {
controllerType: 'input', key: 'apiKey',
controllerProps: { value: '' } title: 'API Key',
} description: 'Your API key',
])) controllerType: 'input',
}] controllerProps: { value: '' },
]) },
})) ])
} ),
},
],
]),
})),
},
})) }))
vi.mock('../models', () => ({ vi.mock('../models', () => ({
fetchModels: vi.fn(() => Promise.resolve([ fetchModels: vi.fn(() =>
{ id: 'llama-2-7b', name: 'Llama 2 7B', description: 'Llama model' } Promise.resolve([
])) { id: 'llama-2-7b', name: 'Llama 2 7B', description: 'Llama model' },
])
),
})) }))
vi.mock('@/lib/extension', () => ({ vi.mock('@/lib/extension', () => ({
ExtensionManager: { ExtensionManager: {
getInstance: vi.fn(() => ({ getInstance: vi.fn(() => ({
getEngine: vi.fn() getEngine: vi.fn(),
})) })),
} },
})) }))
vi.mock('@tauri-apps/plugin-http', () => ({ vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn() fetch: vi.fn(),
})) }))
vi.mock('@/types/models', () => ({ vi.mock('@/types/models', () => ({
ModelCapabilities: { ModelCapabilities: {
COMPLETION: 'completion', COMPLETION: 'completion',
TOOLS: 'tools' TOOLS: 'tools',
}, },
DefaultToolUseSupportedModels: { DefaultToolUseSupportedModels: {
'gpt-4': 'gpt-4', 'gpt-4': 'gpt-4',
'gpt-3.5-turbo': 'gpt-3.5-turbo' 'gpt-3.5-turbo': 'gpt-3.5-turbo',
} },
})) }))
vi.mock('@/lib/predefined', () => ({ vi.mock('@/lib/predefined', () => ({
modelSettings: { modelSettings: {
temperature: { temperature: {
key: 'temperature', key: 'temperature',
controller_props: { value: 0.7 } controller_props: { value: 0.7 },
}, },
ctx_len: { ctx_len: {
key: 'ctx_len', key: 'ctx_len',
controller_props: { value: 2048 } controller_props: { value: 2048 },
} },
} },
})) }))
describe('providers service', () => { describe('providers service', () => {
@ -105,14 +116,14 @@ describe('providers service', () => {
it('should return builtin and runtime providers', async () => { it('should return builtin and runtime providers', async () => {
const providers = await getProviders() const providers = await getProviders()
expect(providers).toHaveLength(2) // 1 runtime + 1 builtin expect(providers).toHaveLength(9) // 8 runtime + 1 builtin
expect(providers.some(p => p.provider === 'llamacpp')).toBe(true) expect(providers.some((p) => p.provider === 'llamacpp')).toBe(true)
expect(providers.some(p => p.provider === 'openai')).toBe(true) expect(providers.some((p) => p.provider === 'openai')).toBe(true)
}) })
it('should map builtin provider models correctly', async () => { it('should map builtin provider models correctly', async () => {
const providers = await getProviders() const providers = await getProviders()
const openaiProvider = providers.find(p => p.provider === 'openai') const openaiProvider = providers.find((p) => p.provider === 'openai')
expect(openaiProvider).toBeDefined() expect(openaiProvider).toBeDefined()
expect(openaiProvider?.models).toHaveLength(2) expect(openaiProvider?.models).toHaveLength(2)
@ -122,7 +133,7 @@ describe('providers service', () => {
it('should create runtime providers from engine manager', async () => { it('should create runtime providers from engine manager', async () => {
const providers = await getProviders() const providers = await getProviders()
const llamacppProvider = providers.find(p => p.provider === 'llamacpp') const llamacppProvider = providers.find((p) => p.provider === 'llamacpp')
expect(llamacppProvider).toBeDefined() expect(llamacppProvider).toBeDefined()
expect(llamacppProvider?.base_url).toBe('http://localhost:1337') expect(llamacppProvider?.base_url).toBe('http://localhost:1337')
@ -136,44 +147,44 @@ describe('providers service', () => {
const mockResponse = { const mockResponse = {
ok: true, ok: true,
json: vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({
data: [ data: [{ id: 'gpt-3.5-turbo' }, { id: 'gpt-4' }],
{ id: 'gpt-3.5-turbo' }, }),
{ id: 'gpt-4' }
]
})
} }
vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any)
const provider = { const provider = {
provider: 'openai', provider: 'openai',
base_url: 'https://api.openai.com/v1', base_url: 'https://api.openai.com/v1',
api_key: 'test-key' api_key: 'test-key',
} as ModelProvider } as ModelProvider
const models = await fetchModelsFromProvider(provider) const models = await fetchModelsFromProvider(provider)
expect(fetchTauri).toHaveBeenCalledWith('https://api.openai.com/v1/models', { expect(fetchTauri).toHaveBeenCalledWith(
method: 'GET', 'https://api.openai.com/v1/models',
headers: { {
'Content-Type': 'application/json', method: 'GET',
'x-api-key': 'test-key', headers: {
'Authorization': 'Bearer test-key' 'Content-Type': 'application/json',
'x-api-key': 'test-key',
'Authorization': 'Bearer test-key',
},
} }
}) )
expect(models).toEqual(['gpt-3.5-turbo', 'gpt-4']) expect(models).toEqual(['gpt-3.5-turbo', 'gpt-4'])
}) })
it('should fetch models successfully with direct array format', async () => { it('should fetch models successfully with direct array format', async () => {
const mockResponse = { const mockResponse = {
ok: true, ok: true,
json: vi.fn().mockResolvedValue(['model1', 'model2']) json: vi.fn().mockResolvedValue(['model1', 'model2']),
} }
vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any)
const provider = { const provider = {
provider: 'custom', provider: 'custom',
base_url: 'https://api.custom.com', base_url: 'https://api.custom.com',
api_key: '' api_key: '',
} as ModelProvider } as ModelProvider
const models = await fetchModelsFromProvider(provider) const models = await fetchModelsFromProvider(provider)
@ -185,17 +196,14 @@ describe('providers service', () => {
const mockResponse = { const mockResponse = {
ok: true, ok: true,
json: vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({
models: [ models: [{ id: 'model1' }, 'model2'],
{ id: 'model1' }, }),
'model2'
]
})
} }
vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any)
const provider = { const provider = {
provider: 'custom', provider: 'custom',
base_url: 'https://api.custom.com' base_url: 'https://api.custom.com',
} as ModelProvider } as ModelProvider
const models = await fetchModelsFromProvider(provider) const models = await fetchModelsFromProvider(provider)
@ -205,26 +213,30 @@ describe('providers service', () => {
it('should throw error when provider has no base_url', async () => { it('should throw error when provider has no base_url', async () => {
const provider = { const provider = {
provider: 'custom' provider: 'custom',
} as ModelProvider } as ModelProvider
await expect(fetchModelsFromProvider(provider)).rejects.toThrow('Provider must have base_url configured') await expect(fetchModelsFromProvider(provider)).rejects.toThrow(
'Provider must have base_url configured'
)
}) })
it('should throw error when API response is not ok', async () => { it('should throw error when API response is not ok', async () => {
const mockResponse = { const mockResponse = {
ok: false, ok: false,
status: 404, status: 404,
statusText: 'Not Found' statusText: 'Not Found',
} }
vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any)
const provider = { const provider = {
provider: 'custom', provider: 'custom',
base_url: 'https://api.custom.com' base_url: 'https://api.custom.com',
} as ModelProvider } as ModelProvider
await expect(fetchModelsFromProvider(provider)).rejects.toThrow('Cannot connect to custom at https://api.custom.com') await expect(fetchModelsFromProvider(provider)).rejects.toThrow(
'Cannot connect to custom at https://api.custom.com'
)
}) })
it('should handle network errors gracefully', async () => { it('should handle network errors gracefully', async () => {
@ -232,16 +244,18 @@ describe('providers service', () => {
const provider = { const provider = {
provider: 'custom', provider: 'custom',
base_url: 'https://api.custom.com' base_url: 'https://api.custom.com',
} as ModelProvider } as ModelProvider
await expect(fetchModelsFromProvider(provider)).rejects.toThrow('Cannot connect to custom at https://api.custom.com') await expect(fetchModelsFromProvider(provider)).rejects.toThrow(
'Cannot connect to custom at https://api.custom.com'
)
}) })
it('should return empty array for unexpected response format', async () => { it('should return empty array for unexpected response format', async () => {
const mockResponse = { const mockResponse = {
ok: true, ok: true,
json: vi.fn().mockResolvedValue({ unexpected: 'format' }) json: vi.fn().mockResolvedValue({ unexpected: 'format' }),
} }
vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any) vi.mocked(fetchTauri).mockResolvedValue(mockResponse as any)
@ -249,13 +263,16 @@ describe('providers service', () => {
const provider = { const provider = {
provider: 'custom', provider: 'custom',
base_url: 'https://api.custom.com' base_url: 'https://api.custom.com',
} as ModelProvider } as ModelProvider
const models = await fetchModelsFromProvider(provider) const models = await fetchModelsFromProvider(provider)
expect(models).toEqual([]) expect(models).toEqual([])
expect(consoleSpy).toHaveBeenCalledWith('Unexpected response format from provider API:', { unexpected: 'format' }) expect(consoleSpy).toHaveBeenCalledWith(
'Unexpected response format from provider API:',
{ unexpected: 'format' }
)
consoleSpy.mockRestore() consoleSpy.mockRestore()
}) })
@ -264,12 +281,14 @@ describe('providers service', () => {
describe('updateSettings', () => { describe('updateSettings', () => {
it('should update provider settings successfully', async () => { it('should update provider settings successfully', async () => {
const mockEngine = { const mockEngine = {
updateSettings: vi.fn().mockResolvedValue(undefined) updateSettings: vi.fn().mockResolvedValue(undefined),
} }
const mockExtensionManager = { const mockExtensionManager = {
getEngine: vi.fn().mockReturnValue(mockEngine) getEngine: vi.fn().mockReturnValue(mockEngine),
} }
vi.mocked(ExtensionManager.getInstance).mockReturnValue(mockExtensionManager) vi.mocked(ExtensionManager.getInstance).mockReturnValue(
mockExtensionManager
)
const settings = [ const settings = [
{ {
@ -277,8 +296,8 @@ describe('providers service', () => {
title: 'API Key', title: 'API Key',
description: 'Your API key', description: 'Your API key',
controller_type: 'input', controller_type: 'input',
controller_props: { value: 'test-key' } controller_props: { value: 'test-key' },
} },
] as ProviderSetting[] ] as ProviderSetting[]
await updateSettings('openai', settings) await updateSettings('openai', settings)
@ -292,16 +311,18 @@ describe('providers service', () => {
controller_type: 'input', controller_type: 'input',
controller_props: { value: 'test-key' }, controller_props: { value: 'test-key' },
controllerType: 'input', controllerType: 'input',
controllerProps: { value: 'test-key' } controllerProps: { value: 'test-key' },
} },
]) ])
}) })
it('should handle missing engine gracefully', async () => { it('should handle missing engine gracefully', async () => {
const mockExtensionManager = { const mockExtensionManager = {
getEngine: vi.fn().mockReturnValue(null) getEngine: vi.fn().mockReturnValue(null),
} }
vi.mocked(ExtensionManager.getInstance).mockReturnValue(mockExtensionManager) vi.mocked(ExtensionManager.getInstance).mockReturnValue(
mockExtensionManager
)
const settings = [] as ProviderSetting[] const settings = [] as ProviderSetting[]
@ -312,12 +333,14 @@ describe('providers service', () => {
it('should handle settings with undefined values', async () => { it('should handle settings with undefined values', async () => {
const mockEngine = { const mockEngine = {
updateSettings: vi.fn().mockResolvedValue(undefined) updateSettings: vi.fn().mockResolvedValue(undefined),
} }
const mockExtensionManager = { const mockExtensionManager = {
getEngine: vi.fn().mockReturnValue(mockEngine) getEngine: vi.fn().mockReturnValue(mockEngine),
} }
vi.mocked(ExtensionManager.getInstance).mockReturnValue(mockExtensionManager) vi.mocked(ExtensionManager.getInstance).mockReturnValue(
mockExtensionManager
)
const settings = [ const settings = [
{ {
@ -325,8 +348,8 @@ describe('providers service', () => {
title: 'API Key', title: 'API Key',
description: 'Your API key', description: 'Your API key',
controller_type: 'input', controller_type: 'input',
controller_props: { value: undefined } controller_props: { value: undefined },
} },
] as ProviderSetting[] ] as ProviderSetting[]
await updateSettings('openai', settings) await updateSettings('openai', settings)
@ -339,8 +362,8 @@ describe('providers service', () => {
controller_type: 'input', controller_type: 'input',
controller_props: { value: undefined }, controller_props: { value: undefined },
controllerType: 'input', controllerType: 'input',
controllerProps: { value: '' } controllerProps: { value: '' },
} },
]) ])
}) })
}) })

View File

@ -1,5 +1,5 @@
import { models as providerModels } from 'token.js' import { models as providerModels } from 'token.js'
import { predefinedProviders } from '@/mock/data' import { predefinedProviders } from '@/consts/providers'
import { EngineManager, SettingComponentProps } from '@janhq/core' import { EngineManager, SettingComponentProps } from '@janhq/core'
import { import {
DefaultToolUseSupportedModels, DefaultToolUseSupportedModels,