Persist custom Hermes models (#913)
This commit is contained in:
@@ -5,6 +5,8 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
const mockSystemApi = vi.hoisted(() => ({
|
||||
checkHealth: vi.fn(),
|
||||
fetchAvailableModels: vi.fn(),
|
||||
addCustomModel: vi.fn(),
|
||||
removeCustomModel: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
updateModelAlias: vi.fn(),
|
||||
updateModelVisibility: vi.fn(),
|
||||
@@ -20,6 +22,8 @@ describe('App Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockSystemApi.addCustomModel.mockResolvedValue({ success: true, custom_models: {} })
|
||||
mockSystemApi.removeCustomModel.mockResolvedValue({ success: true, custom_models: {} })
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -318,6 +322,32 @@ describe('App Store', () => {
|
||||
expect(store.customModels).toEqual({ deepseek: ['manually-supported-id'] })
|
||||
})
|
||||
|
||||
it('loads persisted custom models from the server response', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'gemma-4-26b-a4b-it',
|
||||
default_provider: 'google-ai-studio',
|
||||
groups: [{
|
||||
provider: 'google-ai-studio',
|
||||
label: 'Google AI Studio',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
models: ['gemma-4-26b-a4b-it'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
custom_models: {
|
||||
'google-ai-studio': ['gemma-4-26b-a4b-it'],
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('gemma-4-26b-a4b-it')
|
||||
expect(store.customModels).toEqual({
|
||||
'google-ai-studio': ['gemma-4-26b-a4b-it'],
|
||||
})
|
||||
})
|
||||
|
||||
it('saves and clears model aliases via the Web UI-only alias API', async () => {
|
||||
mockSystemApi.updateModelAlias.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
@@ -345,17 +375,70 @@ describe('App Store', () => {
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}]
|
||||
mockSystemApi.addCustomModel.mockResolvedValue({
|
||||
success: true,
|
||||
custom_models: { deepseek: ['test'] },
|
||||
})
|
||||
mockSystemApi.removeCustomModel.mockResolvedValue({
|
||||
success: true,
|
||||
custom_models: {},
|
||||
})
|
||||
|
||||
await store.switchModel('test', 'deepseek')
|
||||
expect(store.selectedModel).toBe('test')
|
||||
expect(store.customModels).toEqual({ deepseek: ['test'] })
|
||||
expect(mockSystemApi.addCustomModel).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
model: 'test',
|
||||
})
|
||||
|
||||
await store.removeCustomModel('test', 'deepseek')
|
||||
expect(store.customModels).toEqual({})
|
||||
expect(mockSystemApi.removeCustomModel).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
model: 'test',
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(mockSystemApi.updateDefaultModel).toHaveBeenLastCalledWith({
|
||||
default: 'deepseek-v4-flash',
|
||||
provider: 'deepseek',
|
||||
})
|
||||
})
|
||||
|
||||
it('removes deleted custom models from loaded model groups immediately', async () => {
|
||||
mockSystemApi.removeCustomModel.mockResolvedValue({
|
||||
success: true,
|
||||
custom_models: {},
|
||||
})
|
||||
const store = useAppStore()
|
||||
store.customModels = { deepseek: ['manual-model'] }
|
||||
store.modelGroups = [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash', 'manual-model'],
|
||||
available_models: ['deepseek-v4-flash', 'manual-model'],
|
||||
api_key: '',
|
||||
}]
|
||||
store.profileModelGroups = [{
|
||||
profile: 'default',
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash', 'manual-model'],
|
||||
available_models: ['deepseek-v4-flash', 'manual-model'],
|
||||
api_key: '',
|
||||
}],
|
||||
}]
|
||||
|
||||
await store.removeCustomModel('manual-model', 'deepseek')
|
||||
|
||||
expect(store.modelGroups[0].models).toEqual(['deepseek-v4-flash'])
|
||||
expect(store.modelGroups[0].available_models).toEqual(['deepseek-v4-flash'])
|
||||
expect(store.profileModelGroups[0].groups[0].models).toEqual(['deepseek-v4-flash'])
|
||||
expect(store.profileModelGroups[0].groups[0].available_models).toEqual(['deepseek-v4-flash'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,6 +130,27 @@ describe('models controller — model visibility', () => {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('merges Web UI custom models into available provider groups', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
customModels: {
|
||||
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.groups[0]).toMatchObject({
|
||||
provider: 'deepseek',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'],
|
||||
available_models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'],
|
||||
})
|
||||
expect(ctx.body.custom_models).toEqual({
|
||||
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
|
||||
})
|
||||
})
|
||||
it('accepts OAuth providers stored in credential_pool entries', async () => {
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
@@ -281,6 +302,63 @@ describe('models controller — model visibility', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('adds and removes custom models in web-ui app config only', async () => {
|
||||
mockReadAppConfig.mockResolvedValueOnce({
|
||||
customModels: { deepseek: ['existing'] },
|
||||
})
|
||||
mockWriteAppConfig.mockResolvedValueOnce({
|
||||
customModels: { deepseek: ['existing', 'manual-model'] },
|
||||
})
|
||||
|
||||
const addCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' })
|
||||
await ctrl.addCustomModel(addCtx)
|
||||
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({
|
||||
customModels: { deepseek: ['existing', 'manual-model'] },
|
||||
})
|
||||
expect(addCtx.body).toEqual({
|
||||
success: true,
|
||||
custom_models: { deepseek: ['existing', 'manual-model'] },
|
||||
})
|
||||
|
||||
mockReadAppConfig.mockResolvedValueOnce({
|
||||
customModels: { deepseek: ['existing', 'manual-model'] },
|
||||
})
|
||||
mockWriteAppConfig.mockResolvedValueOnce({
|
||||
customModels: { deepseek: ['existing'] },
|
||||
})
|
||||
|
||||
const removeCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' })
|
||||
await ctrl.removeCustomModel(removeCtx)
|
||||
|
||||
expect(mockWriteAppConfig).toHaveBeenLastCalledWith({
|
||||
customModels: { deepseek: ['existing'] },
|
||||
})
|
||||
expect(removeCtx.body).toEqual({
|
||||
success: true,
|
||||
custom_models: { deepseek: ['existing'] },
|
||||
})
|
||||
})
|
||||
|
||||
it('removes custom models from query params when DELETE body is missing', async () => {
|
||||
mockReadAppConfig.mockResolvedValueOnce({
|
||||
customModels: { deepseek: ['manual-model'] },
|
||||
})
|
||||
mockWriteAppConfig.mockResolvedValueOnce({
|
||||
customModels: {},
|
||||
})
|
||||
|
||||
const ctx = makeCtx()
|
||||
ctx.request.body = undefined
|
||||
ctx.query = { provider: 'deepseek', model: 'manual-model' }
|
||||
|
||||
await ctrl.removeCustomModel(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ customModels: {} })
|
||||
expect(ctx.body).toEqual({ success: true, custom_models: {} })
|
||||
})
|
||||
|
||||
it('rejects empty include lists', async () => {
|
||||
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: [] })
|
||||
await ctrl.setModelVisibility(ctx)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { bridgeTerminalError } from '../../packages/server/src/services/hermes/run-chat/handle-bridge-run'
|
||||
|
||||
describe('bridge terminal error detection', () => {
|
||||
it('uses bridge status errors directly', () => {
|
||||
expect(bridgeTerminalError({
|
||||
status: 'error',
|
||||
error: 'bridge crashed',
|
||||
result: null,
|
||||
} as any)).toBe('bridge crashed')
|
||||
})
|
||||
|
||||
it('surfaces agent result failure flags as run failures', () => {
|
||||
expect(bridgeTerminalError({
|
||||
status: 'complete',
|
||||
error: undefined,
|
||||
result: {
|
||||
failed: true,
|
||||
completed: false,
|
||||
error: 'API call failed after 3 retries. HTTP 503: no available channel',
|
||||
final_response: 'API call failed after 3 retries. HTTP 503: no available channel',
|
||||
},
|
||||
} as any)).toBe('API call failed after 3 retries. HTTP 503: no available channel')
|
||||
})
|
||||
|
||||
it('falls back to final_response for failed results without an error field', () => {
|
||||
expect(bridgeTerminalError({
|
||||
status: 'complete',
|
||||
result: {
|
||||
completed: false,
|
||||
final_response: 'API call failed after 3 retries: timeout',
|
||||
},
|
||||
} as any)).toBe('API call failed after 3 retries: timeout')
|
||||
})
|
||||
|
||||
it('surfaces HTTP auth/provider errors even when failure flags are missing', () => {
|
||||
expect(bridgeTerminalError({
|
||||
status: 'complete',
|
||||
result: {
|
||||
final_response: 'API call failed after 3 retries. HTTP 403: forbidden',
|
||||
},
|
||||
} as any)).toBe('API call failed after 3 retries. HTTP 403: forbidden')
|
||||
|
||||
expect(bridgeTerminalError({
|
||||
status: 'complete',
|
||||
result: {
|
||||
error: 'HTTP 401: unauthorized',
|
||||
},
|
||||
} as any)).toBe('HTTP 401: unauthorized')
|
||||
})
|
||||
|
||||
it('surfaces generic provider result errors even without failed flags', () => {
|
||||
expect(bridgeTerminalError({
|
||||
status: 'complete',
|
||||
result: {
|
||||
error: '分组 subrouter 下模型 test 无可用渠道(distributor)',
|
||||
},
|
||||
} as any)).toBe('分组 subrouter 下模型 test 无可用渠道(distributor)')
|
||||
})
|
||||
|
||||
it('does not flag successful complete results', () => {
|
||||
expect(bridgeTerminalError({
|
||||
status: 'complete',
|
||||
result: {
|
||||
completed: true,
|
||||
final_response: 'done',
|
||||
},
|
||||
} as any)).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user