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
+21
View File
@@ -25,11 +25,20 @@ export interface ConfigModelsResponse {
groups: ModelGroup[]
}
export interface ModelVisibilityRule {
mode: 'all' | 'include'
models: string[]
}
export type ModelVisibility = Record<string, ModelVisibilityRule>
export interface AvailableModelGroup {
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
label: string // display name (e.g. "zai", "subrouter.ai")
base_url: string
models: string[]
/** Full unfiltered model catalog for this provider, used to restore hidden WUI models. */
available_models?: string[]
api_key: string
builtin?: boolean
/** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */
@@ -41,6 +50,7 @@ export interface AvailableModelsResponse {
default_provider: string
groups: AvailableModelGroup[]
allProviders: AvailableModelGroup[]
model_visibility?: ModelVisibility
}
export interface CustomProvider {
@@ -104,3 +114,14 @@ export async function updateProvider(poolKey: string, data: {
body: JSON.stringify(data),
})
}
export async function updateModelVisibility(data: {
provider: string
mode: 'all' | 'include'
models: string[]
}): Promise<{ success: boolean; model_visibility: ModelVisibility }> {
return request<{ success: boolean; model_visibility: ModelVisibility }>('/api/hermes/model-visibility', {
method: 'PUT',
body: JSON.stringify(data),
})
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { NButton, useMessage, useDialog } from 'naive-ui'
import { NButton, NCheckbox, NCheckboxGroup, NModal, useMessage, useDialog } from 'naive-ui'
import type { AvailableModelGroup } from '@/api/hermes/system'
import { useModelsStore } from '@/stores/hermes/models'
import { useAppStore } from '@/stores/hermes/app'
@@ -21,6 +21,45 @@ const isCustom = computed(() => !props.provider.builtin && props.provider.provid
const isCopilot = computed(() => props.provider.provider === 'copilot')
const displayName = computed(() => props.provider.label)
const deleting = ref(false)
const showVisibilityModal = ref(false)
const visibilitySaving = ref(false)
const selectedVisibleModels = ref<string[]>([])
const sourceProvider = computed(() => modelsStore.allProviders.find(g => g.provider === props.provider.provider))
const allModels = computed(() => props.provider.available_models?.length ? props.provider.available_models : (sourceProvider.value?.models?.length ? sourceProvider.value.models : props.provider.models))
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
function openVisibilityModal() {
const rule = appStore.getProviderVisibility(props.provider.provider)
selectedVisibleModels.value = rule.mode === 'include' ? allModels.value.filter(m => rule.models.includes(m)) : [...allModels.value]
showVisibilityModal.value = true
}
async function handleVisibilitySave() {
if (selectedVisibleModels.value.length === 0) {
message.error(t('models.visibilitySelectOne'))
return
}
visibilitySaving.value = true
try {
const selected = selectedVisibleModels.value.filter(m => allModels.value.includes(m))
const mode = selected.length === allModels.value.length ? 'all' : 'include'
await appStore.setModelVisibility(props.provider.provider, { mode, models: selected })
await modelsStore.fetchProviders()
showVisibilityModal.value = false
message.success(t('models.visibilitySaved'))
} catch (e: any) {
message.error(e.message || t('models.visibilitySaveFailed'))
} finally {
visibilitySaving.value = false
}
}
function resetVisibility() {
selectedVisibleModels.value = [...allModels.value]
}
async function handleDelete() {
let copilotMsg = ''
@@ -93,7 +132,9 @@ async function handleDelete() {
</div>
<div class="info-row models-row">
<span class="info-label">{{ t('models.models') }}</span>
<span class="info-value models-count">{{ provider.models.length }} {{ t('models.count') }}</span>
<span class="info-value models-count">
{{ isFiltered ? visibleCountLabel : provider.models.length }} {{ t('models.count') }}
</span>
</div>
<div class="models-list">
<span
@@ -108,8 +149,46 @@ async function handleDelete() {
</div>
<div class="card-actions">
<NButton size="tiny" quaternary @click="openVisibilityModal">{{ t('models.manageVisibleModels') }}</NButton>
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
</div>
<NModal
v-model:show="showVisibilityModal"
preset="card"
:title="t('models.manageVisibleModelsFor', { name: displayName })"
:style="{ width: 'min(560px, calc(100vw - 32px))' }"
:mask-closable="!visibilitySaving"
>
<p class="visibility-hint">{{ t('models.visibilityHint') }}</p>
<div class="visibility-count">
{{ selectedVisibleModels.length }}/{{ allModels.length }} {{ t('models.count') }}
</div>
<div class="visibility-list">
<NCheckboxGroup v-model:value="selectedVisibleModels">
<NCheckbox
v-for="model in allModels"
:key="model"
:value="model"
class="visibility-model"
>
<code>{{ model }}</code>
</NCheckbox>
</NCheckboxGroup>
</div>
<div class="visibility-actions">
<NButton size="small" quaternary :disabled="visibilitySaving" @click="resetVisibility">
{{ t('models.showAllModels') }}
</NButton>
<div class="visibility-action-spacer" />
<NButton size="small" :disabled="visibilitySaving" @click="showVisibilityModal = false">
{{ t('common.cancel') }}
</NButton>
<NButton size="small" type="primary" :loading="visibilitySaving" @click="handleVisibilitySave">
{{ t('common.save') }}
</NButton>
</div>
</NModal>
</div>
</template>
@@ -237,4 +316,48 @@ async function handleDelete() {
border-top: 1px solid $border-light;
padding-top: 10px;
}
.visibility-hint {
margin: 0 0 10px;
color: $text-secondary;
font-size: 13px;
line-height: 1.5;
}
.visibility-count {
color: $text-muted;
font-size: 12px;
margin-bottom: 10px;
}
.visibility-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid $border-light;
border-radius: $radius-sm;
padding: 8px;
}
.visibility-model {
display: flex;
width: 100%;
padding: 4px 2px;
code {
font-family: $font-code;
font-size: 12px;
color: $text-secondary;
}
}
.visibility-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.visibility-action-spacer {
flex: 1;
}
</style>
+7
View File
@@ -553,6 +553,13 @@ export default {
models: 'Models',
count: 'models',
more: 'more',
manageVisibleModels: 'Manage visible models',
manageVisibleModelsFor: 'Manage visible models for {name}',
visibilityHint: 'Only affects the Web UI model picker and Models page. Hermes CLI provider/model config is not rewritten; calls still use canonical model IDs.',
visibilitySelectOne: 'Keep at least one visible model',
visibilitySaved: 'Visible models saved',
visibilitySaveFailed: 'Failed to save visible models',
showAllModels: 'Show all models',
builtIn: 'Built-in',
customType: 'Custom',
provider: 'Provider',
+7
View File
@@ -553,6 +553,13 @@ export default {
models: '模型列表',
count: '个模型',
more: '个更多',
manageVisibleModels: '管理可见模型',
manageVisibleModelsFor: '管理 {name} 可见模型',
visibilityHint: '仅影响 Web UI 的模型选择器和模型页展示,不会改写 Hermes CLI 的 provider/model 配置。实际调用仍使用原始模型 ID。',
visibilitySelectOne: '至少保留一个可见模型',
visibilitySaved: '可见模型已保存',
visibilitySaveFailed: '保存可见模型失败',
showAllModels: '显示全部模型',
builtIn: '内置',
customType: '自定义',
provider: 'Provider',
+35 -4
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { checkHealth, fetchAvailableModels, updateDefaultModel, triggerUpdate, type AvailableModelGroup } from '@/api/hermes/system'
import { checkHealth, fetchAvailableModels, updateDefaultModel, updateModelVisibility, triggerUpdate, type AvailableModelGroup, type AvailableModelsResponse, type ModelVisibility, type ModelVisibilityRule } from '@/api/hermes/system'
const WEB_UI_VERSION = __APP_VERSION__
@@ -20,6 +20,7 @@ export const useAppStore = defineStore('app', () => {
const selectedModel = ref('')
const selectedProvider = ref('')
const customModels = ref<Record<string, string[]>>({})
const modelVisibility = ref<ModelVisibility>({})
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
const nodeVersion = ref('')
@@ -58,12 +59,21 @@ export const useAppStore = defineStore('app', () => {
}
}
function applyAvailableModelsResponse(res: AvailableModelsResponse) {
modelGroups.value = res.groups
modelVisibility.value = res.model_visibility || {}
const defaultGroup = res.groups.find(g => g.provider === (res.default_provider || '') && g.models.includes(res.default))
const inferredGroup = res.groups.find(g => g.models.includes(res.default))
const fallbackGroup = res.groups.find(g => g.models.length > 0)
const selectedGroup = defaultGroup || inferredGroup || fallbackGroup
selectedModel.value = selectedGroup ? (defaultGroup || inferredGroup ? res.default : selectedGroup.models[0]) : ''
selectedProvider.value = selectedGroup?.provider || ''
}
async function loadModels() {
try {
const res = await fetchAvailableModels()
modelGroups.value = res.groups
selectedModel.value = res.default
selectedProvider.value = res.default_provider || ''
applyAvailableModelsResponse(res)
} catch {
// ignore
}
@@ -89,6 +99,22 @@ export const useAppStore = defineStore('app', () => {
}
}
function getProviderVisibility(provider: string): ModelVisibilityRule {
return modelVisibility.value[provider] || { mode: 'all', models: [] }
}
function isModelVisible(provider: string, model: string): boolean {
const rule = getProviderVisibility(provider)
return rule.mode !== 'include' || rule.models.includes(model)
}
async function setModelVisibility(provider: string, rule: ModelVisibilityRule) {
const res = await updateModelVisibility({ provider, mode: rule.mode, models: rule.models })
modelVisibility.value = res.model_visibility || {}
await loadModels()
}
function startHealthPolling(interval = 30000) {
stopHealthPolling()
checkConnection()
@@ -134,6 +160,7 @@ export const useAppStore = defineStore('app', () => {
doUpdate,
modelGroups,
customModels,
modelVisibility,
selectedModel,
selectedProvider,
streamEnabled,
@@ -141,7 +168,11 @@ export const useAppStore = defineStore('app', () => {
maxTokens,
checkConnection,
loadModels,
applyAvailableModelsResponse,
switchModel,
getProviderVisibility,
isModelVisible,
setModelVisibility,
startHealthPolling,
stopHealthPolling,
}
@@ -37,6 +37,8 @@ export const useModelsStore = defineStore('models', () => {
providers.value = res.groups
allProviders.value = res.allProviders
defaultModel.value = res.default
const appStore = useAppStore()
appStore.applyAvailableModelsResponse(res)
} catch (err) {
console.error('Failed to fetch providers:', err)
} finally {
@@ -4,12 +4,74 @@ import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/herme
import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
import { readAppConfig } from '../../services/app-config'
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
import { getDb } from '../../db'
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
type ModelMeta = { preview?: boolean; disabled?: boolean }
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>
function uniqueStrings(values: unknown): string[] {
if (!Array.isArray(values)) return []
return Array.from(new Set(values.map(v => String(v || '').trim()).filter(Boolean)))
}
function normalizeModelVisibility(input: unknown): ModelVisibility {
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
const out: ModelVisibility = {}
for (const [provider, rawRule] of Object.entries(input as Record<string, unknown>)) {
const providerKey = String(provider || '').trim()
if (!providerKey || !rawRule || typeof rawRule !== 'object' || Array.isArray(rawRule)) continue
const rule = rawRule as { mode?: unknown; models?: unknown }
const mode = rule.mode === 'include' ? 'include' : 'all'
const models = uniqueStrings(rule.models)
if (mode === 'include') {
if (models.length > 0) out[providerKey] = { mode, models }
} else {
out[providerKey] = { mode: 'all', models: [] }
}
}
return out
}
function filterModelsForProvider(provider: string, models: string[], visibility: ModelVisibility): string[] {
const rule = visibility[provider]
if (!rule || rule.mode !== 'include') return models
const allowed = new Set(rule.models)
const visible = models.filter(model => allowed.has(model))
// If a stale hand-edited rule references models that are no longer present,
// fail open so the provider remains recoverable from the Web UI.
return visible.length > 0 ? visible : models
}
function applyModelVisibility(groups: AvailableGroup[], visibility: ModelVisibility): AvailableGroup[] {
return groups
.map(group => {
const availableModels = group.available_models || group.models
return {
...group,
available_models: availableModels,
models: filterModelsForProvider(group.provider, availableModels, visibility),
}
})
.filter(group => group.models.length > 0)
}
function resolveVisibleDefault(defaultModel: string, defaultProvider: string, groups: AvailableGroup[]) {
if (defaultModel) {
const explicit = groups.find(group => group.provider === defaultProvider && group.models.includes(defaultModel))
if (explicit) return { defaultModel, defaultProvider }
const inferred = groups.find(group => group.models.includes(defaultModel))
if (inferred) return { defaultModel, defaultProvider: inferred.provider }
}
const fallback = groups.find(group => group.models.length > 0)
return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' }
}
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
async function isCopilotAuthorized(envContent: string): Promise<boolean> {
@@ -41,7 +103,7 @@ export async function getAvailable(ctx: any) {
currentDefault = modelSection.trim()
}
const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string; builtin?: boolean; model_meta?: Record<string, { preview?: boolean; disabled?: boolean }> }> = []
const groups: AvailableGroup[] = []
const seenProviders = new Set<string>()
let envContent = ''
@@ -57,10 +119,11 @@ export async function getAvailable(ctx: any) {
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return match?.[1]?.trim() || ''
}
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, { preview?: boolean; disabled?: boolean }>) => {
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
if (seenProviders.has(provider)) return
seenProviders.add(provider)
groups.push({ provider, label, base_url, models: [...models], api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
const availableModels = [...models]
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
}
const isOAuthAuthorized = (providerKey: string): boolean => {
@@ -93,6 +156,7 @@ export async function getAvailable(ctx: any) {
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
const appConfig = await readAppConfig()
const copilotEnabled = appConfig.copilotEnabled === true
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
// config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 copilot 已不
@@ -184,6 +248,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 visibleDefault = resolveVisibleDefault(currentDefault, currentDefaultProvider, visibleGroups)
// 动态拉一次 copilot 模型用于 allProviders 展示(同一请求复用缓存)
// 未启用 Copilot 时跳过拉取,避免空跑网络请求。
@@ -199,11 +265,36 @@ export async function getAvailable(ctx: any) {
if (groups.length === 0) {
const fallback = buildModelGroups(config)
ctx.body = { ...fallback, allProviders: allProvidersBase }
const fallbackGroups: AvailableGroup[] = fallback.groups.map(group => {
const models = group.models.map(model => model.id)
return {
provider: group.provider,
label: group.provider,
base_url: '',
models,
available_models: models,
api_key: '',
}
})
const visibleFallbackGroups = applyModelVisibility(fallbackGroups, modelVisibility)
const fallbackDefault = resolveVisibleDefault(fallback.default, currentDefaultProvider, visibleFallbackGroups)
ctx.body = {
default: fallbackDefault.defaultModel,
default_provider: fallbackDefault.defaultProvider,
groups: visibleFallbackGroups,
allProviders: allProvidersBase,
model_visibility: modelVisibility,
}
return
}
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders: allProvidersBase }
ctx.body = {
default: visibleDefault.defaultModel,
default_provider: visibleDefault.defaultProvider,
groups: visibleGroups,
allProviders: allProvidersBase,
model_visibility: modelVisibility,
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
@@ -362,3 +453,40 @@ export async function getModelContext(ctx: any) {
ctx.body = { error: err.message }
}
}
export async function setModelVisibility(ctx: any) {
const { provider, mode, models } = ctx.request.body as { provider?: string; mode?: string; models?: string[] }
const providerKey = String(provider || '').trim()
if (!providerKey) {
ctx.status = 400
ctx.body = { error: 'Missing provider' }
return
}
if (mode !== 'all' && mode !== 'include') {
ctx.status = 400
ctx.body = { error: 'Invalid visibility mode' }
return
}
const selectedModels = uniqueStrings(models)
if (mode === 'include' && selectedModels.length === 0) {
ctx.status = 400
ctx.body = { error: 'Select at least one model' }
return
}
try {
const appConfig = await readAppConfig()
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
if (mode === 'all') {
delete modelVisibility[providerKey]
} else {
modelVisibility[providerKey] = { mode: 'include', models: selectedModels }
}
const saved = await writeAppConfig({ modelVisibility })
ctx.body = { success: true, model_visibility: normalizeModelVisibility(saved.modelVisibility) }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -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-visibility', ctrl.setModelVisibility)
// Model context routes
modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext)
@@ -5,6 +5,11 @@ import { homedir } from 'os'
const APP_HOME = join(homedir(), '.hermes-web-ui')
const APP_CONFIG_FILE = join(APP_HOME, 'config.json')
export interface ModelVisibilityRule {
mode: 'all' | 'include'
models: string[]
}
export interface AppConfig {
// Whether GitHub Copilot has been explicitly added by the user in web-ui.
// Default false: even when COPILOT_GITHUB_TOKEN / gh-cli / apps.json can
@@ -12,6 +17,11 @@ export interface AppConfig {
// via "Add Provider". Mirrors how the user manages Codex/Nous: the web-ui
// owns the provider list, system credentials are merely a fallback source.
copilotEnabled?: boolean
// 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.
modelVisibility?: Record<string, ModelVisibilityRule>
}
let cache: AppConfig | null = null
+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()
})
})