Files
hermes-web-ui/tests/server/model-visibility-controller.test.ts
Zhicheng Han 35810818ac fix: 保存自定义 Provider API 模式 (#1604)
* fix(webui): persist custom provider api_mode

Refs #389

* docs: add chat chain fragment for issue #389

* add api mode locale labels

* set longcat api mode

* correct longcat api mode

---------

Co-authored-by: ekko <fqsy1416@gmail.com>
2026-06-17 08:15:44 +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()
})
})