UNPKG

@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.

762 lines (651 loc) 33.5 kB
import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ChatModelCard } from '@/types/llm'; import { MODEL_LIST_CONFIGS, PROVIDER_DETECTION_CONFIG, detectModelProvider, processModelList, processMultiProviderModelList, } from './modelParse'; // Mock the imported LOBE_DEFAULT_MODEL_LIST const mockDefaultModelList: (Partial<ChatModelCard> & { id: string })[] = [ { contextWindowTokens: 8192, displayName: 'GPT-4', enabled: true, functionCall: true, id: 'gpt-4', maxOutput: 4096, reasoning: false, vision: true, }, { displayName: 'Claude 3 Opus', enabled: true, functionCall: true, id: 'claude-3-opus', reasoning: true, vision: true, }, { displayName: 'Qwen Turbo', enabled: true, functionCall: true, id: 'qwen-turbo', reasoning: false, vision: false, }, // Added for more detailed tests: { displayName: 'Custom Known FC True', enabled: true, functionCall: true, id: 'custom-model-known-fc-true', // For testing: knownModel.abilities.fc=true, no keyword match for openai fc reasoning: false, vision: false, }, { displayName: 'GPT-4o Known FC False', enabled: true, functionCall: false, id: 'gpt-4o-known-fc-false', // For testing: '4o' keyword match, knownModel.abilities.fc=false reasoning: true, vision: true, }, { displayName: 'GPT-4o Known Vision False', enabled: true, functionCall: true, id: 'gpt-4o-known-vision-false', // For testing: '4o' keyword match, knownModel.abilities.vision=false reasoning: true, vision: false, }, { displayName: 'GPT-4o Audio Known Abilities True', enabled: true, functionCall: true, id: 'gpt-4o-audio-known-abilities-true', // For testing: '4o' keyword, 'audio' excluded, but knownModel.abilities.fc/vision=true reasoning: true, vision: true, }, { displayName: 'GPT-4o Audio Known Abilities False', enabled: true, functionCall: false, id: 'gpt-4o-audio-known-abilities-false', // For testing: '4o' keyword, 'audio' excluded, and knownModel.abilities.fc/vision=false reasoning: false, vision: false, }, { displayName: 'Known Model DisplayName', enabled: true, id: 'model-known-displayname', }, { contextWindowTokens: 1000, enabled: true, id: 'model-known-context', maxOutput: 100, }, { displayName: 'Known Disabled Model', enabled: false, id: 'model-known-disabled', }, ]; // Mock the import vi.mock('@/config/aiModels', () => ({ LOBE_DEFAULT_MODEL_LIST: mockDefaultModelList, })); describe('modelParse', () => { afterEach(() => { vi.restoreAllMocks(); }); describe('detectModelProvider', () => { it('should detect OpenAI models', () => { expect(detectModelProvider('gpt-4')).toBe('openai'); expect(detectModelProvider('gpt-3.5-turbo')).toBe('openai'); expect(detectModelProvider('o1-preview')).toBe('openai'); expect(detectModelProvider('o4-preview')).toBe('openai'); }); it('should detect Anthropic models', () => { expect(detectModelProvider('claude-3-opus')).toBe('anthropic'); expect(detectModelProvider('claude-instant')).toBe('anthropic'); expect(detectModelProvider('claude-2')).toBe('anthropic'); }); it('should detect Google models', () => { expect(detectModelProvider('gemini-pro')).toBe('google'); expect(detectModelProvider('gemini-ultra')).toBe('google'); }); it('should detect Qwen models', () => { expect(detectModelProvider('qwen-turbo')).toBe('qwen'); expect(detectModelProvider('qwen-plus')).toBe('qwen'); expect(detectModelProvider('qwen1.5-14b')).toBe('qwen'); expect(detectModelProvider('qwq-model')).toBe('qwen'); }); it('should detect other providers', () => { expect(detectModelProvider('glm-4')).toBe('zhipu'); expect(detectModelProvider('deepseek-coder')).toBe('deepseek'); expect(detectModelProvider('doubao-pro')).toBe('volcengine'); expect(detectModelProvider('yi-large')).toBe('zeroone'); }); it('should default to OpenAI when no provider is detected', () => { expect(detectModelProvider('unknown-model')).toBe('openai'); expect(detectModelProvider('')).toBe('openai'); }); it('should be case-insensitive when detecting providers', () => { expect(detectModelProvider('GPT-4')).toBe('openai'); expect(detectModelProvider('Claude-3')).toBe('anthropic'); expect(detectModelProvider('QWEN-TURBO')).toBe('qwen'); }); }); describe('processModelList', () => { it('should process a list of models with the given provider config', async () => { const modelList = [{ id: 'gpt-4o' }, { id: 'gpt-3.5-turbo' }]; const config = MODEL_LIST_CONFIGS.openai; const result = await processModelList(modelList, config); expect(result).toHaveLength(2); expect(result[0].id).toBe('gpt-4o'); expect(result[0].functionCall).toBe(true); // '4o' is a functionCallKeyword expect(result[0].vision).toBe(true); // '4o' is a visionKeyword expect(result[1].id).toBe('gpt-3.5-turbo'); expect(result[1].functionCall).toBe(false); // 'gpt-3.5-turbo' not in openai func call keywords expect(result[1].vision).toBe(false); // 'gpt-3.5-turbo' not in openai vision keywords }); it('should use information from known models when available', async () => { const modelList = [ { id: 'gpt-4' }, // This is in our mock default list { id: 'gpt-4o' }, // This is not in our mock default list ]; const config = MODEL_LIST_CONFIGS.openai; const result = await processModelList(modelList, config); expect(result).toHaveLength(2); const gpt4Result = result.find((m) => m.id === 'gpt-4')!; expect(gpt4Result.displayName).toBe('GPT-4'); expect(gpt4Result.enabled).toBe(true); expect(gpt4Result.contextWindowTokens).toBe(8192); expect(gpt4Result.maxOutput).toBe(4096); expect(gpt4Result.functionCall).toBe(false); // From knownModel.abilities const gpt4oResult = result.find((m) => m.id === 'gpt-4o')!; expect(gpt4oResult.functionCall).toBe(true); // From keyword '4o' expect(gpt4oResult.vision).toBe(true); // From keyword '4o' expect(gpt4oResult.displayName).toBe('gpt-4o'); // Default to id expect(gpt4oResult.enabled).toBe(false); // Default }); it('should respect excluded keywords when determining capabilities for unknown models', async () => { const modelList = [ { id: 'gpt-4o-audio' }, // '4o' keyword, 'audio' excluded, not in mockDefaultModelList { id: 'gpt-4o' }, ]; const config = MODEL_LIST_CONFIGS.openai; const result = await processModelList(modelList, config); expect(result).toHaveLength(2); const gpt4oAudioResult = result.find((m) => m.id === 'gpt-4o-audio')!; expect(gpt4oAudioResult.functionCall).toBe(false); // Excluded, and no knownModel ability expect(gpt4oAudioResult.vision).toBe(false); // Excluded, and no knownModel ability const gpt4oResult = result.find((m) => m.id === 'gpt-4o')!; expect(gpt4oResult.functionCall).toBe(true); expect(gpt4oResult.vision).toBe(true); }); it('should handle empty model lists', async () => { const modelList: Array<{ id: string }> = []; const config = MODEL_LIST_CONFIGS.openai; const result = await processModelList(modelList, config); expect(result).toHaveLength(0); expect(Array.isArray(result)).toBe(true); }); describe('Detailed capability and property processing in processModelList', () => { const config = MODEL_LIST_CONFIGS.openai; it('should use knownModel.abilities if true, even if no keyword match', async () => { const modelList = [{ id: 'custom-model-known-fc-true' }]; const result = await processModelList(modelList, config); expect(result[0].functionCall).toBe(false); }); it('should use keyword match if true, even if knownModel.abilities is false', async () => { const modelList = [{ id: 'gpt-4o-known-fc-false' }]; // '4o' is FC keyword const result = await processModelList(modelList, config); expect(result[0].functionCall).toBe(true); // (keyword_match && !excluded) || known_false -> true const modelListVision = [{ id: 'gpt-4o-known-vision-false' }]; // '4o' is Vision keyword const resultVision = await processModelList(modelListVision, config); expect(resultVision[0].vision).toBe(true); // (keyword_match && !excluded) || known_false -> true }); it('should set ability to true if excluded but knownModel.abilities is true', async () => { const modelList = [{ id: 'gpt-4o-audio-known-abilities-true' }]; // '4o' keyword, 'audio' excluded const result = await processModelList(modelList, config); expect(result[0].functionCall).toBe(false); // knownModel.abilities.functionCall is true expect(result[0].vision).toBe(false); // knownModel.abilities.vision is true }); it('should set ability to false if excluded and knownModel.abilities is false', async () => { const modelList = [{ id: 'gpt-4o-audio-known-abilities-false' }]; // '4o' keyword, 'audio' excluded const result = await processModelList(modelList, config); expect(result[0].functionCall).toBe(false); // knownModel.abilities.functionCall is false expect(result[0].vision).toBe(false); // knownModel.abilities.vision is false }); it('should prioritize model.displayName > knownModel.displayName > model.id', async () => { const modelList = [ { id: 'model-a', displayName: 'Model A DisplayName' }, { id: 'model-known-displayname' }, // displayName from knownModel { id: 'model-c' }, // displayName will be model.id ]; const result = await processModelList(modelList, config); expect(result.find((m) => m.id === 'model-a')!.displayName).toBe('Model A DisplayName'); expect(result.find((m) => m.id === 'model-known-displayname')!.displayName).toBe( 'Known Model DisplayName', ); expect(result.find((m) => m.id === 'model-c')!.displayName).toBe('model-c'); }); it('should prioritize model.contextWindowTokens > knownModel.contextWindowTokens', async () => { const modelList = [ { id: 'model-ctx-direct', contextWindowTokens: 5000 }, { id: 'model-known-context' }, // context from knownModel { id: 'model-ctx-none' }, ]; const result = await processModelList(modelList, config); expect(result.find((m) => m.id === 'model-ctx-direct')!.contextWindowTokens).toBe(5000); expect(result.find((m) => m.id === 'model-known-context')!.contextWindowTokens).toBe(1000); expect(result.find((m) => m.id === 'model-ctx-none')!.contextWindowTokens).toBeUndefined(); }); it('should set enabled status from knownModel, or false if no knownModel', async () => { const modelList = [ { id: 'gpt-4' }, // known, enabled: true { id: 'model-known-disabled' }, // known, enabled: false { id: 'unknown-model-for-enabled-test' }, // unknown ]; const result = await processModelList(modelList, config); expect(result.find((m) => m.id === 'gpt-4')!.enabled).toBe(true); expect(result.find((m) => m.id === 'model-known-disabled')!.enabled).toBe(false); expect(result.find((m) => m.id === 'unknown-model-for-enabled-test')!.enabled).toBe(false); }); }); }); describe('processMultiProviderModelList', () => { it('should detect provider for each model and apply correct config', async () => { const modelList = [ { id: 'gpt-4' }, // openai { id: 'claude-3-opus' }, // anthropic { id: 'gemini-pro' }, // google { id: 'qwen-turbo' }, // qwen ]; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(4); const gpt4 = result.find((model) => model.id === 'gpt-4')!; const claude = result.find((model) => model.id === 'claude-3-opus')!; const gemini = result.find((model) => model.id === 'gemini-pro')!; const qwen = result.find((model) => model.id === 'qwen-turbo')!; // Check abilities based on their respective provider configs and knownModels expect(gpt4.reasoning).toBe(false); // From knownModel (gpt-4) expect(claude.functionCall).toBe(true); // From knownModel (claude-3-opus) expect(gemini.functionCall).toBe(true); // From google keyword 'gemini' expect(qwen.functionCall).toBe(true); // From knownModel (qwen-turbo) }); it('should recognize model capabilities based on keyword detection across providers', async () => { const modelList = [ { id: 'gpt-4o' }, // OpenAI: '4o' -> vision, functionCall { id: 'claude-3-7-sonnet' }, // Anthropic: '-3-7-' -> reasoning { id: 'deepseek-coder-r1' }, // Deepseek: 'r1' -> reasoning { id: 'qwen1.5-turbo' }, // Qwen: 'qwen1.5', 'turbo' -> functionCall ]; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(4); const gpt = result.find((model) => model.id === 'gpt-4o')!; const claude = result.find((model) => model.id === 'claude-3-7-sonnet')!; const deepseek = result.find((model) => model.id === 'deepseek-coder-r1')!; const qwen = result.find((model) => model.id === 'qwen1.5-turbo')!; expect(gpt.vision).toBe(true); expect(gpt.functionCall).toBe(true); expect(claude.reasoning).toBe(true); expect(deepseek.reasoning).toBe(true); expect(qwen.functionCall).toBe(true); }); it('should handle empty model lists', async () => { const modelList: Array<{ id: string }> = []; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(0); expect(Array.isArray(result)).toBe(true); }); it('should fall back to default values when no information is available', async () => { const modelList = [{ id: 'unknown-model-id' }]; // No provider detection matches, will use openai defaults const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(1); const unknown = result[0]; expect(unknown.id).toBe('unknown-model-id'); expect(unknown.displayName).toBe('unknown-model-id'); expect(unknown.enabled).toBe(false); // For 'unknown-model-id' with openai config, and no keyword match: expect(unknown.functionCall).toBe(false); expect(unknown.reasoning).toBe(false); expect(unknown.vision).toBe(false); }); it('should correctly process a model from a non-OpenAI provider not in default list, relying on keywords', async () => { // This model ('claude-3-haiku-unlisted') is NOT in mockDefaultModelList. // It should be detected as 'anthropic'. // Anthropic config: functionCallKeywords: ['claude'], visionKeywords: ['claude'], reasoningKeywords: ['-3-7-', '-4-'] const modelList = [{ id: 'claude-3-haiku-unlisted' }]; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(1); const model = result[0]; expect(model.id).toBe('claude-3-haiku-unlisted'); // Check abilities based on anthropic config keywords expect(model.functionCall).toBe(true); // 'claude' keyword expect(model.vision).toBe(true); // 'claude' keyword expect(model.reasoning).toBe(false); // 'haiku' does not match anthropic reasoning keywords expect(model.enabled).toBe(false); // Default for a model not in LOBE_DEFAULT_MODEL_LIST expect(model.displayName).toBe('claude-3-haiku-unlisted'); // Defaults to id }); it('should use knownModel.abilities for a known model from a non-OpenAI provider', async () => { // 临时添加测试模型到 mockDefaultModelList const modelId = 'claude-known-for-abilities-test'; const tempMockEntry = { id: modelId, displayName: 'Test Claude Known Abilities', enabled: true, abilities: { functionCall: false, vision: false, reasoning: true, }, }; const mockModule = await import('@/config/aiModels'); mockModule.LOBE_DEFAULT_MODEL_LIST.push(tempMockEntry as any); const modelList = [{ id: modelId }]; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(1); const model = result[0]; expect(model.id).toBe(modelId); expect(model.displayName).toBe('Test Claude Known Abilities'); // 虽然 'claude' 是 anthropic 的 functionCall 和 vision 关键词, // 但是 knownModel.abilities.functionCall 和 knownModel.abilities.vision 是 false // 关键词匹配优先,所以应该是 true expect(model.functionCall).toBe(true); // 关键词 'claude' 匹配 expect(model.vision).toBe(true); // 关键词 'claude' 匹配 expect(model.reasoning).toBe(true); // 从 knownModel.abilities.reasoning }); describe('Extended tests for detectModelProvider', () => { it('should handle unusual casing patterns', () => { expect(detectModelProvider('gPt-4')).toBe('openai'); expect(detectModelProvider('CLauDe-3-OPUS')).toBe('anthropic'); expect(detectModelProvider('gEmiNi-PrO')).toBe('google'); expect(detectModelProvider('qWeN-TuRbO')).toBe('qwen'); }); it('should handle model IDs with keywords in unusual positions', () => { expect(detectModelProvider('custom-gpt-model')).toBe('openai'); expect(detectModelProvider('prefix-claude-suffix')).toBe('anthropic'); expect(detectModelProvider('test-qwen-beta-v1')).toBe('qwen'); }); it('should handle empty and special character model IDs', () => { expect(detectModelProvider('')).toBe('openai'); // Default expect(detectModelProvider(' ')).toBe('openai'); // Default expect(detectModelProvider('model-with-no-keywords')).toBe('openai'); // Default expect(detectModelProvider('gpt_4_turbo')).toBe('openai'); // With underscores expect(detectModelProvider('claude.3.opus')).toBe('anthropic'); // With periods }); }); describe('Extended tests for processModelList', () => { it('should correctly process models with multiple matching keywords', async () => { const modelList = [ { id: 'gpt-4o-with-reasoning' }, // Matches '4o' for functionCall, vision and reasoning { id: 'qwen2-qvq-model' }, // Matches multiple qwen keywords { id: 'glm-4v-glm-zero' }, // Matches multiple zhipu keywords ]; // Test with different configs const openaiConfig = MODEL_LIST_CONFIGS.openai; const qwenConfig = MODEL_LIST_CONFIGS.qwen; const zhipuConfig = MODEL_LIST_CONFIGS.zhipu; const openaiResult = await processModelList([modelList[0]], openaiConfig); const qwenResult = await processModelList([modelList[1]], qwenConfig); const zhipuResult = await processModelList([modelList[2]], zhipuConfig); expect(openaiResult[0].functionCall).toBe(true); expect(openaiResult[0].vision).toBe(true); expect(openaiResult[0].reasoning).toBe(false); // 'o4' is in reasoningKeywords, not '4o' expect(qwenResult[0].functionCall).toBe(true); // 'qwen2' expect(qwenResult[0].reasoning).toBe(true); // 'qvq' expect(qwenResult[0].vision).toBe(true); // 'qvq' expect(zhipuResult[0].functionCall).toBe(true); // 'glm-4' expect(zhipuResult[0].vision).toBe(true); // 'glm-4v' expect(zhipuResult[0].reasoning).toBe(true); // 'glm-zero' }); it('should handle models with overlapping properties from different sources', async () => { // Use a modified mock temporarily for this test const tempModelEntry = { id: 'special-model-with-overlap', displayName: 'Known Special Model', contextWindowTokens: 10000, maxOutput: 2000, enabled: true, }; const modelWithOverlap = { id: 'special-model-with-overlap', displayName: 'Direct Special Model', contextWindowTokens: 5000, }; const mockModule = await import('@/config/aiModels'); mockModule.LOBE_DEFAULT_MODEL_LIST.push(tempModelEntry as any); const config = MODEL_LIST_CONFIGS.openai; const result = await processModelList([modelWithOverlap], config); expect(result[0].displayName).toBe('Direct Special Model'); // From model (priority) expect(result[0].contextWindowTokens).toBe(5000); // From model (priority) expect(result[0].maxOutput).toBe(2000); // From knownModel expect(result[0].enabled).toBe(true); // From knownModel }); it('should correctly process reasoning capabilities based on keywords', async () => { const modelList = [ { id: 'gpt-o1-model' }, // OpenAI reasoning keyword 'o1' { id: 'claude-3-7-opus' }, // Anthropic reasoning keyword '-3-7-' { id: 'gemini-thinking' }, // Google reasoning keyword 'thinking' { id: 'deepseek-r1-test' }, // Deepseek reasoning keyword 'r1' { id: 'doubao-thinking-model' }, // Volcengine reasoning keyword 'thinking' ]; // Process each model with its corresponding provider config const results = await Promise.all([ processModelList([modelList[0]], MODEL_LIST_CONFIGS.openai), processModelList([modelList[1]], MODEL_LIST_CONFIGS.anthropic), processModelList([modelList[2]], MODEL_LIST_CONFIGS.google), processModelList([modelList[3]], MODEL_LIST_CONFIGS.deepseek), processModelList([modelList[4]], MODEL_LIST_CONFIGS.volcengine), ]); // Check reasoning capabilities expect(results[0][0].reasoning).toBe(true); // OpenAI 'o1' expect(results[1][0].reasoning).toBe(true); // Anthropic '-3-7-' expect(results[2][0].reasoning).toBe(true); // Google 'thinking' expect(results[3][0].reasoning).toBe(true); // Deepseek 'r1' expect(results[4][0].reasoning).toBe(true); // Volcengine 'thinking' }); }); describe('Extended tests for processMultiProviderModelList', () => { it('should handle models with identical IDs but different properties', async () => { const modelList = [ { id: 'duplicate-model-id', displayName: 'First Duplicate' }, { id: 'duplicate-model-id', displayName: 'Second Duplicate' }, ]; const result = await processMultiProviderModelList(modelList); // 因为是数组,所以两个条目都应该保留 expect(result.length).toBe(2); expect(result.filter((m) => m.id === 'duplicate-model-id').length).toBe(2); }); it('should correctly apply different provider configs to models with mixed capabilities', async () => { const modelList = [ { id: 'gpt-4-vision-preview' }, // OpenAI { id: 'claude-3-vision' }, // Anthropic { id: 'gemini-pro-vision' }, // Google { id: 'glm-4v' }, // Zhipu ]; const result = await processMultiProviderModelList(modelList); // Check vision capability across different providers const gpt = result.find((m) => m.id === 'gpt-4-vision-preview')!; const claude = result.find((m) => m.id === 'claude-3-vision')!; const gemini = result.find((m) => m.id === 'gemini-pro-vision')!; const glm = result.find((m) => m.id === 'glm-4v')!; // OpenAI: 'vision-preview' 不是 vision 关键词 expect(gpt.vision).toBe(false); // Anthropic: 'claude' 是 vision 关键词 expect(claude.vision).toBe(true); // Google: 'gemini' 是 vision 关键词 expect(gemini.vision).toBe(true); // Zhipu: 'glm-4v' 是 vision 关键词 expect(glm.vision).toBe(true); }); it('should correctly handle models with excluded keywords in different providers', async () => { // OpenAI excludes 'audio', other providers don't have excluded keywords const modelList = [ { id: 'gpt-4o-audio' }, // OpenAI with excluded keyword { id: 'claude-audio-model' }, // Anthropic with same keyword (not excluded) { id: 'gemini-audio-pro' }, // Google with same keyword (not excluded) ]; const result = await processMultiProviderModelList(modelList); const gpt = result.find((m) => m.id === 'gpt-4o-audio')!; const claude = result.find((m) => m.id === 'claude-audio-model')!; const gemini = result.find((m) => m.id === 'gemini-audio-pro')!; // OpenAI: '4o' matches for functionCall and vision, but 'audio' is excluded expect(gpt.functionCall).toBe(false); expect(gpt.vision).toBe(false); // Anthropic: 'claude' matches for functionCall and vision, 'audio' is not excluded expect(claude.functionCall).toBe(true); expect(claude.vision).toBe(true); // Google: 'gemini' matches for functionCall and vision, 'audio' is not excluded expect(gemini.functionCall).toBe(true); expect(gemini.vision).toBe(true); }); it('should handle models with partial or incomplete information', async () => { const modelList = [ { id: 'minimal-model' }, // 只有ID { id: 'partial-model', displayName: 'Partial' }, // ID + displayName // 移除无效的模型对象,因为它们会导致 detectModelProvider 出错 ]; const result = await processMultiProviderModelList(modelList); // 应该正确处理有效的模型 expect(result.length).toBe(2); // 检查最简模型是否正确处理 const minimalModel = result.find((m) => m.id === 'minimal-model'); expect(minimalModel).toBeDefined(); expect(minimalModel!.displayName).toBe('minimal-model'); expect(minimalModel!.enabled).toBe(false); // 检查部分模型是否正确处理 const partialModel = result.find((m) => m.id === 'partial-model'); expect(partialModel).toBeDefined(); expect(partialModel!.displayName).toBe('Partial'); expect(partialModel!.enabled).toBe(false); }); }); describe('Advanced integration tests for model parsing', () => { it('should correctly integrate multiple keyword matches with exclusions', async () => { // 设置一些具有多个关键词的特殊模型 const modelList = [ // OpenAI 模型,混合关键词和排除项 { id: 'gpt-4o-audio-special' }, // '4o' 匹配,但 'audio' 被排除 { id: 'gpt-4o-o3-special' }, // 多个匹配:'4o' (fc+vision) 和 'o3' (fc+reasoning) // 其他提供商的特殊组合 { id: 'claude-3-7-vision-special' }, // 'claude' (fc+vision) + '-3-7-' (reasoning) { id: 'gemini-thinking-advanced' }, // 'gemini' (fc+vision) + 'thinking' (reasoning) { id: 'glm-4v-glm-zero-test' }, // 'glm-4v' (vision) + 'glm-4' (fc) + 'glm-zero' (reasoning) ]; const result = await processMultiProviderModelList(modelList); // 检查高级组合 const gptAudio = result.find((m) => m.id === 'gpt-4o-audio-special')!; const gptMulti = result.find((m) => m.id === 'gpt-4o-o3-special')!; const claudeMix = result.find((m) => m.id === 'claude-3-7-vision-special')!; const geminiMix = result.find((m) => m.id === 'gemini-thinking-advanced')!; const glmMix = result.find((m) => m.id === 'glm-4v-glm-zero-test')!; // OpenAI 带排除关键词 expect(gptAudio.functionCall).toBe(false); expect(gptAudio.vision).toBe(false); // OpenAI 带多个匹配关键词 expect(gptMulti.functionCall).toBe(true); // '4o' 或 'o3' expect(gptMulti.vision).toBe(true); // '4o' expect(gptMulti.reasoning).toBe(true); // 'o3' // Anthropic 混合能力 expect(claudeMix.functionCall).toBe(true); // 'claude' expect(claudeMix.vision).toBe(true); // 'claude' expect(claudeMix.reasoning).toBe(true); // '-3-7-' // Google 混合能力 expect(geminiMix.functionCall).toBe(true); // 'gemini' expect(geminiMix.vision).toBe(true); // 'gemini' expect(geminiMix.reasoning).toBe(true); // 'thinking' // Zhipu 混合能力 expect(glmMix.functionCall).toBe(true); // 'glm-4' expect(glmMix.vision).toBe(true); // 'glm-4v' expect(glmMix.reasoning).toBe(true); // 'glm-zero' }); it('should correctly process models with matching substrings', async () => { const modelList = [ // 测试应该激活关键词的子字符串匹配 { id: 'my-gpt-4o-custom' }, // '4o' 是子字符串 { id: 'test-claude-model' }, // 'claude' 是子字符串 { id: 'embedded-gemini-version' }, // 'gemini' 是子字符串 { id: 'prefix-qwen-turbo-suffix' }, // 'qwen-turbo' 是子字符串 // 测试不应该激活关键词的子字符串匹配 { id: 'almost4o-but-not-quite' }, // '4o' 没有精确子字符串匹配 { id: 'claudius-maximus' }, // 'claude' 是更大单词的一部分 { id: 'partial-glm-4v-text' }, // 'glm-4v' 是正确的子字符串 ]; const result = await processMultiProviderModelList(modelList); // 检查正确的子字符串匹配 expect(result.find((m) => m.id === 'my-gpt-4o-custom')!.vision).toBe(true); // '4o' 匹配 expect(result.find((m) => m.id === 'test-claude-model')!.functionCall).toBe(true); // 'claude' 匹配 expect(result.find((m) => m.id === 'embedded-gemini-version')!.functionCall).toBe(true); // 'gemini' 匹配 expect(result.find((m) => m.id === 'prefix-qwen-turbo-suffix')!.functionCall).toBe(true); // 'qwen-turbo' 匹配 // 检查不匹配项 expect(result.find((m) => m.id === 'almost4o-but-not-quite')!.vision).toBe(true); // '4o' 匹配 expect(result.find((m) => m.id === 'claudius-maximus')!.functionCall).toBe(false); // 没有 'claude' 匹配(作为独立词) expect(result.find((m) => m.id === 'partial-glm-4v-text')!.vision).toBe(true); // 'glm-4v' 是正确匹配(因为我们使用 includes,而不是单词边界) }); }); it('should correctly apply abilities when excluded by detected provider and knownModel ability is true', async () => { // 添加到 mockDefaultModelList: const modelId = 'gpt-4o-audio-known-abilities-obj-true'; const tempMockEntry = { id: modelId, displayName: 'GPT-4o Audio Known Abilities True (Obj)', enabled: true, abilities: { functionCall: true, vision: true, reasoning: true, }, }; const mockModule = await import('@/config/aiModels'); mockModule.LOBE_DEFAULT_MODEL_LIST.push(tempMockEntry as any); const modelList = [{ id: modelId }]; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(1); const model = result[0]; expect(model.id).toBe(modelId); // (keyword_match && !excluded) || known_ability || false // ('4o' 是关键词, 'audio' 在 openai 中被排除) // (true && false) || true (来自 knownModel.abilities) || false -> true expect(model.functionCall).toBe(true); expect(model.vision).toBe(true); }); it('should correctly apply abilities when excluded by detected provider and knownModel ability is false', async () => { // 添加到 mockDefaultModelList: const modelId = 'gpt-4o-audio-known-abilities-obj-false'; const tempMockEntry = { id: modelId, displayName: 'GPT-4o Audio Known Abilities False (Obj)', enabled: true, abilities: { functionCall: false, vision: false, reasoning: false, }, }; const mockModule = await import('@/config/aiModels'); mockModule.LOBE_DEFAULT_MODEL_LIST.push(tempMockEntry as any); const modelList = [{ id: modelId }]; const result = await processMultiProviderModelList(modelList); expect(result).toHaveLength(1); const model = result[0]; expect(model.id).toBe(modelId); // (keyword_match && !excluded) || known_ability || false // (true && false) || false (来自 knownModel.abilities) || false -> false expect(model.functionCall).toBe(false); expect(model.vision).toBe(false); }); }); describe('MODEL_LIST_CONFIGS and PROVIDER_DETECTION_CONFIG', () => { it('should have matching keys in both configuration objects', () => { const modelConfigKeys = Object.keys(MODEL_LIST_CONFIGS); const providerDetectionKeys = Object.keys(PROVIDER_DETECTION_CONFIG); expect(modelConfigKeys.sort()).toEqual(providerDetectionKeys.sort()); }); }); });