@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
1,488 lines (1,250 loc) • 50.5 kB
text/typescript
import { AiProviderModelListItem, EnabledAiModel } from 'model-bank';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
import { clientDB, initializeDB } from '@/database/client/db';
import {
AiProviderDetailItem,
AiProviderListItem,
AiProviderRuntimeConfig,
EnabledProvider,
} from '@/types/aiProvider';
import { AiInfraRepos } from './index';
const userId = 'test-user-id';
const mockProviderConfigs = {
openai: { enabled: true },
anthropic: { enabled: false },
};
let repo: AiInfraRepos;
beforeEach(async () => {
await initializeDB();
vi.clearAllMocks();
repo = new AiInfraRepos(clientDB as any, userId, mockProviderConfigs);
});
describe('AiInfraRepos', () => {
describe('getAiProviderList', () => {
it('should merge builtin and user providers correctly', async () => {
const mockUserProviders = [
{ id: 'openai', enabled: true, name: 'Custom OpenAI' },
{ id: 'custom', enabled: true, name: 'Custom Provider' },
] as AiProviderListItem[];
vi.spyOn(repo.aiProviderModel, 'getAiProviderList').mockResolvedValueOnce(mockUserProviders);
const result = await repo.getAiProviderList();
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
// Verify the merge logic
const openaiProvider = result.find((p) => p.id === 'openai');
expect(openaiProvider).toMatchObject({ enabled: true, name: 'Custom OpenAI' });
});
it('should sort providers according to DEFAULT_MODEL_PROVIDER_LIST order', async () => {
vi.spyOn(repo.aiProviderModel, 'getAiProviderList').mockResolvedValue([]);
const result = await repo.getAiProviderList();
expect(result).toEqual(
expect.arrayContaining(
DEFAULT_MODEL_PROVIDER_LIST.map((item) =>
expect.objectContaining({
id: item.id,
source: 'builtin',
}),
),
),
);
});
});
describe('getUserEnabledProviderList', () => {
it('should return only enabled providers', async () => {
const mockProviders = [
{ id: 'openai', enabled: true, name: 'OpenAI', sort: 1 },
{ id: 'anthropic', enabled: false, name: 'Anthropic', sort: 2 },
] as AiProviderListItem[];
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
const result = await repo.getUserEnabledProviderList();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
id: 'openai',
name: 'OpenAI',
});
});
it('should return only enabled provider', async () => {
const mockProviders = [
{
enabled: true,
id: 'openai',
logo: 'logo1',
name: 'OpenAI',
sort: 1,
source: 'builtin' as const,
},
{
enabled: false,
id: 'anthropic',
logo: 'logo2',
name: 'Anthropic',
sort: 2,
source: 'builtin' as const,
},
];
vi.spyOn(repo.aiProviderModel, 'getAiProviderList').mockResolvedValue(mockProviders);
const result = await repo.getUserEnabledProviderList();
expect(result).toEqual([
{
id: 'openai',
logo: 'logo1',
name: 'OpenAI',
source: 'builtin',
},
]);
});
});
describe('getEnabledModels', () => {
it('should merge and filter enabled models', async () => {
const mockProviders = [{ id: 'openai', enabled: true }] as AiProviderListItem[];
const mockAllModels = [
{ id: 'gpt-4', providerId: 'openai', enabled: true },
] as EnabledAiModel[];
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{ id: 'gpt-4', enabled: true, type: 'chat' },
]);
const result = await repo.getEnabledModels();
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toMatchObject({
id: 'gpt-4',
providerId: 'openai',
});
});
it('should merge builtin and user models correctly', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', sort: 1, source: 'builtin' as const },
];
const mockAllModels = [
{
abilities: { vision: true },
displayName: 'Custom GPT-4',
enabled: true,
id: 'gpt-4',
providerId: 'openai',
sort: 1,
type: 'chat' as const,
contextWindowTokens: 10,
},
];
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
abilities: {},
displayName: 'GPT-4',
enabled: true,
id: 'gpt-4',
type: 'chat' as const,
},
]);
const result = await repo.getEnabledModels();
expect(result).toContainEqual(
expect.objectContaining({
abilities: { vision: true },
displayName: 'Custom GPT-4',
enabled: true,
contextWindowTokens: 10,
id: 'gpt-4',
providerId: 'openai',
sort: 1,
type: 'chat',
}),
);
});
it('should handle case when user model not found', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', sort: 1, source: 'builtin' as const },
];
const mockAllModels: any[] = [];
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
abilities: { reasoning: true },
displayName: 'GPT-4',
enabled: true,
id: 'gpt-4',
type: 'chat' as const,
},
]);
const result = await repo.getEnabledModels();
expect(result[0]).toEqual(
expect.objectContaining({
abilities: { reasoning: true },
enabled: true,
id: 'gpt-4',
providerId: 'openai',
}),
);
});
it('should include settings property from builtin model', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' },
] as AiProviderListItem[];
const mockAllModels: EnabledAiModel[] = [];
const mockSettings = { searchImpl: 'tool' as const };
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
enabled: true,
id: 'gpt-4',
settings: mockSettings,
type: 'chat',
},
]);
const result = await repo.getEnabledModels();
expect(result[0]).toMatchObject({
id: 'gpt-4',
settings: mockSettings,
});
});
it('should return all models including disabled ones when filterEnabled is false', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' },
{ enabled: false, id: 'anthropic', name: 'Anthropic', source: 'builtin' },
] as AiProviderListItem[];
const mockAllModels = [
{
abilities: {},
enabled: false,
id: 'claude-3',
providerId: 'anthropic',
type: 'chat' as const,
},
] as EnabledAiModel[];
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
enabled: true,
id: 'gpt-4',
type: 'chat' as const,
},
{
enabled: false,
id: 'claude-3',
type: 'chat' as const,
},
]);
const result = await repo.getEnabledModels(false);
// Should include both enabled and disabled models
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'gpt-4',
enabled: true,
providerId: 'openai',
}),
expect.objectContaining({
id: 'claude-3',
enabled: false,
providerId: 'anthropic',
}),
]),
);
// Verify we have at least the expected models (may have more from builtin models)
expect(result.length).toBeGreaterThanOrEqual(2);
// Verify disabled models are included
const disabledModels = result.filter((model) => !model.enabled);
expect(disabledModels.length).toBeGreaterThan(0);
});
it('should allow search=true and add searchImpl=params when user enables it without providing settings (builtin has no search and no settings)', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
] as AiProviderListItem[];
// User explicitly enables search but provides no settings field
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: true },
// no settings on user model
};
// Builtin does NOT have search capability and thus no settings
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: false },
// no settings since builtin search is false
},
]);
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
const result = await repo.getEnabledModels();
const merged = result.find(
(m) => m.id === 'gpt-4' && m.providerId === 'openai' && m.type === 'chat',
);
expect(merged).toBeDefined();
expect(merged?.abilities).toMatchObject({ search: true });
// settings should remain undefined because builtin had none and user never has settings
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
it('should remove builtin rearch settings and disable search when user turns search off', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
] as AiProviderListItem[];
// User explicitly disables search and provides no settings field
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: false },
// no settings on user model
};
const builtinSettings = { searchImpl: 'tool' as const };
// Builtin has search capability and corresponding settings
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: true },
settings: builtinSettings,
},
]);
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
const result = await repo.getEnabledModels();
const merged = result.find(
(m) => m.id === 'gpt-4' && m.providerId === 'openai' && m.type === 'chat',
);
expect(merged).toBeDefined();
// User's choice takes precedence
expect(merged?.abilities).toMatchObject({ search: false });
// Builtin settings are preserved on the merged model
expect(merged?.settings).toBeUndefined();
});
it('should set search=true and settings=params for custom provider when user enables search and builtin has no search/settings', async () => {
const mockProviders = [
{ enabled: true, id: 'custom', name: 'Custom Provider', source: 'custom' as const },
] as AiProviderListItem[];
// Builtin (preset) has the model but without search and without settings
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
{
id: 'my-model',
enabled: true,
type: 'chat' as const,
abilities: { search: false },
// no settings
} as any,
]);
// User explicitly enables search; user models do not carry settings
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([
{
id: 'my-model',
providerId: 'custom',
enabled: true,
type: 'chat',
abilities: { search: true },
} as EnabledAiModel,
]);
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'my-model' && m.providerId === 'custom');
expect(merged).toBeDefined();
expect(merged?.abilities).toMatchObject({ search: true });
// For custom provider, when user enables search with no builtin settings, default to 'params'
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
// 测试场景:用户模型 abilitie 为空(Empty),而基础模型有搜索能力和设置
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: {}, // Empty object, no search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: false }, // 使用 builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
expect(merged?.abilities?.search).toEqual(false);
// 删去 builtin settings
expect(merged?.settings).toBeUndefined();
});
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: {}, // Empty object, no search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: true }, // 使用 builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
expect(merged?.abilities?.search).toEqual(true);
// 保留 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试场景:用户模型未启用搜索(abilities.search 为 undefined),而基础模型有搜索能力和设置
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { vision: true }, // 启用 vision 能力, no search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: false }, // builtin abilities 不生效
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search 仍 undefined(兼容老版本)
expect(merged?.abilities?.search).toBeUndefined();
// 保留 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { vision: true }, // 启用 vision 能力, no search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: { search: true }, // builtin abilities 不生效
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search 仍 undefined(兼容老版本)
expect(merged?.abilities?.search).toBeUndefined();
// 保留 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试场景:用户模型未启用搜索(abilities.search 为 undefined),而基础模型也无搜索能力和设置
it('should retain no settings when user model has no abilities.search (undefined) and builtin has no settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: {}, // 无 search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: {},
// builtin 无 settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities?.search).toBeUndefined();
// 无 settings
expect(merged?.settings).toBeUndefined();
});
// 测试:用户模型有 abilities.search: true
it('should inject defaults when user has search: true, no existing settings (builtin none)', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: true }, // 用户启用 search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
abilities: {},
// 无 settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 注入 defaults (openai: params)
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
it('should retain existing settings when user has search: true and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: true },
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
settings: { searchImpl: 'tool' }, // builtin 有 settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 使用 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'tool' });
});
// 测试:用户模型有 abilities.search: false
it('should remove settings when user has search: false and builtin has settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: false }, // 用户禁用 search
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
settings: { searchImpl: 'tool', extendParams: [] }, // builtin 有 settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// 移除 search 相关,保留其他
expect(merged?.settings).toEqual({ extendParams: [] });
});
it('should keep no settings when user has search: false and no existing settings', async () => {
const mockProviders = [
{ enabled: true, id: 'openai', name: 'OpenAI', source: 'builtin' as const },
];
const userModel: EnabledAiModel = {
id: 'gpt-4',
providerId: 'openai',
enabled: true,
type: 'chat',
abilities: { search: false },
};
const builtinModel = {
id: 'gpt-4',
enabled: true,
type: 'chat' as const,
// 无 settings
};
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue([userModel]);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([builtinModel]);
const result = await repo.getEnabledModels();
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// 无 settings
expect(merged?.settings).toBeUndefined();
});
});
describe('getAiProviderModelList', () => {
it('should merge builtin and user models', async () => {
const providerId = 'openai';
const mockUserModels = [
{ id: 'custom-gpt4', enabled: true, type: 'chat' },
] as AiProviderModelListItem[];
const mockBuiltinModels = [{ id: 'gpt-4', enabled: true }];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(mockUserModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(mockBuiltinModels);
const result = await repo.getAiProviderModelList(providerId);
expect(result).toHaveLength(2);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'custom-gpt4' }),
expect.objectContaining({ id: 'gpt-4' }),
]),
);
});
it('should merge default and custom models', async () => {
const mockCustomModels = [
{
displayName: 'Custom GPT-4',
enabled: false,
id: 'gpt-4',
type: 'chat' as const,
},
];
const mockDefaultModels = [
{
displayName: 'GPT-4',
enabled: true,
id: 'gpt-4',
type: 'chat' as const,
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(mockCustomModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(mockDefaultModels);
const result = await repo.getAiProviderModelList('openai');
expect(result).toContainEqual(
expect.objectContaining({
displayName: 'Custom GPT-4',
enabled: false,
id: 'gpt-4',
}),
);
});
it('should use builtin models', async () => {
const providerId = 'ai21';
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue([]);
const result = await repo.getAiProviderModelList(providerId);
expect(result).toHaveLength(2);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'jamba-mini' }),
expect.objectContaining({ id: 'jamba-large' }),
]),
);
});
it('should return empty if not exist provider', async () => {
const providerId = 'abc';
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue([]);
const result = await repo.getAiProviderModelList(providerId);
expect(result).toHaveLength(0);
});
// New tests for getAiProviderModelList per the corrected behavior
it('should allow search=true and add searchImpl=params when user enables it without providing settings (builtin has no search and no settings)', async () => {
const providerId = 'openai';
// User explicitly enables search in custom model, but provides no settings
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true },
// user never has settings
} as any,
];
// Builtin has no search and no settings
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false },
// no settings
} as any,
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged.abilities).toMatchObject({ search: true });
// when user enables search with no settings, default searchImpl should be 'params'
expect(merged.settings).toEqual({ searchImpl: 'params' });
});
it('should remove builtin search settings and disable search when user turns search off', async () => {
const providerId = 'openai';
// User explicitly disables search in custom model, and provides no settings
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false },
// user never has settings
} as any,
];
// Builtin has search with settings
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true },
settings: { searchImpl: 'tool' },
} as any,
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// User's choice takes precedence
expect(merged.abilities).toMatchObject({ search: false });
// Builtin search settings should be removed since user turned search off
expect(merged.settings).toBeUndefined();
});
it('should set search=true and settings=params for custom provider when user enables search and builtin has no search/settings', async () => {
const providerId = 'custom';
// User list: model with search enabled, but no settings
const userModels: AiProviderModelListItem[] = [
{
id: 'my-model',
type: 'chat',
enabled: true,
abilities: { search: true },
// user never has settings
} as any,
];
// Default list: same model without search and without settings
const defaultModels: AiProviderModelListItem[] = [
{
id: 'my-model',
type: 'chat',
enabled: true,
abilities: { search: false },
// no settings
} as any,
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(defaultModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'my-model') as any;
expect(merged).toBeDefined();
expect(merged.abilities).toMatchObject({ search: true });
// For custom provider, when user enables search with no builtin settings, default to 'params'
expect(merged.settings).toEqual({ searchImpl: 'params' });
});
// 测试场景:用户模型 abilitie 为空(Empty),而基础模型有搜索能力和设置
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {}, // Empty object, no search
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false }, // 使用 builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
expect(merged?.abilities?.search).toEqual(false);
// 保留 builtin settings
expect(merged?.settings).toBeUndefined();
});
it('should retain builtin abilities and settings when user model has no abilities (empty) and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {}, // Empty object, no search
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true }, // 使用 builtin abilities
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// 使用 builtin abilities
expect(merged?.abilities?.search).toEqual(true);
// 保留 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试场景:用户模型未启用搜索(abilities.search 为 undefined),而基础模型有搜索能力和设置
it('should retain builtin settings when user model has no abilities (empty) and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { vision: true }, // 启用 vision 能力, no search
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false }, // builtin abilities 会被 merge
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search 会被 merge 为 false,此处和 getEnabledAiModel 不同
expect(merged?.abilities?.search).toEqual(false);
// 删去 builtin settings
expect(merged?.settings).toBeUndefined();
});
it('should retain builtin settings when user model has no abilities (empty) and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { vision: true }, // 启用 vision 能力, no search
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true }, // builtin abilities 会被 merge
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin has settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
// abilities.search 会被 merge 为 true,此处和 getEnabledAiModel 不同
expect(merged?.abilities?.search).toEqual(true);
// 保留 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
// 测试:用户模型无 abilities.search(undefined),保留 builtin settings(mergeArrayById 优先用户,但用户无则 builtin)
it('should retain builtin settings when user model has no abilities.search (undefined) and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {}, // 无 search
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {},
settings: { searchImpl: 'params', searchProvider: 'google' }, // builtin 有
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities?.search).toBeUndefined();
// 保留 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'params', searchProvider: 'google' });
});
it('should retain no settings when user model has no abilities.search (undefined) and builtin has no settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: {}, // 无 search
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
// 无 settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities?.search).toBeUndefined();
// 无 settings
expect(merged?.settings).toBeUndefined();
});
// 测试:用户模型有 abilities.search: true
it('should inject defaults when user has search: true, no existing settings (builtin none)', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true }, // 用户启用
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
// 无 settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 注入 defaults
expect(merged?.settings).toEqual({ searchImpl: 'params' });
});
it('should retain existing settings when user has search: true and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: true },
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
settings: { searchImpl: 'tool' },
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: true });
// 使用 builtin settings
expect(merged?.settings).toEqual({ searchImpl: 'tool' });
});
// 测试:用户模型有 abilities.search: false
it('should remove settings when user has search: false and builtin has settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false }, // 用户禁用
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
settings: { searchImpl: 'tool', extendParams: [] },
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// 移除 search 相关,保留其他
expect(merged?.settings).toEqual({ extendParams: [] });
});
it('should keep no settings when user has search: false and no existing settings', async () => {
const providerId = 'openai';
const userModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
abilities: { search: false },
},
];
const builtinModels: AiProviderModelListItem[] = [
{
id: 'gpt-4',
type: 'chat',
enabled: true,
// 无 settings
},
];
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(userModels);
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(builtinModels);
const result = await repo.getAiProviderModelList(providerId);
const merged = result.find((m) => m.id === 'gpt-4');
expect(merged).toBeDefined();
expect(merged?.abilities).toEqual({ search: false });
// 无 settings
expect(merged?.settings).toBeUndefined();
});
});
describe('getAiProviderRuntimeState', () => {
it('should return complete runtime state', async () => {
const mockRuntimeConfig = {
openai: { apiKey: 'test-key' },
} as unknown as Record<string, AiProviderRuntimeConfig>;
const mockEnabledProviders = [{ id: 'openai', name: 'OpenAI' }] as EnabledProvider[];
const mockEnabledModels = [
{ id: 'gpt-4', providerId: 'openai', enabled: true },
] as EnabledAiModel[];
vi.spyOn(repo.aiProviderModel, 'getAiProviderRuntimeConfig').mockResolvedValue(
mockRuntimeConfig,
);
vi.spyOn(repo, 'getUserEnabledProviderList').mockResolvedValue(mockEnabledProviders);
vi.spyOn(repo, 'getEnabledModels').mockResolvedValue(mockEnabledModels);
const result = await repo.getAiProviderRuntimeState();
expect(result).toMatchObject({
enabledAiProviders: mockEnabledProviders,
enabledAiModels: mockEnabledModels,
runtimeConfig: expect.any(Object),
});
});
it('should return provider runtime state', async () => {
const mockRuntimeConfig = {
openai: {
apiKey: 'test-key',
},
} as unknown as Record<string, AiProviderRuntimeConfig>;
vi.spyOn(repo.aiProviderModel, 'getAiProviderRuntimeConfig').mockResolvedValue(
mockRuntimeConfig,
);
vi.spyOn(repo, 'getUserEnabledProviderList').mockResolvedValue([
{ id: 'openai', logo: 'logo1', name: 'OpenAI', source: 'builtin' },
]);
vi.spyOn(repo, 'getEnabledModels').mockResolvedValue([
{
abilities: {},
enabled: true,
id: 'gpt-4',
providerId: 'openai',
type: 'chat',
},
]);
const result = await repo.getAiProviderRuntimeState();
expect(result).toEqual({
enabledAiModels: [
expect.objectContaining({
enabled: true,
id: 'gpt-4',
providerId: 'openai',
}),
],
enabledAiProviders: [{ id: 'openai', logo: 'logo1', name: 'OpenAI', source: 'builtin' }],
enabledChatAiProviders: [
{ id: 'openai', logo: 'logo1', name: 'OpenAI', source: 'builtin' },
],
enabledImageAiProviders: [],
runtimeConfig: {
openai: {
apiKey: 'test-key',
enabled: true,
},
},
});
});
it('should return provider runtime state with enabledImageAiProviders', async () => {
const mockRuntimeConfig = {
fal: {
apiKey: 'test-fal-key',
},
openai: {
apiKey: 'test-openai-key',
},
} as unknown as Record<string, AiProviderRuntimeConfig>;
vi.spyOn(repo.aiProviderModel, 'getAiProviderRuntimeConfig').mockResolvedValue(
mockRuntimeConfig,
);
// Mock providers including fal for image generation
vi.spyOn(repo, 'getUserEnabledProviderList').mockResolvedValue([
{ id: 'openai', logo: 'openai-logo', name: 'OpenAI', source: 'builtin' },
{ id: 'fal', logo: 'fal-logo', name: 'Fal', source: 'builtin' },
]);
// Mock models including image models from fal
vi.spyOn(repo, 'getEnabledModels').mockResolvedValue([
{
abilities: {},
enabled: true,
id: 'gpt-4',
providerId: 'openai',
type: 'chat',
},
{
abilities: {},
enabled: true,
id: 'flux/schnell',
providerId: 'fal',
type: 'image',
},
{
abilities: {},
enabled: true,
id: 'flux-kontext/dev',
providerId: 'fal',
type: 'image',
},
]);
const result = await repo.getAiProviderRuntimeState();
expect(result).toEqual({
enabledAiModels: [
expect.objectContaining({
enabled: true,
id: 'gpt-4',
providerId: 'openai',
type: 'chat',
}),
expect.objectContaining({
enabled: true,
id: 'flux/schnell',
providerId: 'fal',
type: 'image',
}),
expect.objectContaining({
enabled: true,
id: 'flux-kontext/dev',
providerId: 'fal',
type: 'image',
}),
],
enabledAiProviders: [
{ id: 'openai', logo: 'openai-logo', name: 'OpenAI', source: 'builtin' },
{ id: 'fal', logo: 'fal-logo', name: 'Fal', source: 'builtin' },
],
enabledChatAiProviders: [
{ id: 'openai', logo: 'openai-logo', name: 'OpenAI', source: 'builtin' },
],
enabledImageAiProviders: [
expect.objectContaining({
id: 'fal',
name: 'Fal',
}),
],
runtimeConfig: {
fal: {
apiKey: 'test-fal-key',
enabled: undefined,
},
openai: {
apiKey: 'test-openai-key',
enabled: true,
},
},
});
});
});
describe('getAiProviderDetail', () => {
it('should merge provider config with user settings', async () => {
const providerId = 'openai';
const mockProviderDetail = {
id: providerId,
customSetting: 'test',
} as unknown as AiProviderDetailItem;
vi.spyOn(repo.aiProviderModel, 'getAiProviderById').mockResolvedValue(mockProviderDetail);
const result = await repo.getAiProviderDetail(providerId);
expect(result).toMatchObject({
id: providerId,
customSetting: 'test',
enabled: true, // from mockProviderConfigs
});
});
it('should merge provider configs correctly', async () => {
const mockProviderDetail = {
enabled: true,
id: 'openai',
keyVaults: { apiKey: 'test-key' },
name: 'Custom OpenAI',
settings: {},
source: 'builtin' as const,
};
vi.spyOn(repo.aiProviderModel, 'getAiProviderById').mockResolvedValue(mockProviderDetail);
const result = await repo.getAiProviderDetail('openai');
expect(result).toEqual({
enabled: true,
id: 'openai',
keyVaults: { apiKey: 'test-key' },
name: 'Custom OpenAI',
settings: {},
source: 'builtin',
});
});
});
});