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.
651 lines
22 KiB
TypeScript
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()
|
|
})
|
|
})
|