feat(models): 增加模型显示名重命名 (#614)
* feat(models): add WUI model display aliases Persist display-only model aliases in Web UI app config, surface them in the model selector/search, and keep canonical model IDs for Hermes calls. * fix(models): improve WUI model alias editing * fix(models): clarify unlisted model picker * fix(models): scope aliases to providers
This commit is contained in:
@@ -6,6 +6,7 @@ const mockSystemApi = vi.hoisted(() => ({
|
||||
checkHealth: vi.fn(),
|
||||
fetchAvailableModels: vi.fn(),
|
||||
updateDefaultModel: vi.fn(),
|
||||
updateModelAlias: vi.fn(),
|
||||
updateModelVisibility: vi.fn(),
|
||||
triggerUpdate: vi.fn(),
|
||||
}))
|
||||
@@ -35,8 +36,6 @@ 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',
|
||||
@@ -64,10 +63,57 @@ describe('App Store', () => {
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(store.customModels).toEqual({})
|
||||
expect(store.isModelVisible('deepseek', 'deepseek-reasoner')).toBe(true)
|
||||
expect(store.isModelVisible('deepseek', 'deepseek-chat')).toBe(false)
|
||||
})
|
||||
|
||||
it('loads aliases while falling back from a hidden default without rehydrating it as custom', 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'],
|
||||
available_models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
allProviders: [
|
||||
{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
api_key: 'sk-test',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
],
|
||||
model_aliases: {
|
||||
deepseek: { 'deepseek-reasoner': 'Reasoner Alias' },
|
||||
},
|
||||
model_visibility: {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.modelAliases).toEqual({
|
||||
deepseek: { 'deepseek-reasoner': 'Reasoner Alias' },
|
||||
})
|
||||
expect(store.modelVisibility).toEqual({
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
expect(store.selectedModel).toBe('deepseek-reasoner')
|
||||
expect(store.selectedProvider).toBe('deepseek')
|
||||
expect(store.displayModelName('deepseek-reasoner', 'deepseek')).toBe('Reasoner Alias')
|
||||
expect(store.customModels).toEqual({})
|
||||
})
|
||||
|
||||
it('persists model visibility without changing the canonical selected model id', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-reasoner',
|
||||
@@ -118,4 +164,127 @@ describe('App Store', () => {
|
||||
expect(consoleError).toHaveBeenCalledWith('Failed to update Hermes Web UI:', expect.any(Error))
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('loads model aliases and resolves display names without changing canonical IDs', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'deepseek-v4-flash',
|
||||
default_provider: 'deepseek',
|
||||
groups: [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
model_aliases: {
|
||||
deepseek: { 'deepseek-v4-flash': 'Flash Alias' },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(store.getModelAlias('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias')
|
||||
expect(store.displayModelName('deepseek-v4-flash', 'deepseek')).toBe('Flash Alias')
|
||||
expect(store.displayModelName('unknown', 'deepseek')).toBe('unknown')
|
||||
})
|
||||
|
||||
it('keeps aliases scoped to their provider when model IDs overlap', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'shared-model',
|
||||
default_provider: 'provider-a',
|
||||
groups: [
|
||||
{
|
||||
provider: 'provider-a',
|
||||
label: 'Provider A',
|
||||
base_url: 'https://a.example/v1',
|
||||
models: ['shared-model'],
|
||||
api_key: '',
|
||||
},
|
||||
{
|
||||
provider: 'provider-b',
|
||||
label: 'Provider B',
|
||||
base_url: 'https://b.example/v1',
|
||||
models: ['shared-model'],
|
||||
api_key: '',
|
||||
},
|
||||
],
|
||||
allProviders: [],
|
||||
model_aliases: {
|
||||
'provider-a': { 'shared-model': 'A Alias' },
|
||||
},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.displayModelName('shared-model', 'provider-a')).toBe('A Alias')
|
||||
expect(store.displayModelName('shared-model', 'provider-b')).toBe('shared-model')
|
||||
expect(store.displayModelName('shared-model')).toBe('A Alias')
|
||||
})
|
||||
|
||||
it('rehydrates an active unlisted default model as removable after loading models', async () => {
|
||||
mockSystemApi.fetchAvailableModels.mockResolvedValue({
|
||||
default: 'manually-supported-id',
|
||||
default_provider: 'deepseek',
|
||||
groups: [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}],
|
||||
allProviders: [],
|
||||
model_aliases: {},
|
||||
})
|
||||
const store = useAppStore()
|
||||
|
||||
await store.loadModels()
|
||||
|
||||
expect(store.selectedModel).toBe('manually-supported-id')
|
||||
expect(store.customModels).toEqual({ deepseek: ['manually-supported-id'] })
|
||||
})
|
||||
|
||||
it('saves and clears model aliases via the Web UI-only alias API', async () => {
|
||||
mockSystemApi.updateModelAlias.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
|
||||
await store.setModelAlias('deepseek-v4-flash', 'deepseek', ' Flash Alias ')
|
||||
|
||||
expect(mockSystemApi.updateModelAlias).toHaveBeenCalledWith({
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-flash',
|
||||
alias: 'Flash Alias',
|
||||
})
|
||||
expect(store.modelAliases).toEqual({ deepseek: { 'deepseek-v4-flash': 'Flash Alias' } })
|
||||
|
||||
await store.setModelAlias('deepseek-v4-flash', 'deepseek', '')
|
||||
expect(store.modelAliases).toEqual({})
|
||||
})
|
||||
|
||||
it('removes an unlisted custom model and falls back to a listed model when active', async () => {
|
||||
mockSystemApi.updateDefaultModel.mockResolvedValue(undefined)
|
||||
const store = useAppStore()
|
||||
store.modelGroups = [{
|
||||
provider: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-v4-flash'],
|
||||
api_key: '',
|
||||
}]
|
||||
|
||||
await store.switchModel('test', 'deepseek')
|
||||
expect(store.selectedModel).toBe('test')
|
||||
expect(store.customModels).toEqual({ deepseek: ['test'] })
|
||||
|
||||
await store.removeCustomModel('test', 'deepseek')
|
||||
expect(store.customModels).toEqual({})
|
||||
expect(store.selectedModel).toBe('deepseek-v4-flash')
|
||||
expect(mockSystemApi.updateDefaultModel).toHaveBeenLastCalledWith({
|
||||
default: 'deepseek-v4-flash',
|
||||
provider: 'deepseek',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||
mockReadAppConfig: vi.fn(),
|
||||
mockWriteAppConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/app-config', () => ({
|
||||
readAppConfig: mockReadAppConfig,
|
||||
writeAppConfig: mockWriteAppConfig,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
readConfigYaml: vi.fn(),
|
||||
writeConfigYaml: vi.fn(),
|
||||
fetchProviderModels: vi.fn(),
|
||||
buildModelGroups: vi.fn(() => ({ default: '', default_provider: '', groups: [] })),
|
||||
PROVIDER_ENV_MAP: {},
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/shared/providers', () => ({
|
||||
buildProviderModelMap: vi.fn(() => ({})),
|
||||
PROVIDER_PRESETS: [],
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
|
||||
getCopilotModelsDetailed: vi.fn(),
|
||||
resolveCopilotOAuthToken: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db', () => ({
|
||||
getDb: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/schemas', () => ({
|
||||
MODEL_CONTEXT_TABLE: 'model_context',
|
||||
}))
|
||||
|
||||
import { setModelAlias } from '../../packages/server/src/controllers/hermes/models'
|
||||
|
||||
describe('model alias controller', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWriteAppConfig.mockResolvedValue({})
|
||||
})
|
||||
|
||||
function createCtx(body: unknown) {
|
||||
return {
|
||||
request: { body },
|
||||
status: 200,
|
||||
body: undefined as unknown,
|
||||
}
|
||||
}
|
||||
|
||||
it('saves a trimmed alias in Web UI app config', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
modelAliases: {
|
||||
deepseek: { old: 'Old Alias' },
|
||||
},
|
||||
})
|
||||
const ctx = createCtx({ provider: 'deepseek', model: 'deepseek-v4-flash', alias: ' Flash Alias ' })
|
||||
|
||||
await setModelAlias(ctx)
|
||||
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({
|
||||
modelAliases: {
|
||||
deepseek: {
|
||||
old: 'Old Alias',
|
||||
'deepseek-v4-flash': 'Flash Alias',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(ctx.body).toEqual({
|
||||
success: true,
|
||||
model_aliases: {
|
||||
deepseek: {
|
||||
old: 'Old Alias',
|
||||
'deepseek-v4-flash': 'Flash Alias',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes an alias when alias is blank and removes empty provider entries', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({
|
||||
modelAliases: {
|
||||
deepseek: { 'deepseek-v4-flash': 'Flash Alias' },
|
||||
},
|
||||
})
|
||||
const ctx = createCtx({ provider: 'deepseek', model: 'deepseek-v4-flash', alias: ' ' })
|
||||
|
||||
await setModelAlias(ctx)
|
||||
|
||||
expect(mockWriteAppConfig).toHaveBeenCalledWith({ modelAliases: {} })
|
||||
expect(ctx.body).toEqual({ success: true, model_aliases: {} })
|
||||
})
|
||||
|
||||
it('rejects missing provider or model', async () => {
|
||||
const ctx = createCtx({ provider: 'deepseek', alias: 'Alias' })
|
||||
|
||||
await setModelAlias(ctx)
|
||||
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'Invalid provider, model, or alias' })
|
||||
expect(mockWriteAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores inherited Object.prototype names as own alias keys', async () => {
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
const ctx = createCtx({ provider: 'toString', model: 'valueOf', alias: 'Safe Alias' })
|
||||
|
||||
await setModelAlias(ctx)
|
||||
|
||||
const written = mockWriteAppConfig.mock.calls[0][0]
|
||||
expect(written.modelAliases.toString.valueOf).toBe('Safe Alias')
|
||||
expect(Object.prototype.hasOwnProperty.call(written.modelAliases, 'toString')).toBe(true)
|
||||
expect(Object.prototype.hasOwnProperty.call(written.modelAliases.toString, 'valueOf')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects reserved object keys to avoid prototype pollution', async () => {
|
||||
const ctx = createCtx({ provider: '__proto__', model: 'deepseek-v4-flash', alias: 'Alias' })
|
||||
|
||||
await setModelAlias(ctx)
|
||||
|
||||
expect(ctx.status).toBe(400)
|
||||
expect(ctx.body).toEqual({ error: 'Invalid provider or model' })
|
||||
expect(mockWriteAppConfig).not.toHaveBeenCalled()
|
||||
expect(({} as Record<string, unknown>).deepseek).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockReadFile, mockReadConfigYaml, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig } = vi.hoisted(() => ({
|
||||
const { mockReadFile, mockReadConfigYaml, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
|
||||
mockReadFile: vi.fn(),
|
||||
mockReadConfigYaml: vi.fn(),
|
||||
mockFetchProviderModels: vi.fn(),
|
||||
mockBuildModelGroups: vi.fn(() => ({ default: '', groups: [] })),
|
||||
mockReadAppConfig: vi.fn(),
|
||||
mockWriteAppConfig: vi.fn(),
|
||||
mockExistsSync: vi.fn(() => false),
|
||||
mockReadFileSync: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
@@ -14,8 +16,8 @@ vi.mock('fs/promises', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false),
|
||||
readFileSync: vi.fn(),
|
||||
existsSync: mockExistsSync,
|
||||
readFileSync: mockReadFileSync,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
@@ -30,12 +32,14 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||
buildModelGroups: mockBuildModelGroups,
|
||||
PROVIDER_ENV_MAP: {
|
||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY' },
|
||||
openrouter: {},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/shared/providers', () => ({
|
||||
buildProviderModelMap: () => ({
|
||||
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
openrouter: ['openrouter/auto'],
|
||||
}),
|
||||
PROVIDER_PRESETS: [
|
||||
{
|
||||
@@ -44,6 +48,12 @@ vi.mock('../../packages/server/src/shared/providers', () => ({
|
||||
base_url: 'https://api.deepseek.com/v1',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
value: 'openrouter',
|
||||
label: 'OpenRouter',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: ['openrouter/auto'],
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -78,6 +88,8 @@ beforeEach(() => {
|
||||
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
|
||||
mockReadAppConfig.mockResolvedValue({})
|
||||
mockWriteAppConfig.mockImplementation(async patch => patch)
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
mockReadFileSync.mockReturnValue('{}')
|
||||
})
|
||||
|
||||
describe('models controller — model visibility', () => {
|
||||
@@ -104,6 +116,27 @@ describe('models controller — model visibility', () => {
|
||||
deepseek: { mode: 'include', models: ['deepseek-reasoner'] },
|
||||
})
|
||||
})
|
||||
it('accepts OAuth providers stored in credential_pool entries', async () => {
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(JSON.stringify({
|
||||
credential_pool: {
|
||||
openrouter: [{ label: 'primary', access_token: 'oauth-token' }],
|
||||
},
|
||||
}))
|
||||
|
||||
const ctx = makeCtx()
|
||||
await ctrl.getAvailable(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body.groups).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: 'openrouter',
|
||||
label: 'OpenRouter',
|
||||
models: ['openrouter/auto'],
|
||||
available_models: ['openrouter/auto'],
|
||||
}),
|
||||
]))
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user