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
@@ -10,10 +10,57 @@ import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
type ModelMeta = { preview?: boolean; disabled?: boolean }
type ModelMeta = { preview?: boolean; disabled?: boolean; alias?: string }
type AvailableGroup = { provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record<string, ModelMeta>; available_models?: string[] }
type ModelVisibility = Record<string, ModelVisibilityRule>
const RESERVED_ALIAS_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
function isSafeAliasKey(value: string): boolean {
const trimmed = value.trim()
return !!trimmed && trimmed.length <= 512 && !RESERVED_ALIAS_KEYS.has(trimmed)
}
function createAliasMap(): Record<string, string> {
return Object.create(null) as Record<string, string>
}
function createProviderAliasMap(): Record<string, Record<string, string>> {
return Object.create(null) as Record<string, Record<string, string>>
}
function normalizeAliases(value: unknown): Record<string, Record<string, string>> {
const normalized = createProviderAliasMap()
if (!value || typeof value !== 'object' || Array.isArray(value)) return normalized
for (const [provider, models] of Object.entries(value as Record<string, unknown>)) {
if (!isSafeAliasKey(provider) || !models || typeof models !== 'object' || Array.isArray(models)) continue
for (const [model, alias] of Object.entries(models as Record<string, unknown>)) {
if (!isSafeAliasKey(model) || typeof alias !== 'string') continue
const trimmed = alias.trim()
if (!trimmed || trimmed.length > 512) continue
if (!Object.hasOwn(normalized, provider)) normalized[provider] = createAliasMap()
normalized[provider][model] = trimmed
}
}
return normalized
}
function applyModelAliases<T extends { provider: string; models: string[]; model_meta?: Record<string, ModelMeta> }>(groups: T[], aliases: Record<string, Record<string, string>>): T[] {
return groups.map((group) => {
const providerAliases = aliases[group.provider]
if (!providerAliases) return group
const modelMeta: Record<string, ModelMeta> = { ...(group.model_meta || {}) }
let changed = false
for (const model of group.models) {
const alias = providerAliases[model]
if (!alias) continue
modelMeta[model] = { ...(modelMeta[model] || {}), alias }
changed = true
}
return changed ? { ...group, model_meta: modelMeta } : group
})
}
function uniqueStrings(values: unknown): string[] {
if (!Array.isArray(values)) return []
return Array.from(new Set(values.map(v => String(v || '').trim()).filter(Boolean)))
@@ -158,6 +205,7 @@ export async function getAvailable(ctx: any) {
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
const appConfig = await readAppConfig()
const copilotEnabled = appConfig.copilotEnabled === true
const modelAliases = normalizeAliases(appConfig.modelAliases)
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
@@ -186,7 +234,7 @@ export async function getAvailable(ctx: any) {
}
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
let modelMeta: Record<string, { preview?: boolean; disabled?: boolean }> | undefined
let modelMeta: Record<string, ModelMeta> | undefined
if (providerKey === 'copilot') {
const live = await getCopilotLive()
if (live.length > 0) {
@@ -250,7 +298,8 @@ export async function getAvailable(ctx: any) {
}
for (const g of groups) { g.models = Array.from(new Set(g.models)) }
const visibleGroups = applyModelVisibility(groups, modelVisibility)
const groupsWithAliases = applyModelAliases(groups, modelAliases)
const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility)
const visibleDefault = resolveVisibleDefault(currentDefault, currentDefaultProvider, visibleGroups)
// 动态拉一次 copilot 模型用于 allProviders 展示(同一请求复用缓存)
@@ -264,6 +313,7 @@ export async function getAvailable(ctx: any) {
base_url: p.base_url,
models: p.value === 'copilot' && liveCopilotIds.length > 0 ? liveCopilotIds : p.models,
}))
const allProviders = applyModelAliases(allProvidersBase, modelAliases)
if (groups.length === 0) {
const fallback = buildModelGroups(config)
@@ -278,13 +328,15 @@ export async function getAvailable(ctx: any) {
api_key: '',
}
})
const visibleFallbackGroups = applyModelVisibility(fallbackGroups, modelVisibility)
const fallbackGroupsWithAliases = applyModelAliases(fallbackGroups, modelAliases)
const visibleFallbackGroups = applyModelVisibility(fallbackGroupsWithAliases, modelVisibility)
const fallbackDefault = resolveVisibleDefault(fallback.default, currentDefaultProvider, visibleFallbackGroups)
ctx.body = {
default: fallbackDefault.defaultModel,
default_provider: fallbackDefault.defaultProvider,
groups: visibleFallbackGroups,
allProviders: allProvidersBase,
allProviders,
model_aliases: modelAliases,
model_visibility: modelVisibility,
}
return
@@ -294,7 +346,8 @@ export async function getAvailable(ctx: any) {
default: visibleDefault.defaultModel,
default_provider: visibleDefault.defaultProvider,
groups: visibleGroups,
allProviders: allProvidersBase,
allProviders,
model_aliases: modelAliases,
model_visibility: modelVisibility,
}
} catch (err: any) {
@@ -303,6 +356,55 @@ export async function getAvailable(ctx: any) {
}
}
export async function setModelAlias(ctx: any) {
const body = ctx.request.body
const provider = body && typeof body === 'object' && !Array.isArray(body) ? body.provider : undefined
const model = body && typeof body === 'object' && !Array.isArray(body) ? body.model : undefined
const alias = body && typeof body === 'object' && !Array.isArray(body) ? body.alias : undefined
if (typeof provider !== 'string' || typeof model !== 'string' || (alias !== undefined && typeof alias !== 'string')) {
ctx.status = 400
ctx.body = { error: 'Invalid provider, model, or alias' }
return
}
const cleanProvider = provider.trim()
const cleanModel = model.trim()
const cleanAlias = (alias || '').trim()
if (!isSafeAliasKey(cleanProvider) || !isSafeAliasKey(cleanModel)) {
ctx.status = 400
ctx.body = { error: 'Invalid provider or model' }
return
}
if (cleanAlias.length > 512) {
ctx.status = 400
ctx.body = { error: 'Alias is too long' }
return
}
try {
const appConfig = await readAppConfig()
const modelAliases = normalizeAliases(appConfig.modelAliases)
if (cleanAlias) {
if (!Object.hasOwn(modelAliases, cleanProvider)) modelAliases[cleanProvider] = createAliasMap()
modelAliases[cleanProvider][cleanModel] = cleanAlias
} else {
if (Object.hasOwn(modelAliases, cleanProvider)) delete modelAliases[cleanProvider][cleanModel]
if (Object.hasOwn(modelAliases, cleanProvider) && Object.keys(modelAliases[cleanProvider]).length === 0) {
delete modelAliases[cleanProvider]
}
}
await writeAppConfig({ modelAliases })
ctx.body = { success: true, model_aliases: modelAliases }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function getConfigModels(ctx: any) {
try {
const config = await readConfigYaml()
@@ -6,6 +6,7 @@ export const modelRoutes = new Router()
modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable)
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
modelRoutes.put('/api/hermes/model-alias', ctrl.setModelAlias)
modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility)
// Model context routes
@@ -18,6 +18,10 @@ export interface AppConfig {
// owns the provider list, system credentials are merely a fallback source.
copilotEnabled?: boolean
// Web UI-only model display aliases. Keys are provider -> canonical model ID -> display label.
// These aliases never replace the canonical model ID sent back to Hermes.
modelAliases?: Record<string, Record<string, string>>
// Web UI-only model picker visibility. This filters what the WUI exposes in
// its sidebar/model pages and never renames or rewrites Hermes canonical
// provider/model IDs. Hermes CLI config remains the upstream source of truth.
@@ -256,7 +256,7 @@ function resolveHermesAgentRoot(): string {
'/opt/hermes',
join(homedir(), '.hermes', 'hermes-agent'), // Unix/Linux/macOS
]
// Windows specific path
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
candidates.push(join(process.env.LOCALAPPDATA, 'hermes', 'hermes-agent'))