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:
@@ -25,11 +25,20 @@ export interface ConfigModelsResponse {
|
|||||||
groups: ModelGroup[]
|
groups: ModelGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelVisibilityRule {
|
||||||
|
mode: 'all' | 'include'
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelVisibility = Record<string, ModelVisibilityRule>
|
||||||
|
|
||||||
export interface AvailableModelGroup {
|
export interface AvailableModelGroup {
|
||||||
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
|
provider: string // credential pool key (e.g. "zai", "custom:subrouter.ai")
|
||||||
label: string // display name (e.g. "zai", "subrouter.ai")
|
label: string // display name (e.g. "zai", "subrouter.ai")
|
||||||
base_url: string
|
base_url: string
|
||||||
models: string[]
|
models: string[]
|
||||||
|
/** Full unfiltered model catalog for this provider, used to restore hidden WUI models. */
|
||||||
|
available_models?: string[]
|
||||||
api_key: string
|
api_key: string
|
||||||
builtin?: boolean
|
builtin?: boolean
|
||||||
/** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */
|
/** 可选:模型 ID -> 元数据(preview/disabled)。目前仅 Copilot 提供。 */
|
||||||
@@ -41,6 +50,7 @@ export interface AvailableModelsResponse {
|
|||||||
default_provider: string
|
default_provider: string
|
||||||
groups: AvailableModelGroup[]
|
groups: AvailableModelGroup[]
|
||||||
allProviders: AvailableModelGroup[]
|
allProviders: AvailableModelGroup[]
|
||||||
|
model_visibility?: ModelVisibility
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomProvider {
|
export interface CustomProvider {
|
||||||
@@ -104,3 +114,14 @@ export async function updateProvider(poolKey: string, data: {
|
|||||||
body: JSON.stringify(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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
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 type { AvailableModelGroup } from '@/api/hermes/system'
|
||||||
import { useModelsStore } from '@/stores/hermes/models'
|
import { useModelsStore } from '@/stores/hermes/models'
|
||||||
import { useAppStore } from '@/stores/hermes/app'
|
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 isCopilot = computed(() => props.provider.provider === 'copilot')
|
||||||
const displayName = computed(() => props.provider.label)
|
const displayName = computed(() => props.provider.label)
|
||||||
const deleting = ref(false)
|
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() {
|
async function handleDelete() {
|
||||||
let copilotMsg = ''
|
let copilotMsg = ''
|
||||||
@@ -93,7 +132,9 @@ async function handleDelete() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-row models-row">
|
<div class="info-row models-row">
|
||||||
<span class="info-label">{{ t('models.models') }}</span>
|
<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>
|
||||||
<div class="models-list">
|
<div class="models-list">
|
||||||
<span
|
<span
|
||||||
@@ -108,8 +149,46 @@ async function handleDelete() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions">
|
<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>
|
<NButton size="tiny" quaternary type="error" :loading="deleting" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -237,4 +316,48 @@ async function handleDelete() {
|
|||||||
border-top: 1px solid $border-light;
|
border-top: 1px solid $border-light;
|
||||||
padding-top: 10px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -553,6 +553,13 @@ export default {
|
|||||||
models: 'Models',
|
models: 'Models',
|
||||||
count: 'models',
|
count: 'models',
|
||||||
more: 'more',
|
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',
|
builtIn: 'Built-in',
|
||||||
customType: 'Custom',
|
customType: 'Custom',
|
||||||
provider: 'Provider',
|
provider: 'Provider',
|
||||||
|
|||||||
@@ -553,6 +553,13 @@ export default {
|
|||||||
models: '模型列表',
|
models: '模型列表',
|
||||||
count: '个模型',
|
count: '个模型',
|
||||||
more: '个更多',
|
more: '个更多',
|
||||||
|
manageVisibleModels: '管理可见模型',
|
||||||
|
manageVisibleModelsFor: '管理 {name} 可见模型',
|
||||||
|
visibilityHint: '仅影响 Web UI 的模型选择器和模型页展示,不会改写 Hermes CLI 的 provider/model 配置。实际调用仍使用原始模型 ID。',
|
||||||
|
visibilitySelectOne: '至少保留一个可见模型',
|
||||||
|
visibilitySaved: '可见模型已保存',
|
||||||
|
visibilitySaveFailed: '保存可见模型失败',
|
||||||
|
showAllModels: '显示全部模型',
|
||||||
builtIn: '内置',
|
builtIn: '内置',
|
||||||
customType: '自定义',
|
customType: '自定义',
|
||||||
provider: 'Provider',
|
provider: 'Provider',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
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__
|
const WEB_UI_VERSION = __APP_VERSION__
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const selectedModel = ref('')
|
const selectedModel = ref('')
|
||||||
const selectedProvider = ref('')
|
const selectedProvider = ref('')
|
||||||
const customModels = ref<Record<string, string[]>>({})
|
const customModels = ref<Record<string, string[]>>({})
|
||||||
|
const modelVisibility = ref<ModelVisibility>({})
|
||||||
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
const healthPollTimer = ref<ReturnType<typeof setInterval>>()
|
||||||
const nodeVersion = ref('')
|
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() {
|
async function loadModels() {
|
||||||
try {
|
try {
|
||||||
const res = await fetchAvailableModels()
|
const res = await fetchAvailableModels()
|
||||||
modelGroups.value = res.groups
|
applyAvailableModelsResponse(res)
|
||||||
selectedModel.value = res.default
|
|
||||||
selectedProvider.value = res.default_provider || ''
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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) {
|
function startHealthPolling(interval = 30000) {
|
||||||
stopHealthPolling()
|
stopHealthPolling()
|
||||||
checkConnection()
|
checkConnection()
|
||||||
@@ -134,6 +160,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
doUpdate,
|
doUpdate,
|
||||||
modelGroups,
|
modelGroups,
|
||||||
customModels,
|
customModels,
|
||||||
|
modelVisibility,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
streamEnabled,
|
streamEnabled,
|
||||||
@@ -141,7 +168,11 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
maxTokens,
|
maxTokens,
|
||||||
checkConnection,
|
checkConnection,
|
||||||
loadModels,
|
loadModels,
|
||||||
|
applyAvailableModelsResponse,
|
||||||
switchModel,
|
switchModel,
|
||||||
|
getProviderVisibility,
|
||||||
|
isModelVisible,
|
||||||
|
setModelVisibility,
|
||||||
startHealthPolling,
|
startHealthPolling,
|
||||||
stopHealthPolling,
|
stopHealthPolling,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const useModelsStore = defineStore('models', () => {
|
|||||||
providers.value = res.groups
|
providers.value = res.groups
|
||||||
allProviders.value = res.allProviders
|
allProviders.value = res.allProviders
|
||||||
defaultModel.value = res.default
|
defaultModel.value = res.default
|
||||||
|
const appStore = useAppStore()
|
||||||
|
appStore.applyAvailableModelsResponse(res)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch providers:', err)
|
console.error('Failed to fetch providers:', err)
|
||||||
} finally {
|
} 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 { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
|
||||||
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
|
||||||
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
|
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 { getDb } from '../../db'
|
||||||
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
|
import { MODEL_CONTEXT_TABLE } from '../../db/hermes/schemas'
|
||||||
|
|
||||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
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
|
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
|
||||||
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
|
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
|
||||||
async function isCopilotAuthorized(envContent: string): Promise<boolean> {
|
async function isCopilotAuthorized(envContent: string): Promise<boolean> {
|
||||||
@@ -41,7 +103,7 @@ export async function getAvailable(ctx: any) {
|
|||||||
currentDefault = modelSection.trim()
|
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>()
|
const seenProviders = new Set<string>()
|
||||||
|
|
||||||
let envContent = ''
|
let envContent = ''
|
||||||
@@ -57,10 +119,11 @@ export async function getAvailable(ctx: any) {
|
|||||||
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
|
||||||
return match?.[1]?.trim() || ''
|
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
|
if (seenProviders.has(provider)) return
|
||||||
seenProviders.add(provider)
|
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 => {
|
const isOAuthAuthorized = (providerKey: string): boolean => {
|
||||||
@@ -93,6 +156,7 @@ export async function getAvailable(ctx: any) {
|
|||||||
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
|
// 时也不返回。避免误把 VS Code/gh CLI 用户的全局凭证当作 hermes provider。
|
||||||
const appConfig = await readAppConfig()
|
const appConfig = await readAppConfig()
|
||||||
const copilotEnabled = appConfig.copilotEnabled === true
|
const copilotEnabled = appConfig.copilotEnabled === true
|
||||||
|
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
|
||||||
|
|
||||||
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
|
// 兼容老用户:上一版本会"自动 fallback discovery"出 Copilot;升级后这些用户的
|
||||||
// config.yaml 可能仍把 model.default 指向某个 copilot 模型。若此时 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)) }
|
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 模型用于 allProviders 展示(同一请求复用缓存)
|
||||||
// 未启用 Copilot 时跳过拉取,避免空跑网络请求。
|
// 未启用 Copilot 时跳过拉取,避免空跑网络请求。
|
||||||
@@ -199,11 +265,36 @@ export async function getAvailable(ctx: any) {
|
|||||||
|
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
const fallback = buildModelGroups(config)
|
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
|
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) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
ctx.body = { error: err.message }
|
ctx.body = { error: err.message }
|
||||||
@@ -362,3 +453,40 @@ export async function getModelContext(ctx: any) {
|
|||||||
ctx.body = { error: err.message }
|
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/available-models', ctrl.getAvailable)
|
||||||
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
|
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
|
||||||
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
|
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
|
||||||
|
modelRoutes.put('/api/hermes/model-visibility', ctrl.setModelVisibility)
|
||||||
|
|
||||||
// Model context routes
|
// Model context routes
|
||||||
modelRoutes.get('/api/hermes/model-context', ctrl.getModelContext)
|
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_HOME = join(homedir(), '.hermes-web-ui')
|
||||||
const APP_CONFIG_FILE = join(APP_HOME, 'config.json')
|
const APP_CONFIG_FILE = join(APP_HOME, 'config.json')
|
||||||
|
|
||||||
|
export interface ModelVisibilityRule {
|
||||||
|
mode: 'all' | 'include'
|
||||||
|
models: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
// Whether GitHub Copilot has been explicitly added by the user in web-ui.
|
// 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
|
// 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
|
// via "Add Provider". Mirrors how the user manages Codex/Nous: the web-ui
|
||||||
// owns the provider list, system credentials are merely a fallback source.
|
// owns the provider list, system credentials are merely a fallback source.
|
||||||
copilotEnabled?: boolean
|
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
|
let cache: AppConfig | null = null
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const mockSystemApi = vi.hoisted(() => ({
|
|||||||
checkHealth: vi.fn(),
|
checkHealth: vi.fn(),
|
||||||
fetchAvailableModels: vi.fn(),
|
fetchAvailableModels: vi.fn(),
|
||||||
updateDefaultModel: vi.fn(),
|
updateDefaultModel: vi.fn(),
|
||||||
|
updateModelVisibility: vi.fn(),
|
||||||
triggerUpdate: vi.fn(),
|
triggerUpdate: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -34,6 +35,77 @@ describe('App Store', () => {
|
|||||||
expect(window.localStorage.getItem('hermes_sidebar_collapsed')).toBe('0')
|
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 () => {
|
it('clears the updating state and reports failure when self-update request fails', async () => {
|
||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
|
mockSystemApi.triggerUpdate.mockRejectedValue(new Error('install failed'))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user