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:
Zhicheng Han
2026-05-11 16:18:13 +02:00
committed by GitHub
parent 7b781b4f42
commit b8be47d8d6
20 changed files with 898 additions and 57 deletions
+130
View File
@@ -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'],
}),
]))
})