Models:支持在 Web UI 里管理可见模型 (#613)

* feat(models): add WUI model visibility filter

Store provider model visibility in Web UI app config and filter the WUI model picker/model page without rewriting Hermes CLI config or canonical model IDs.

* fix(models): sync sidebar after visibility changes
This commit is contained in:
Zhicheng Han
2026-05-11 15:24:45 +02:00
committed by GitHub
parent c6fb449a19
commit 3a1893d401
12 changed files with 710 additions and 12 deletions
+72
View File
@@ -6,6 +6,7 @@ const mockSystemApi = vi.hoisted(() => ({
checkHealth: vi.fn(),
fetchAvailableModels: vi.fn(),
updateDefaultModel: vi.fn(),
updateModelVisibility: vi.fn(),
triggerUpdate: vi.fn(),
}))
@@ -34,6 +35,77 @@ describe('App Store', () => {
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
})
it('loads model visibility and falls back when the configured default is hidden', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-chat',
default_provider: 'deepseek',
groups: [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-reasoner'],
},
],
allProviders: [],
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const store = useAppStore()
await store.loadModels()
expect(store.modelVisibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
})
expect(store.selectedModel).toBe('deepseek-reasoner')
expect(store.selectedProvider).toBe('deepseek')
expect(store.isModelVisible('deepseek', 'deepseek-reasoner')).toBe(true)
expect(store.isModelVisible('deepseek', 'deepseek-chat')).toBe(false)
})
it('persists model visibility without changing the canonical selected model id', async () => {
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-reasoner',
default_provider: 'deepseek',
groups: [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-reasoner'],
},
],
allProviders: [],
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
mockSystemApi.updateModelVisibility.mockResolvedValue({
success: true,
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const store = useAppStore()
await store.setModelVisibility('deepseek', { mode: 'include', models: ['deepseek-reasoner'] })
expect(mockSystemApi.updateModelVisibility).toHaveBeenCalledWith({
provider: 'deepseek',
mode: 'include',
models: ['deepseek-reasoner'],
})
expect(store.selectedModel).toBe('deepseek-reasoner')
expect(store.selectedProvider).toBe('deepseek')
expect(mockSystemApi.updateDefaultModel).not.toHaveBeenCalled()
})
it('clears the updating state and reports failure when self-update request fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const mockSystemApi = vi.hoisted(() => ({
fetchAvailableModels: vi.fn(),
updateDefaultModel: vi.fn(),
addCustomProvider: vi.fn(),
removeCustomProvider: vi.fn(),
}))
vi.mock('@/api/hermes/system', () => mockSystemApi)
import { useAppStore } from '@/stores/hermes/app'
import { useModelsStore } from '@/stores/hermes/models'
describe('Models Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
window.localStorage.clear()
})
it('keeps the sidebar model picker in sync after provider model visibility changes', async () => {
const visibleGroups = [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
model_meta: {
'deepseek-v4-pro': { preview: true },
},
},
]
mockSystemApi.fetchAvailableModels.mockResolvedValue({
default: 'deepseek-v4-flash',
default_provider: 'deepseek',
groups: visibleGroups,
allProviders: visibleGroups,
model_visibility: {
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
},
})
const appStore = useAppStore()
appStore.modelGroups = [
{
provider: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
api_key: 'sk-test',
models: ['deepseek-v4-flash'],
available_models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
},
]
const modelsStore = useModelsStore()
await modelsStore.fetchProviders()
expect(modelsStore.providers[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
expect(appStore.modelGroups[0].models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
expect(appStore.modelGroups[0].available_models).toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
expect(appStore.modelGroups[0].model_meta).toEqual({
'deepseek-v4-pro': { preview: true },
})
expect(appStore.modelVisibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-v4-flash', 'deepseek-v4-pro'] },
})
expect(appStore.selectedModel).toBe('deepseek-v4-flash')
expect(appStore.selectedProvider).toBe('deepseek')
})
})
@@ -0,0 +1,221 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockReadFile, mockReadConfigYaml, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockReadConfigYaml: vi.fn(),
mockFetchProviderModels: vi.fn(),
mockBuildModelGroups: vi.fn(() => ({ default: '', groups: [] })),
mockReadAppConfig: vi.fn(),
mockWriteAppConfig: vi.fn(),
}))
vi.mock('fs/promises', () => ({
readFile: mockReadFile,
}))
vi.mock('fs', () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveEnvPath: () => '/fake/home/.hermes/.env',
getActiveAuthPath: () => '/fake/home/.hermes/auth.json',
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYaml: mockReadConfigYaml,
writeConfigYaml: vi.fn(),
fetchProviderModels: mockFetchProviderModels,
buildModelGroups: mockBuildModelGroups,
PROVIDER_ENV_MAP: {
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
},
}))
vi.mock('../../packages/server/src/shared/providers', () => ({
buildProviderModelMap: () => ({
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
}),
PROVIDER_PRESETS: [
{
value: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat', 'deepseek-reasoner'],
},
],
}))
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
getCopilotModelsDetailed: vi.fn(async () => []),
resolveCopilotOAuthToken: vi.fn(async () => ''),
}))
vi.mock('../../packages/server/src/services/app-config', () => ({
readAppConfig: mockReadAppConfig,
writeAppConfig: mockWriteAppConfig,
}))
vi.mock('../../packages/server/src/db', () => ({
getDb: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
MODEL_CONTEXT_TABLE: 'model_context',
}))
import * as ctrl from '../../packages/server/src/controllers/hermes/models'
function makeCtx(body: Record<string, unknown> = {}): any {
return { params: {}, query: {}, request: { body }, body: undefined, status: 200 }
}
beforeEach(() => {
vi.clearAllMocks()
mockReadFile.mockResolvedValue('DEEPSEEK_API_KEY=sk-test\n')
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
mockReadAppConfig.mockResolvedValue({})
mockWriteAppConfig.mockImplementation(async patch => patch)
})
describe('models controller — model visibility', () => {
it('filters available models per provider without changing canonical IDs', async () => {
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
},
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toHaveLength(1)
expect(ctx.body.groups[0]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-reasoner'],
available_models: ['deepseek-chat', 'deepseek-reasoner'],
})
expect(ctx.body.default).toBe('deepseek-reasoner')
expect(ctx.body.default_provider).toBe('deepseek')
expect(ctx.body.model_visibility).toEqual({
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
})
})
it('fails open for stale include rules so a provider can be recovered in the UI', async () => {
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
deepseek: { mode: 'include', models: ['missing-model'] },
},
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.body.groups[0]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-chat', 'deepseek-reasoner'],
available_models: ['deepseek-chat', 'deepseek-reasoner'],
})
})
it('applies visibility to the config fallback path when no credentialed providers are active', async () => {
mockReadFile.mockResolvedValue('')
mockReadConfigYaml.mockResolvedValue({
model: { default: 'custom-a' },
custom_providers: [
{ name: 'local', model: 'custom-a' },
{ name: 'local', model: 'custom-b' },
],
})
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
Custom: { mode: 'include', models: ['custom-b'] },
},
})
mockBuildModelGroups.mockReturnValue({
default: 'custom-a',
groups: [
{
provider: 'Custom',
models: [
{ id: 'custom-a', label: 'local: custom-a' },
{ id: 'custom-b', label: 'local: custom-b' },
],
},
],
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.body.groups).toEqual([
expect.objectContaining({
provider: 'Custom',
models: ['custom-b'],
available_models: ['custom-a', 'custom-b'],
}),
])
expect(ctx.body.default).toBe('custom-b')
expect(ctx.body.default_provider).toBe('Custom')
})
it('saves include visibility in web-ui app config only', async () => {
mockReadAppConfig.mockResolvedValue({ copilotEnabled: true })
mockWriteAppConfig.mockResolvedValue({
copilotEnabled: true,
modelVisibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
})
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: ['deepseek-chat', 'deepseek-chat', ''] })
await ctrl.setModelVisibility(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
modelVisibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
})
expect(ctx.body).toEqual({
success: true,
model_visibility: { deepseek: { mode: 'include', models: ['deepseek-chat'] } },
})
})
it('resets a provider to all models by deleting its web-ui visibility rule', async () => {
mockReadAppConfig.mockResolvedValue({
modelVisibility: {
deepseek: { mode: 'include', models: ['deepseek-chat'] },
openrouter: { mode: 'include', models: ['x'] },
},
})
mockWriteAppConfig.mockResolvedValue({
modelVisibility: {
openrouter: { mode: 'include', models: ['x'] },
},
})
const ctx = makeCtx({ provider: 'deepseek', mode: 'all', models: [] })
await ctrl.setModelVisibility(ctx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
modelVisibility: {
openrouter: { mode: 'include', models: ['x'] },
},
})
expect(ctx.body.model_visibility).toEqual({
openrouter: { mode: 'include', models: ['x'] },
})
})
it('rejects empty include lists', async () => {
const ctx = makeCtx({ provider: 'deepseek', mode: 'include', models: [] })
await ctrl.setModelVisibility(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body).toEqual({ error: 'Select at least one model' })
expect(mockWriteAppConfig).not.toHaveBeenCalled()
})
})