Files
hermes-ui/tests/server/model-visibility-controller.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

651 lines
22 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockReadFile, mockReadConfigYaml, mockReadConfigYamlForProfile, mockFetchProviderModels, mockBuildModelGroups, mockReadAppConfig, mockWriteAppConfig, mockExistsSync, mockReadFileSync, mockListProfileNamesFromDisk, mockListUserProfiles, mockReadProviderModelCatalogCache, mockGetCachedProviderModels, mockRefreshConfiguredProviderModelCatalogs, mockWriteProviderModelCatalogEntry, mockGetCopilotModelsDetailed } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
mockReadConfigYaml: vi.fn(),
mockReadConfigYamlForProfile: vi.fn(),
mockFetchProviderModels: vi.fn(),
mockBuildModelGroups: vi.fn(() => ({ default: '', groups: [] })),
mockReadAppConfig: vi.fn(),
mockWriteAppConfig: vi.fn(),
mockExistsSync: vi.fn(() => false),
mockReadFileSync: vi.fn(),
mockListProfileNamesFromDisk: vi.fn(() => ['default']),
mockListUserProfiles: vi.fn(() => []),
mockReadProviderModelCatalogCache: vi.fn(),
mockGetCachedProviderModels: vi.fn(),
mockRefreshConfiguredProviderModelCatalogs: vi.fn(),
mockWriteProviderModelCatalogEntry: vi.fn(),
mockGetCopilotModelsDetailed: vi.fn(async () => []),
}))
vi.mock('fs/promises', () => ({
readFile: mockReadFile,
}))
vi.mock('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveEnvPath: () => '/fake/home/.hermes/.env',
getActiveAuthPath: () => '/fake/home/.hermes/auth.json',
getActiveProfileName: () => 'default',
getProfileDir: () => '/fake/home/.hermes',
listProfileNamesFromDisk: mockListProfileNamesFromDisk,
}))
vi.mock('../../packages/server/src/db/hermes/users-store', () => ({
listUserProfiles: mockListUserProfiles,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYaml: mockReadConfigYaml,
readConfigYamlForProfile: mockReadConfigYamlForProfile,
writeConfigYaml: vi.fn(),
fetchProviderModels: mockFetchProviderModels,
buildModelGroups: mockBuildModelGroups,
PROVIDER_ENV_MAP: {
'fun-codex': { api_key_env: '', base_url_env: '' },
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' },
lmstudio: { api_key_env: 'LM_API_KEY', base_url_env: 'LM_BASE_URL' },
'xai-oauth': { api_key_env: '', base_url_env: '' },
openrouter: {},
copilot: { api_key_env: 'GITHUB_TOKEN', base_url_env: '' },
},
}))
vi.mock('../../packages/server/src/shared/providers', () => ({
buildProviderModelMap: () => ({
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
'xai-oauth': ['grok-4.3', 'grok-4.20-0309-reasoning'],
openrouter: ['openrouter/auto'],
}),
PROVIDER_PRESETS: [
{
value: 'fun-codex',
label: 'Codex-apikey.fun',
base_url: 'https://api.apikey.fun/v1',
models: ['gpt-5.5', 'gpt-5.4'],
builtin: true,
},
{
value: 'deepseek',
label: 'DeepSeek',
base_url: 'https://api.deepseek.com/v1',
models: ['deepseek-chat', 'deepseek-reasoner'],
builtin: true,
},
{
value: 'openrouter',
label: 'OpenRouter',
base_url: 'https://openrouter.ai/api/v1',
models: ['openrouter/auto'],
builtin: true,
},
{
value: 'lmstudio',
label: 'LM Studio',
base_url: 'http://127.0.0.1:1234/v1',
models: [],
builtin: true,
},
{
value: 'xai-oauth',
label: 'xAI Grok OAuth (SuperGrok Subscription)',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
builtin: true,
},
{
value: 'copilot',
label: 'GitHub Copilot',
base_url: 'https://api.githubcopilot.com',
models: ['gpt-5.5'],
builtin: true,
},
],
}))
vi.mock('../../packages/server/src/services/hermes/copilot-models', () => ({
getCopilotModelsDetailed: mockGetCopilotModelsDetailed,
resolveCopilotOAuthToken: vi.fn(async () => ''),
}))
vi.mock('../../packages/server/src/services/app-config', () => ({
readAppConfig: mockReadAppConfig,
writeAppConfig: mockWriteAppConfig,
}))
vi.mock('../../packages/server/src/services/hermes/model-catalog-cache', () => ({
readProviderModelCatalogCache: mockReadProviderModelCatalogCache,
getCachedProviderModels: mockGetCachedProviderModels,
refreshConfiguredProviderModelCatalogs: mockRefreshConfiguredProviderModelCatalogs,
writeProviderModelCatalogEntry: mockWriteProviderModelCatalogEntry,
}))
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')
mockFetchProviderModels.mockResolvedValue([])
mockReadConfigYaml.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'deepseek-chat', provider: 'deepseek' } })
mockBuildModelGroups.mockReturnValue({ default: '', groups: [] })
mockReadAppConfig.mockResolvedValue({})
mockWriteAppConfig.mockImplementation(async patch => patch)
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReturnValue('{}')
mockListProfileNamesFromDisk.mockReturnValue(['default'])
mockListUserProfiles.mockReturnValue([])
mockReadProviderModelCatalogCache.mockResolvedValue({ version: 1, updated_at: '1970-01-01T00:00:00.000Z', providers: {} })
mockGetCachedProviderModels.mockReturnValue(null)
mockRefreshConfiguredProviderModelCatalogs.mockResolvedValue(undefined)
mockWriteProviderModelCatalogEntry.mockResolvedValue({})
mockGetCopilotModelsDetailed.mockResolvedValue([])
})
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('merges Web UI custom models into available provider groups', async () => {
mockReadAppConfig.mockResolvedValue({
customModels: {
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
},
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups[0]).toMatchObject({
provider: 'deepseek',
models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'],
available_models: ['deepseek-chat', 'deepseek-reasoner', 'gemma-4-26b-a4b-it'],
})
expect(ctx.body.custom_models).toEqual({
deepseek: ['gemma-4-26b-a4b-it', 'deepseek-chat'],
})
})
it('prefers cached live provider catalogs over static built-in presets', async () => {
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'deepseek-live', provider: 'deepseek' } })
mockGetCachedProviderModels.mockImplementation((_cache: unknown, provider: string) => {
return provider === 'deepseek' ? ['deepseek-live', 'deepseek-new'] : null
})
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'deepseek',
models: ['deepseek-live', 'deepseek-new'],
available_models: ['deepseek-live', 'deepseek-new'],
}),
]))
expect(mockFetchProviderModels).not.toHaveBeenCalled()
})
it('limits the default available-models response to profiles bound to regular admins', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default', 'research', 'private'])
mockListUserProfiles.mockReturnValue([
{ user_id: 7, profile_name: 'research', is_default: 1, created_at: 1 },
])
mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => ({
model: {
default: `${profile}-model`,
provider: 'deepseek',
},
}))
const ctx = makeCtx()
ctx.state = { user: { id: 7, username: 'ops', role: 'admin' } }
ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'private' : '')
await ctrl.getAvailable(ctx)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research')
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research'])
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({ provider: 'deepseek' }),
]))
})
it('uses the requested profile for aggregate response defaults', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default', 'tester'])
mockReadConfigYamlForProfile.mockImplementation(async (profile: string) => ({
model: {
default: profile === 'tester' ? 'deepseek-reasoner' : 'deepseek-chat',
provider: 'deepseek',
},
}))
const ctx = makeCtx()
ctx.state = { user: { id: 1, username: 'admin', role: 'super_admin' } }
ctx.get = vi.fn((name: string) => name.toLowerCase() === 'x-hermes-profile' ? 'tester' : '')
await ctrl.getAvailable(ctx)
expect(ctx.body.default).toBe('deepseek-reasoner')
expect(ctx.body.default_provider).toBe('deepseek')
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['default', 'tester'])
})
it('uses explicit query profile for single-profile model fetches', async () => {
mockListProfileNamesFromDisk.mockReturnValue(['default', 'research'])
const ctx = makeCtx()
ctx.query = { profile: 'research' }
ctx.state = { profile: { name: 'default' }, user: { id: 1, username: 'admin', role: 'super_admin' } }
await ctrl.getAvailable(ctx)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledTimes(1)
expect(mockReadConfigYamlForProfile).toHaveBeenCalledWith('research')
expect(ctx.body.profiles.map((profile: any) => profile.profile)).toEqual(['research'])
})
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'],
}),
]))
})
it('shows xAI Grok OAuth when SuperGrok credentials exist in auth.json', async () => {
mockExistsSync.mockReturnValue(true)
mockReadFileSync.mockReturnValue(JSON.stringify({
providers: {
'xai-oauth': {
tokens: { access_token: 'xai-token' },
},
},
}))
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'xai-oauth',
label: 'xAI Grok OAuth (SuperGrok Subscription)',
base_url: 'https://api.x.ai/v1',
models: ['grok-4.3', 'grok-4.20-0309-reasoning'],
}),
]))
})
it('marks allProviders with base URL env support for editable preset URLs', async () => {
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.body.allProviders).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'deepseek',
builtin: true,
base_url_env: 'DEEPSEEK_BASE_URL',
}),
expect.not.objectContaining({
provider: 'xai-oauth',
base_url_env: expect.any(String),
}),
]))
})
it('marks custom-prefixed providers as builtin when their provider key matches a preset', async () => {
mockReadConfigYamlForProfile.mockResolvedValue({
model: { default: 'gpt-5.5', provider: 'custom:fun-codex' },
custom_providers: [
{
name: 'fun-codex',
base_url: 'https://proxy.example.com/v1',
model: 'gpt-5.5',
api_key: 'sk-test',
},
],
})
const ctx = makeCtx()
ctx.query = { profile: 'default' }
await ctrl.getAvailable(ctx)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'custom:fun-codex',
builtin: true,
models: ['gpt-5.5', 'gpt-5.4'],
}),
]))
})
it('loads api_mode from custom provider config entries', async () => {
mockReadConfigYamlForProfile.mockResolvedValue({
model: { default: 'research-model', provider: 'custom:research-proxy' },
custom_providers: [
{
name: 'research-proxy',
base_url: 'https://research.invalid/v1',
model: 'research-model',
api_key: 'sk-test',
api_mode: 'chat_completions',
},
],
})
const ctx = makeCtx()
ctx.query = { profile: 'default' }
await ctrl.getAvailable(ctx)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'custom:research-proxy',
api_mode: 'chat_completions',
models: ['research-model'],
}),
]))
})
it('returns LM Studio configured default model when env credentials exist and catalog is empty', async () => {
mockReadFile.mockResolvedValue('LM_API_KEY=local\nLM_BASE_URL=http://127.0.0.1:1234/v1\n')
mockReadConfigYaml.mockResolvedValue({ model: { default: 'eee', provider: 'lmstudio' } })
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'eee', provider: 'lmstudio' } })
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'lmstudio',
label: 'LM Studio',
base_url: 'http://127.0.0.1:1234/v1',
models: ['eee'],
available_models: ['eee'],
}),
]))
expect(ctx.body.default).toBe('eee')
expect(ctx.body.default_provider).toBe('lmstudio')
})
it('updates the provider model catalog cache after manual model fetch', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ data: [{ id: 'nvidia/live-a' }, { id: 'nvidia/live-b' }, { id: '' }] }),
})) as any
try {
const ctx = makeCtx({
provider: 'nvidia',
label: 'NVIDIA',
base_url: 'https://integrate.api.nvidia.com/v1',
api_key: 'nvapi-test',
update_cache: true,
})
await ctrl.fetchProviderModelList(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({ models: ['nvidia/live-a', 'nvidia/live-b'] })
expect(mockWriteProviderModelCatalogEntry).toHaveBeenCalledWith({
provider: 'nvidia',
label: 'NVIDIA',
base_url: 'https://integrate.api.nvidia.com/v1',
models: ['nvidia/live-a', 'nvidia/live-b'],
source: 'live',
free_only: false,
})
} finally {
globalThis.fetch = originalFetch
}
})
it('refreshes configured provider model catalog cache on demand', async () => {
const ctx = makeCtx()
await ctrl.refreshProviderModelCatalogCache(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body).toEqual({ success: true })
expect(mockRefreshConfiguredProviderModelCatalogs).toHaveBeenCalledWith({ force: true })
})
it('does not fetch Copilot live models while serving available models', async () => {
mockReadFile.mockResolvedValue('GITHUB_TOKEN=ghu-test\n')
mockReadConfigYamlForProfile.mockResolvedValue({ model: { default: 'gpt-5.5', provider: 'copilot' } })
const ctx = makeCtx()
await ctrl.getAvailable(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.groups).toEqual(expect.arrayContaining([
expect.objectContaining({
provider: 'copilot',
models: ['gpt-5.5'],
}),
]))
expect(mockGetCopilotModelsDetailed).not.toHaveBeenCalled()
})
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('adds and removes custom models in web-ui app config only', async () => {
mockReadAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing'] },
})
mockWriteAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing', 'manual-model'] },
})
const addCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' })
await ctrl.addCustomModel(addCtx)
expect(mockWriteAppConfig).toHaveBeenCalledWith({
customModels: { deepseek: ['existing', 'manual-model'] },
})
expect(addCtx.body).toEqual({
success: true,
custom_models: { deepseek: ['existing', 'manual-model'] },
})
mockReadAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing', 'manual-model'] },
})
mockWriteAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['existing'] },
})
const removeCtx = makeCtx({ provider: 'deepseek', model: 'manual-model' })
await ctrl.removeCustomModel(removeCtx)
expect(mockWriteAppConfig).toHaveBeenLastCalledWith({
customModels: { deepseek: ['existing'] },
})
expect(removeCtx.body).toEqual({
success: true,
custom_models: { deepseek: ['existing'] },
})
})
it('removes custom models from query params when DELETE body is missing', async () => {
mockReadAppConfig.mockResolvedValueOnce({
customModels: { deepseek: ['manual-model'] },
})
mockWriteAppConfig.mockResolvedValueOnce({
customModels: {},
})
const ctx = makeCtx()
ctx.request.body = undefined
ctx.query = { provider: 'deepseek', model: 'manual-model' }
await ctrl.removeCustomModel(ctx)
expect(ctx.status).toBe(200)
expect(mockWriteAppConfig).toHaveBeenCalledWith({ customModels: {} })
expect(ctx.body).toEqual({ success: true, custom_models: {} })
})
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()
})
})