From 49903a1567963f4bbae2ba221afafcc9fe7db92b Mon Sep 17 00:00:00 2001 From: SuYao Date: Sun, 23 Nov 2025 17:33:27 +0800 Subject: [PATCH] Test/ai-core (#11307) * test: 1 * test: 2 * test: 3 * format * chore: move provider from config to utils * fix: 4 * test: 5 * chore: redundant logic * test: add reasoning model tests and improve provider options typings * chore: format * test 6 * chore: format * test: 7 * test: 8 * fix: test * fix: format and typecheck * fix error * test: isClaude4SeriesModel * fix: test * fix: test --------- Co-authored-by: defi-failure <159208748+defi-failure@users.noreply.github.com> --- biome.jsonc | 4 +- package.json | 1 + .../src/__tests__/fixtures/mock-providers.ts | 180 +++ .../src/__tests__/fixtures/mock-responses.ts | 331 +++++ .../__tests__/helpers/provider-test-utils.ts | 329 +++++ .../src/__tests__/helpers/test-utils.ts | 291 +++++ packages/aiCore/src/__tests__/index.ts | 12 + .../runtime/__tests__/generateText.test.ts | 499 ++++++++ .../core/runtime/__tests__/streamText.test.ts | 525 ++++++++ .../aiCore/legacy/clients/ApiClientFactory.ts | 2 +- .../aiCore/legacy/clients/BaseApiClient.ts | 2 +- .../__tests__/ApiClientFactory.test.ts | 17 + .../legacy/clients/gemini/VertexAPIClient.ts | 3 +- .../legacy/clients/openai/OpenAIApiClient.ts | 15 +- .../clients/openai/OpenAIResponseAPIClient.ts | 2 +- .../common/ErrorHandlerMiddleware.ts | 3 +- .../middleware/AiSdkMiddlewareBuilder.ts | 2 +- .../__tests__/message-converter.test.ts | 234 ++++ .../__tests__/model-parameters.test.ts | 218 ++++ .../src/aiCore/prepareParams/header.ts | 3 +- .../aiCore/prepareParams/modelCapabilities.ts | 13 - .../aiCore/prepareParams/modelParameters.ts | 35 +- .../aiCore/prepareParams/parameterBuilder.ts | 30 +- .../provider/__tests__/providerConfig.test.ts | 15 +- .../src/aiCore/provider/providerConfig.ts | 19 +- .../src/aiCore/utils/__tests__/image.test.ts | 121 ++ .../src/aiCore/utils/__tests__/mcp.test.ts | 435 +++++++ .../aiCore/utils/__tests__/options.test.ts | 542 ++++++++ .../aiCore/utils/__tests__/reasoning.test.ts | 992 ++++++++++++++- .../aiCore/utils/__tests__/websearch.test.ts | 384 ++++++ src/renderer/src/aiCore/utils/options.ts | 39 +- src/renderer/src/aiCore/utils/reasoning.ts | 4 +- .../src/config/__test__/reasoning.test.ts | 553 -------- .../src/config/__test__/vision.test.ts | 167 --- .../src/config/__test__/websearch.test.ts | 64 - .../config/models/__tests__/embedding.test.ts | 101 ++ .../__tests__}/models.test.ts | 83 +- .../config/models/__tests__/reasoning.test.ts | 1125 +++++++++++++++++ .../config/models/__tests__/tooluse.test.ts | 137 ++ .../src/config/models/__tests__/utils.test.ts | 280 ++++ .../config/models/__tests__/vision.test.ts | 310 +++++ .../config/models/__tests__/websearch.test.ts | 382 ++++++ src/renderer/src/config/models/index.ts | 2 + src/renderer/src/config/models/openai.ts | 107 ++ src/renderer/src/config/models/qwen.ts | 7 + src/renderer/src/config/models/reasoning.ts | 27 +- src/renderer/src/config/models/tooluse.ts | 4 - src/renderer/src/config/models/utils.ts | 157 +-- src/renderer/src/config/models/websearch.ts | 35 +- src/renderer/src/config/providers.ts | 161 +-- src/renderer/src/config/tools.ts | 56 - src/renderer/src/hooks/useVertexAI.ts | 7 - .../tools/components/MCPToolsButton.tsx | 2 +- .../components/WebSearchQuickPanelManager.tsx | 2 +- .../home/Inputbar/tools/urlContextTool.tsx | 2 +- .../home/Inputbar/tools/webSearchTool.tsx | 4 +- .../Tabs/components/OpenAISettingsGroup.tsx | 2 +- .../src/pages/paintings/NewApiPage.tsx | 3 +- .../pages/paintings/PaintingsRoutePage.tsx | 2 +- .../EditModelPopup/ModelEditContent.tsx | 2 +- .../ModelList/ManageModelsList.tsx | 2 +- .../ModelList/ManageModelsPopup.tsx | 2 +- .../ProviderSettings/ModelList/ModelList.tsx | 3 +- .../ModelList/NewApiAddModelPopup.tsx | 2 +- .../ProviderSettings/ProviderSetting.tsx | 26 +- src/renderer/src/services/AssistantService.ts | 2 +- src/renderer/src/services/KnowledgeService.ts | 2 +- src/renderer/src/services/ProviderService.ts | 1 + .../src/services/__tests__/ApiService.test.ts | 21 +- src/renderer/src/store/migrate.ts | 12 +- ...code-language.ts => code-language.test.ts} | 0 .../src/utils/__tests__/provider.test.ts | 171 +++ .../utils/__tests__/topicKnowledge.test.ts | 9 + src/renderer/src/utils/provider.ts | 157 ++- tests/renderer.setup.ts | 3 +- yarn.lock | 288 ++++- 76 files changed, 8357 insertions(+), 1430 deletions(-) create mode 100644 packages/aiCore/src/__tests__/fixtures/mock-providers.ts create mode 100644 packages/aiCore/src/__tests__/fixtures/mock-responses.ts create mode 100644 packages/aiCore/src/__tests__/helpers/provider-test-utils.ts create mode 100644 packages/aiCore/src/__tests__/helpers/test-utils.ts create mode 100644 packages/aiCore/src/__tests__/index.ts create mode 100644 packages/aiCore/src/core/runtime/__tests__/generateText.test.ts create mode 100644 packages/aiCore/src/core/runtime/__tests__/streamText.test.ts create mode 100644 src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts create mode 100644 src/renderer/src/aiCore/prepareParams/__tests__/model-parameters.test.ts create mode 100644 src/renderer/src/aiCore/utils/__tests__/image.test.ts create mode 100644 src/renderer/src/aiCore/utils/__tests__/mcp.test.ts create mode 100644 src/renderer/src/aiCore/utils/__tests__/options.test.ts create mode 100644 src/renderer/src/aiCore/utils/__tests__/websearch.test.ts delete mode 100644 src/renderer/src/config/__test__/reasoning.test.ts delete mode 100644 src/renderer/src/config/__test__/vision.test.ts delete mode 100644 src/renderer/src/config/__test__/websearch.test.ts create mode 100644 src/renderer/src/config/models/__tests__/embedding.test.ts rename src/renderer/src/config/{__test__ => models/__tests__}/models.test.ts (74%) create mode 100644 src/renderer/src/config/models/__tests__/reasoning.test.ts create mode 100644 src/renderer/src/config/models/__tests__/tooluse.test.ts create mode 100644 src/renderer/src/config/models/__tests__/utils.test.ts create mode 100644 src/renderer/src/config/models/__tests__/vision.test.ts create mode 100644 src/renderer/src/config/models/__tests__/websearch.test.ts create mode 100644 src/renderer/src/config/models/openai.ts create mode 100644 src/renderer/src/config/models/qwen.ts delete mode 100644 src/renderer/src/config/tools.ts rename src/renderer/src/utils/__tests__/{code-language.ts => code-language.test.ts} (100%) create mode 100644 src/renderer/src/utils/__tests__/provider.test.ts diff --git a/biome.jsonc b/biome.jsonc index 9509135fc..705b1e01f 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -14,7 +14,7 @@ } }, "enabled": true, - "includes": ["**/*.json", "!*.json", "!**/package.json"] + "includes": ["**/*.json", "!*.json", "!**/package.json", "!coverage/**"] }, "css": { "formatter": { @@ -23,7 +23,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/.claude/**"], + "includes": ["**", "!**/.claude/**", "!**/.vscode/**"], "maxSize": 2097152 }, "formatter": { diff --git a/package.json b/package.json index ceb0cbf3a..662152633 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "@ai-sdk/mistral": "^2.0.23", "@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch", "@ai-sdk/perplexity": "^2.0.17", + "@ai-sdk/test-server": "^0.0.1", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@anthropic-ai/sdk": "^0.41.0", "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch", diff --git a/packages/aiCore/src/__tests__/fixtures/mock-providers.ts b/packages/aiCore/src/__tests__/fixtures/mock-providers.ts new file mode 100644 index 000000000..e8ec2a4a0 --- /dev/null +++ b/packages/aiCore/src/__tests__/fixtures/mock-providers.ts @@ -0,0 +1,180 @@ +/** + * Mock Provider Instances + * Provides mock implementations for all supported AI providers + */ + +import type { ImageModelV2, LanguageModelV2 } from '@ai-sdk/provider' +import { vi } from 'vitest' + +/** + * Creates a mock language model with customizable behavior + */ +export function createMockLanguageModel(overrides?: Partial): LanguageModelV2 { + return { + specificationVersion: 'v1', + provider: 'mock-provider', + modelId: 'mock-model', + defaultObjectGenerationMode: 'tool', + + doGenerate: vi.fn().mockResolvedValue({ + text: 'Mock response text', + finishReason: 'stop', + usage: { + promptTokens: 10, + completionTokens: 20, + totalTokens: 30 + }, + rawCall: { rawPrompt: null, rawSettings: {} }, + rawResponse: { headers: {} }, + warnings: [] + }), + + doStream: vi.fn().mockReturnValue({ + stream: (async function* () { + yield { + type: 'text-delta', + textDelta: 'Mock ' + } + yield { + type: 'text-delta', + textDelta: 'streaming ' + } + yield { + type: 'text-delta', + textDelta: 'response' + } + yield { + type: 'finish', + finishReason: 'stop', + usage: { + promptTokens: 10, + completionTokens: 15, + totalTokens: 25 + } + } + })(), + rawCall: { rawPrompt: null, rawSettings: {} }, + rawResponse: { headers: {} }, + warnings: [] + }), + + ...overrides + } as LanguageModelV2 +} + +/** + * Creates a mock image model with customizable behavior + */ +export function createMockImageModel(overrides?: Partial): ImageModelV2 { + return { + specificationVersion: 'v2', + provider: 'mock-provider', + modelId: 'mock-image-model', + + doGenerate: vi.fn().mockResolvedValue({ + images: [ + { + base64: 'mock-base64-image-data', + uint8Array: new Uint8Array([1, 2, 3, 4, 5]), + mimeType: 'image/png' + } + ], + warnings: [] + }), + + ...overrides + } as ImageModelV2 +} + +/** + * Mock provider configurations for testing + */ +export const mockProviderConfigs = { + openai: { + apiKey: 'sk-test-openai-key-123456789', + baseURL: 'https://api.openai.com/v1', + organization: 'test-org' + }, + + anthropic: { + apiKey: 'sk-ant-test-key-123456789', + baseURL: 'https://api.anthropic.com' + }, + + google: { + apiKey: 'test-google-api-key-123456789', + baseURL: 'https://generativelanguage.googleapis.com/v1' + }, + + xai: { + apiKey: 'xai-test-key-123456789', + baseURL: 'https://api.x.ai/v1' + }, + + azure: { + apiKey: 'test-azure-key-123456789', + resourceName: 'test-resource', + deployment: 'test-deployment' + }, + + deepseek: { + apiKey: 'sk-test-deepseek-key-123456789', + baseURL: 'https://api.deepseek.com/v1' + }, + + openrouter: { + apiKey: 'sk-or-test-key-123456789', + baseURL: 'https://openrouter.ai/api/v1' + }, + + huggingface: { + apiKey: 'hf_test_key_123456789', + baseURL: 'https://api-inference.huggingface.co' + }, + + 'openai-compatible': { + apiKey: 'test-compatible-key-123456789', + baseURL: 'https://api.example.com/v1', + name: 'test-provider' + }, + + 'openai-chat': { + apiKey: 'sk-test-chat-key-123456789', + baseURL: 'https://api.openai.com/v1' + } +} as const + +/** + * Mock provider instances for testing + */ +export const mockProviderInstances = { + openai: { + name: 'openai-mock', + languageModel: createMockLanguageModel({ provider: 'openai', modelId: 'gpt-4' }), + imageModel: createMockImageModel({ provider: 'openai', modelId: 'dall-e-3' }) + }, + + anthropic: { + name: 'anthropic-mock', + languageModel: createMockLanguageModel({ provider: 'anthropic', modelId: 'claude-3-5-sonnet-20241022' }) + }, + + google: { + name: 'google-mock', + languageModel: createMockLanguageModel({ provider: 'google', modelId: 'gemini-2.0-flash-exp' }), + imageModel: createMockImageModel({ provider: 'google', modelId: 'imagen-3.0-generate-001' }) + }, + + xai: { + name: 'xai-mock', + languageModel: createMockLanguageModel({ provider: 'xai', modelId: 'grok-2-latest' }), + imageModel: createMockImageModel({ provider: 'xai', modelId: 'grok-2-image-latest' }) + }, + + deepseek: { + name: 'deepseek-mock', + languageModel: createMockLanguageModel({ provider: 'deepseek', modelId: 'deepseek-chat' }) + } +} + +export type ProviderId = keyof typeof mockProviderConfigs diff --git a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts new file mode 100644 index 000000000..9855cfb36 --- /dev/null +++ b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts @@ -0,0 +1,331 @@ +/** + * Mock Responses + * Provides realistic mock responses for all provider types + */ + +import { jsonSchema, type ModelMessage, type Tool } from 'ai' + +/** + * Standard test messages for all scenarios + */ +export const testMessages = { + simple: [{ role: 'user' as const, content: 'Hello, how are you?' }], + + conversation: [ + { role: 'user' as const, content: 'What is the capital of France?' }, + { role: 'assistant' as const, content: 'The capital of France is Paris.' }, + { role: 'user' as const, content: 'What is its population?' } + ], + + withSystem: [ + { role: 'system' as const, content: 'You are a helpful assistant that provides concise answers.' }, + { role: 'user' as const, content: 'Explain quantum computing in one sentence.' } + ], + + withImages: [ + { + role: 'user' as const, + content: [ + { type: 'text' as const, text: 'What is in this image?' }, + { + type: 'image' as const, + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + } + ] + } + ], + + toolUse: [{ role: 'user' as const, content: 'What is the weather in San Francisco?' }], + + multiTurn: [ + { role: 'user' as const, content: 'Can you help me with a math problem?' }, + { role: 'assistant' as const, content: 'Of course! What math problem would you like help with?' }, + { role: 'user' as const, content: 'What is 15 * 23?' }, + { role: 'assistant' as const, content: '15 * 23 = 345' }, + { role: 'user' as const, content: 'Now divide that by 5' } + ] +} satisfies Record + +/** + * Standard test tools for tool calling scenarios + */ +export const testTools: Record = { + getWeather: { + description: 'Get the current weather in a given location', + inputSchema: jsonSchema({ + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and state, e.g. San Francisco, CA' + }, + unit: { + type: 'string', + enum: ['celsius', 'fahrenheit'], + description: 'The temperature unit to use' + } + }, + required: ['location'] + }), + execute: async ({ location, unit = 'fahrenheit' }) => { + return { + location, + temperature: unit === 'celsius' ? 22 : 72, + unit, + condition: 'sunny' + } + } + }, + + calculate: { + description: 'Perform a mathematical calculation', + inputSchema: jsonSchema({ + type: 'object', + properties: { + operation: { + type: 'string', + enum: ['add', 'subtract', 'multiply', 'divide'], + description: 'The operation to perform' + }, + a: { + type: 'number', + description: 'The first number' + }, + b: { + type: 'number', + description: 'The second number' + } + }, + required: ['operation', 'a', 'b'] + }), + execute: async ({ operation, a, b }) => { + const operations = { + add: (x: number, y: number) => x + y, + subtract: (x: number, y: number) => x - y, + multiply: (x: number, y: number) => x * y, + divide: (x: number, y: number) => x / y + } + return { result: operations[operation as keyof typeof operations](a, b) } + } + }, + + searchDatabase: { + description: 'Search for information in a database', + inputSchema: jsonSchema({ + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query' + }, + limit: { + type: 'number', + description: 'Maximum number of results to return', + default: 10 + } + }, + required: ['query'] + }), + execute: async ({ query, limit = 10 }) => { + return { + results: [ + { id: 1, title: `Result 1 for ${query}`, relevance: 0.95 }, + { id: 2, title: `Result 2 for ${query}`, relevance: 0.87 } + ].slice(0, limit) + } + } + } +} + +/** + * Mock streaming chunks for different providers + */ +export const mockStreamingChunks = { + text: [ + { type: 'text-delta' as const, textDelta: 'Hello' }, + { type: 'text-delta' as const, textDelta: ', ' }, + { type: 'text-delta' as const, textDelta: 'this ' }, + { type: 'text-delta' as const, textDelta: 'is ' }, + { type: 'text-delta' as const, textDelta: 'a ' }, + { type: 'text-delta' as const, textDelta: 'test.' } + ], + + withToolCall: [ + { type: 'text-delta' as const, textDelta: 'Let me check the weather for you.' }, + { + type: 'tool-call-delta' as const, + toolCallType: 'function' as const, + toolCallId: 'call_123', + toolName: 'getWeather', + argsTextDelta: '{"location":' + }, + { + type: 'tool-call-delta' as const, + toolCallType: 'function' as const, + toolCallId: 'call_123', + toolName: 'getWeather', + argsTextDelta: ' "San Francisco, CA"}' + }, + { + type: 'tool-call' as const, + toolCallType: 'function' as const, + toolCallId: 'call_123', + toolName: 'getWeather', + args: { location: 'San Francisco, CA' } + } + ], + + withFinish: [ + { type: 'text-delta' as const, textDelta: 'Complete response.' }, + { + type: 'finish' as const, + finishReason: 'stop' as const, + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15 + } + } + ] +} + +/** + * Mock complete responses for non-streaming scenarios + */ +export const mockCompleteResponses = { + simple: { + text: 'This is a simple response.', + finishReason: 'stop' as const, + usage: { + promptTokens: 15, + completionTokens: 8, + totalTokens: 23 + } + }, + + withToolCalls: { + text: 'I will check the weather for you.', + toolCalls: [ + { + toolCallId: 'call_456', + toolName: 'getWeather', + args: { location: 'New York, NY', unit: 'celsius' } + } + ], + finishReason: 'tool-calls' as const, + usage: { + promptTokens: 25, + completionTokens: 12, + totalTokens: 37 + } + }, + + withWarnings: { + text: 'Response with warnings.', + finishReason: 'stop' as const, + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15 + }, + warnings: [ + { + type: 'unsupported-setting' as const, + message: 'Temperature parameter not supported for this model' + } + ] + } +} + +/** + * Mock image generation responses + */ +export const mockImageResponses = { + single: { + image: { + base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + uint8Array: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]), + mimeType: 'image/png' as const + }, + warnings: [] + }, + + multiple: { + images: [ + { + base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + uint8Array: new Uint8Array([137, 80, 78, 71]), + mimeType: 'image/png' as const + }, + { + base64: 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR42mNk+M9QzwAEjDAGACCKAgdZ9zImAAAAAElFTkSuQmCC', + uint8Array: new Uint8Array([137, 80, 78, 71]), + mimeType: 'image/png' as const + } + ], + warnings: [] + }, + + withProviderMetadata: { + image: { + base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + uint8Array: new Uint8Array([137, 80, 78, 71]), + mimeType: 'image/png' as const + }, + providerMetadata: { + openai: { + images: [ + { + revisedPrompt: 'A detailed and enhanced version of the original prompt' + } + ] + } + }, + warnings: [] + } +} + +/** + * Mock error responses + */ +export const mockErrors = { + invalidApiKey: { + name: 'APIError', + message: 'Invalid API key provided', + statusCode: 401 + }, + + rateLimitExceeded: { + name: 'RateLimitError', + message: 'Rate limit exceeded. Please try again later.', + statusCode: 429, + headers: { + 'retry-after': '60' + } + }, + + modelNotFound: { + name: 'ModelNotFoundError', + message: 'The requested model was not found', + statusCode: 404 + }, + + contextLengthExceeded: { + name: 'ContextLengthError', + message: "This model's maximum context length is 4096 tokens", + statusCode: 400 + }, + + timeout: { + name: 'TimeoutError', + message: 'Request timed out after 30000ms', + code: 'ETIMEDOUT' + }, + + networkError: { + name: 'NetworkError', + message: 'Network connection failed', + code: 'ECONNREFUSED' + } +} diff --git a/packages/aiCore/src/__tests__/helpers/provider-test-utils.ts b/packages/aiCore/src/__tests__/helpers/provider-test-utils.ts new file mode 100644 index 000000000..f8a2051b4 --- /dev/null +++ b/packages/aiCore/src/__tests__/helpers/provider-test-utils.ts @@ -0,0 +1,329 @@ +/** + * Provider-Specific Test Utilities + * Helper functions for testing individual providers with all their parameters + */ + +import type { Tool } from 'ai' +import { expect } from 'vitest' + +/** + * Provider parameter configurations for comprehensive testing + */ +export const providerParameterMatrix = { + openai: { + models: ['gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo', 'gpt-4o'], + parameters: { + temperature: [0, 0.5, 0.7, 1.0, 1.5, 2.0], + maxTokens: [100, 500, 1000, 2000, 4000], + topP: [0.1, 0.5, 0.9, 1.0], + frequencyPenalty: [-2.0, -1.0, 0, 1.0, 2.0], + presencePenalty: [-2.0, -1.0, 0, 1.0, 2.0], + stop: [undefined, ['stop'], ['STOP', 'END']], + seed: [undefined, 12345, 67890], + responseFormat: [undefined, { type: 'json_object' as const }], + user: [undefined, 'test-user-123'] + }, + toolChoice: ['auto', 'required', 'none', { type: 'function' as const, name: 'getWeather' }], + parallelToolCalls: [true, false] + }, + + anthropic: { + models: ['claude-3-5-sonnet-20241022', 'claude-3-opus-20240229', 'claude-3-haiku-20240307'], + parameters: { + temperature: [0, 0.5, 1.0], + maxTokens: [100, 1000, 4000, 8000], + topP: [0.1, 0.5, 0.9, 1.0], + topK: [undefined, 1, 5, 10, 40], + stop: [undefined, ['Human:', 'Assistant:']], + metadata: [undefined, { userId: 'test-123' }] + }, + toolChoice: ['auto', 'any', { type: 'tool' as const, name: 'getWeather' }] + }, + + google: { + models: ['gemini-2.0-flash-exp', 'gemini-1.5-pro', 'gemini-1.5-flash'], + parameters: { + temperature: [0, 0.5, 0.9, 1.0], + maxTokens: [100, 1000, 2000, 8000], + topP: [0.1, 0.5, 0.95, 1.0], + topK: [undefined, 1, 16, 40], + stopSequences: [undefined, ['END'], ['STOP', 'TERMINATE']] + }, + safetySettings: [ + undefined, + [ + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_MEDIUM_AND_ABOVE' }, + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' } + ] + ] + }, + + xai: { + models: ['grok-2-latest', 'grok-2-1212'], + parameters: { + temperature: [0, 0.5, 1.0, 1.5], + maxTokens: [100, 500, 2000, 4000], + topP: [0.1, 0.5, 0.9, 1.0], + stop: [undefined, ['STOP'], ['END', 'TERMINATE']], + seed: [undefined, 12345] + } + }, + + deepseek: { + models: ['deepseek-chat', 'deepseek-coder'], + parameters: { + temperature: [0, 0.5, 1.0], + maxTokens: [100, 1000, 4000], + topP: [0.1, 0.5, 0.95], + frequencyPenalty: [0, 0.5, 1.0], + presencePenalty: [0, 0.5, 1.0], + stop: [undefined, ['```'], ['END']] + } + }, + + azure: { + deployments: ['gpt-4-deployment', 'gpt-35-turbo-deployment'], + parameters: { + temperature: [0, 0.7, 1.0], + maxTokens: [100, 1000, 2000], + topP: [0.1, 0.5, 0.95], + frequencyPenalty: [0, 1.0], + presencePenalty: [0, 1.0], + stop: [undefined, ['STOP']] + } + } +} as const + +/** + * Creates test cases for all parameter combinations + */ +export function generateParameterTestCases>( + params: T, + maxCombinations = 50 +): Array> { + const keys = Object.keys(params) as Array + const testCases: Array> = [] + + // Generate combinations using sampling strategy for large parameter spaces + const totalCombinations = keys.reduce((acc, key) => acc * params[key].length, 1) + + if (totalCombinations <= maxCombinations) { + // Generate all combinations if total is small + generateAllCombinations(params, keys, 0, {}, testCases) + } else { + // Sample diverse combinations if total is large + generateSampledCombinations(params, keys, maxCombinations, testCases) + } + + return testCases +} + +function generateAllCombinations>( + params: T, + keys: Array, + index: number, + current: Partial<{ [K in keyof T]: T[K][number] }>, + results: Array> +) { + if (index === keys.length) { + results.push({ ...current }) + return + } + + const key = keys[index] + for (const value of params[key]) { + generateAllCombinations(params, keys, index + 1, { ...current, [key]: value }, results) + } +} + +function generateSampledCombinations>( + params: T, + keys: Array, + count: number, + results: Array> +) { + // Generate edge cases first (min/max values) + const edgeCase1: any = {} + const edgeCase2: any = {} + + for (const key of keys) { + edgeCase1[key] = params[key][0] + edgeCase2[key] = params[key][params[key].length - 1] + } + + results.push(edgeCase1, edgeCase2) + + // Generate random combinations for the rest + for (let i = results.length; i < count; i++) { + const combination: any = {} + for (const key of keys) { + const values = params[key] + combination[key] = values[Math.floor(Math.random() * values.length)] + } + results.push(combination) + } +} + +/** + * Validates that all provider-specific parameters are correctly passed through + */ +export function validateProviderParams(providerId: string, actualParams: any, expectedParams: any): void { + const requiredFields: Record = { + openai: ['model', 'messages'], + anthropic: ['model', 'messages'], + google: ['model', 'contents'], + xai: ['model', 'messages'], + deepseek: ['model', 'messages'], + azure: ['messages'] + } + + const fields = requiredFields[providerId] || ['model', 'messages'] + + for (const field of fields) { + expect(actualParams).toHaveProperty(field) + } + + // Validate optional parameters if they were provided + const optionalParams = ['temperature', 'max_tokens', 'top_p', 'stop', 'tools'] + + for (const param of optionalParams) { + if (expectedParams[param] !== undefined) { + expect(actualParams[param]).toEqual(expectedParams[param]) + } + } +} + +/** + * Creates a comprehensive test suite for a provider + */ +// oxlint-disable-next-line no-unused-vars +export function createProviderTestSuite(_providerId: string) { + return { + testBasicCompletion: async (executor: any, model: string) => { + const result = await executor.generateText({ + model, + messages: [{ role: 'user' as const, content: 'Hello' }] + }) + + expect(result).toBeDefined() + expect(result.text).toBeDefined() + expect(typeof result.text).toBe('string') + }, + + testStreaming: async (executor: any, model: string) => { + const chunks: any[] = [] + const result = await executor.streamText({ + model, + messages: [{ role: 'user' as const, content: 'Hello' }] + }) + + for await (const chunk of result.textStream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + }, + + testTemperature: async (executor: any, model: string, temperatures: number[]) => { + for (const temperature of temperatures) { + const result = await executor.generateText({ + model, + messages: [{ role: 'user' as const, content: 'Hello' }], + temperature + }) + + expect(result).toBeDefined() + } + }, + + testMaxTokens: async (executor: any, model: string, maxTokensValues: number[]) => { + for (const maxTokens of maxTokensValues) { + const result = await executor.generateText({ + model, + messages: [{ role: 'user' as const, content: 'Hello' }], + maxTokens + }) + + expect(result).toBeDefined() + if (result.usage?.completionTokens) { + expect(result.usage.completionTokens).toBeLessThanOrEqual(maxTokens) + } + } + }, + + testToolCalling: async (executor: any, model: string, tools: Record) => { + const result = await executor.generateText({ + model, + messages: [{ role: 'user' as const, content: 'What is the weather in SF?' }], + tools + }) + + expect(result).toBeDefined() + }, + + testStopSequences: async (executor: any, model: string, stopSequences: string[][]) => { + for (const stop of stopSequences) { + const result = await executor.generateText({ + model, + messages: [{ role: 'user' as const, content: 'Count to 10' }], + stop + }) + + expect(result).toBeDefined() + } + } + } +} + +/** + * Generates test data for vision/multimodal testing + */ +export function createVisionTestData() { + return { + imageUrl: 'https://example.com/test-image.jpg', + base64Image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + messages: [ + { + role: 'user' as const, + content: [ + { type: 'text' as const, text: 'What is in this image?' }, + { + type: 'image' as const, + image: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + } + ] + } + ] + } +} + +/** + * Creates mock responses for different finish reasons + */ +export function createFinishReasonMocks() { + return { + stop: { + text: 'Complete response.', + finishReason: 'stop' as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 } + }, + length: { + text: 'Incomplete response due to', + finishReason: 'length' as const, + usage: { promptTokens: 10, completionTokens: 100, totalTokens: 110 } + }, + 'tool-calls': { + text: 'Calling tools', + finishReason: 'tool-calls' as const, + toolCalls: [{ toolCallId: 'call_1', toolName: 'getWeather', args: { location: 'SF' } }], + usage: { promptTokens: 10, completionTokens: 8, totalTokens: 18 } + }, + 'content-filter': { + text: '', + finishReason: 'content-filter' as const, + usage: { promptTokens: 10, completionTokens: 0, totalTokens: 10 } + } + } +} diff --git a/packages/aiCore/src/__tests__/helpers/test-utils.ts b/packages/aiCore/src/__tests__/helpers/test-utils.ts new file mode 100644 index 000000000..823107578 --- /dev/null +++ b/packages/aiCore/src/__tests__/helpers/test-utils.ts @@ -0,0 +1,291 @@ +/** + * Test Utilities + * Helper functions for testing AI Core functionality + */ + +import { expect, vi } from 'vitest' + +import type { ProviderId } from '../fixtures/mock-providers' +import { createMockImageModel, createMockLanguageModel, mockProviderConfigs } from '../fixtures/mock-providers' + +/** + * Creates a test provider with streaming support + */ +export function createTestStreamingProvider(chunks: any[]) { + return createMockLanguageModel({ + doStream: vi.fn().mockReturnValue({ + stream: (async function* () { + for (const chunk of chunks) { + yield chunk + } + })(), + rawCall: { rawPrompt: null, rawSettings: {} }, + rawResponse: { headers: {} }, + warnings: [] + }) + }) +} + +/** + * Creates a test provider that throws errors + */ +export function createErrorProvider(error: Error) { + return createMockLanguageModel({ + doGenerate: vi.fn().mockRejectedValue(error), + doStream: vi.fn().mockImplementation(() => { + throw error + }) + }) +} + +/** + * Collects all chunks from a stream + */ +export async function collectStreamChunks(stream: AsyncIterable): Promise { + const chunks: T[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks +} + +/** + * Waits for a specific number of milliseconds + */ +export function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Creates a mock abort controller that aborts after a delay + */ +export function createDelayedAbortController(delayMs: number): AbortController { + const controller = new AbortController() + setTimeout(() => controller.abort(), delayMs) + return controller +} + +/** + * Asserts that a function throws an error with a specific message + */ +export async function expectError(fn: () => Promise, expectedMessage?: string | RegExp): Promise { + try { + await fn() + throw new Error('Expected function to throw an error, but it did not') + } catch (error) { + if (expectedMessage) { + const message = (error as Error).message + if (typeof expectedMessage === 'string') { + if (!message.includes(expectedMessage)) { + throw new Error(`Expected error message to include "${expectedMessage}", but got "${message}"`) + } + } else { + if (!expectedMessage.test(message)) { + throw new Error(`Expected error message to match ${expectedMessage}, but got "${message}"`) + } + } + } + return error as Error + } +} + +/** + * Creates a spy function that tracks calls and arguments + */ +export function createSpy any>() { + const calls: Array<{ args: Parameters; result?: ReturnType; error?: Error }> = [] + + const spy = vi.fn((...args: Parameters) => { + try { + const result = undefined as ReturnType + calls.push({ args, result }) + return result + } catch (error) { + calls.push({ args, error: error as Error }) + throw error + } + }) + + return { + fn: spy, + calls, + getCalls: () => calls, + getCallCount: () => calls.length, + getLastCall: () => calls[calls.length - 1], + reset: () => { + calls.length = 0 + spy.mockClear() + } + } +} + +/** + * Validates provider configuration + */ +export function validateProviderConfig(providerId: ProviderId) { + const config = mockProviderConfigs[providerId] + if (!config) { + throw new Error(`No mock configuration found for provider: ${providerId}`) + } + + if (!config.apiKey) { + throw new Error(`Provider ${providerId} is missing apiKey in mock config`) + } + + return config +} + +/** + * Creates a test context with common setup + */ +export function createTestContext() { + const mocks = { + languageModel: createMockLanguageModel(), + imageModel: createMockImageModel(), + providers: new Map() + } + + const cleanup = () => { + mocks.providers.clear() + vi.clearAllMocks() + } + + return { + mocks, + cleanup + } +} + +/** + * Measures execution time of an async function + */ +export async function measureTime(fn: () => Promise): Promise<{ result: T; duration: number }> { + const start = Date.now() + const result = await fn() + const duration = Date.now() - start + return { result, duration } +} + +/** + * Retries a function until it succeeds or max attempts reached + */ +export async function retryUntilSuccess(fn: () => Promise, maxAttempts = 3, delayMs = 100): Promise { + let lastError: Error | undefined + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error as Error + if (attempt < maxAttempts) { + await wait(delayMs) + } + } + } + + throw lastError || new Error('All retry attempts failed') +} + +/** + * Creates a mock streaming response that emits chunks at intervals + */ +export function createTimedStream(chunks: T[], intervalMs = 10) { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + await wait(intervalMs) + yield chunk + } + } + } +} + +/** + * Asserts that two objects are deeply equal, ignoring specified keys + */ +export function assertDeepEqualIgnoring>( + actual: T, + expected: T, + ignoreKeys: string[] = [] +): void { + const filterKeys = (obj: T): Partial => { + const filtered = { ...obj } + for (const key of ignoreKeys) { + delete filtered[key] + } + return filtered + } + + const filteredActual = filterKeys(actual) + const filteredExpected = filterKeys(expected) + + expect(filteredActual).toEqual(filteredExpected) +} + +/** + * Creates a provider mock that simulates rate limiting + */ +export function createRateLimitedProvider(limitPerSecond: number) { + const calls: number[] = [] + + return createMockLanguageModel({ + doGenerate: vi.fn().mockImplementation(async () => { + const now = Date.now() + calls.push(now) + + // Remove calls older than 1 second + const recentCalls = calls.filter((time) => now - time < 1000) + + if (recentCalls.length > limitPerSecond) { + throw new Error('Rate limit exceeded') + } + + return { + text: 'Rate limited response', + finishReason: 'stop' as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + rawCall: { rawPrompt: null, rawSettings: {} }, + rawResponse: { headers: {} }, + warnings: [] + } + }) + }) +} + +/** + * Validates streaming response structure + */ +export function validateStreamChunk(chunk: any): void { + expect(chunk).toBeDefined() + expect(chunk).toHaveProperty('type') + + if (chunk.type === 'text-delta') { + expect(chunk).toHaveProperty('textDelta') + expect(typeof chunk.textDelta).toBe('string') + } else if (chunk.type === 'finish') { + expect(chunk).toHaveProperty('finishReason') + expect(chunk).toHaveProperty('usage') + } else if (chunk.type === 'tool-call') { + expect(chunk).toHaveProperty('toolCallId') + expect(chunk).toHaveProperty('toolName') + expect(chunk).toHaveProperty('args') + } +} + +/** + * Creates a test logger that captures log messages + */ +export function createTestLogger() { + const logs: Array<{ level: string; message: string; meta?: any }> = [] + + return { + info: (message: string, meta?: any) => logs.push({ level: 'info', message, meta }), + warn: (message: string, meta?: any) => logs.push({ level: 'warn', message, meta }), + error: (message: string, meta?: any) => logs.push({ level: 'error', message, meta }), + debug: (message: string, meta?: any) => logs.push({ level: 'debug', message, meta }), + getLogs: () => logs, + clear: () => { + logs.length = 0 + } + } +} diff --git a/packages/aiCore/src/__tests__/index.ts b/packages/aiCore/src/__tests__/index.ts new file mode 100644 index 000000000..23ecd167a --- /dev/null +++ b/packages/aiCore/src/__tests__/index.ts @@ -0,0 +1,12 @@ +/** + * Test Infrastructure Exports + * Central export point for all test utilities, fixtures, and helpers + */ + +// Fixtures +export * from './fixtures/mock-providers' +export * from './fixtures/mock-responses' + +// Helpers +export * from './helpers/provider-test-utils' +export * from './helpers/test-utils' diff --git a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts new file mode 100644 index 000000000..9a0f20415 --- /dev/null +++ b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts @@ -0,0 +1,499 @@ +/** + * RuntimeExecutor.generateText Comprehensive Tests + * Tests non-streaming text generation across all providers with various parameters + */ + +import { generateText } from 'ai' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createMockLanguageModel, + mockCompleteResponses, + mockProviderConfigs, + testMessages, + testTools +} from '../../../__tests__' +import type { AiPlugin } from '../../plugins' +import { globalRegistryManagement } from '../../providers/RegistryManagement' +import { RuntimeExecutor } from '../executor' + +// Mock AI SDK +vi.mock('ai', () => ({ + generateText: vi.fn() +})) + +vi.mock('../../providers/RegistryManagement', () => ({ + globalRegistryManagement: { + languageModel: vi.fn() + }, + DEFAULT_SEPARATOR: '|' +})) + +describe('RuntimeExecutor.generateText', () => { + let executor: RuntimeExecutor<'openai'> + let mockLanguageModel: any + + beforeEach(() => { + vi.clearAllMocks() + + executor = RuntimeExecutor.create('openai', mockProviderConfigs.openai) + + mockLanguageModel = createMockLanguageModel({ + provider: 'openai', + modelId: 'gpt-4' + }) + + vi.mocked(globalRegistryManagement.languageModel).mockReturnValue(mockLanguageModel) + vi.mocked(generateText).mockResolvedValue(mockCompleteResponses.simple as any) + }) + + describe('Basic Functionality', () => { + it('should generate text with minimal parameters', async () => { + const result = await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + expect(generateText).toHaveBeenCalledWith({ + model: mockLanguageModel, + messages: testMessages.simple + }) + + expect(result.text).toBe('This is a simple response.') + expect(result.finishReason).toBe('stop') + expect(result.usage).toBeDefined() + }) + + it('should generate with system messages', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.withSystem + }) + + expect(generateText).toHaveBeenCalledWith({ + model: mockLanguageModel, + messages: testMessages.withSystem + }) + }) + + it('should generate with conversation history', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.conversation + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: testMessages.conversation + }) + ) + }) + }) + + describe('All Parameter Combinations', () => { + it('should support all parameters together', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple, + temperature: 0.7, + maxOutputTokens: 500, + topP: 0.9, + frequencyPenalty: 0.5, + presencePenalty: 0.3, + stopSequences: ['STOP'], + seed: 12345 + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + maxOutputTokens: 500, + topP: 0.9, + frequencyPenalty: 0.5, + presencePenalty: 0.3, + stopSequences: ['STOP'], + seed: 12345 + }) + ) + }) + + it('should support partial parameters', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple, + temperature: 0.5, + maxOutputTokens: 100 + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + maxOutputTokens: 100 + }) + ) + }) + }) + + describe('Tool Calling', () => { + beforeEach(() => { + vi.mocked(generateText).mockResolvedValue(mockCompleteResponses.withToolCalls as any) + }) + + it('should support tool calling', async () => { + const result = await executor.generateText({ + model: 'gpt-4', + messages: testMessages.toolUse, + tools: testTools + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: testTools + }) + ) + + expect(result.toolCalls).toBeDefined() + expect(result.toolCalls).toHaveLength(1) + }) + + it('should support toolChoice auto', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.toolUse, + tools: testTools, + toolChoice: 'auto' + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: 'auto' + }) + ) + }) + + it('should support toolChoice required', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.toolUse, + tools: testTools, + toolChoice: 'required' + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: 'required' + }) + ) + }) + + it('should support toolChoice none', async () => { + vi.mocked(generateText).mockResolvedValue(mockCompleteResponses.simple as any) + + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple, + tools: testTools, + toolChoice: 'none' + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: 'none' + }) + ) + }) + + it('should support specific tool selection', async () => { + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.toolUse, + tools: testTools, + toolChoice: { + type: 'tool', + toolName: 'getWeather' + } + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + toolChoice: { + type: 'tool', + toolName: 'getWeather' + } + }) + ) + }) + }) + + describe('Multiple Providers', () => { + it('should work with Anthropic provider', async () => { + const anthropicExecutor = RuntimeExecutor.create('anthropic', mockProviderConfigs.anthropic) + + const anthropicModel = createMockLanguageModel({ + provider: 'anthropic', + modelId: 'claude-3-5-sonnet-20241022' + }) + + vi.mocked(globalRegistryManagement.languageModel).mockReturnValue(anthropicModel) + + await anthropicExecutor.generateText({ + model: 'claude-3-5-sonnet-20241022', + messages: testMessages.simple + }) + + expect(globalRegistryManagement.languageModel).toHaveBeenCalledWith('anthropic|claude-3-5-sonnet-20241022') + }) + + it('should work with Google provider', async () => { + const googleExecutor = RuntimeExecutor.create('google', mockProviderConfigs.google) + + const googleModel = createMockLanguageModel({ + provider: 'google', + modelId: 'gemini-2.0-flash-exp' + }) + + vi.mocked(globalRegistryManagement.languageModel).mockReturnValue(googleModel) + + await googleExecutor.generateText({ + model: 'gemini-2.0-flash-exp', + messages: testMessages.simple + }) + + expect(globalRegistryManagement.languageModel).toHaveBeenCalledWith('google|gemini-2.0-flash-exp') + }) + + it('should work with xAI provider', async () => { + const xaiExecutor = RuntimeExecutor.create('xai', mockProviderConfigs.xai) + + const xaiModel = createMockLanguageModel({ + provider: 'xai', + modelId: 'grok-2-latest' + }) + + vi.mocked(globalRegistryManagement.languageModel).mockReturnValue(xaiModel) + + await xaiExecutor.generateText({ + model: 'grok-2-latest', + messages: testMessages.simple + }) + + expect(globalRegistryManagement.languageModel).toHaveBeenCalledWith('xai|grok-2-latest') + }) + + it('should work with DeepSeek provider', async () => { + const deepseekExecutor = RuntimeExecutor.create('deepseek', mockProviderConfigs.deepseek) + + const deepseekModel = createMockLanguageModel({ + provider: 'deepseek', + modelId: 'deepseek-chat' + }) + + vi.mocked(globalRegistryManagement.languageModel).mockReturnValue(deepseekModel) + + await deepseekExecutor.generateText({ + model: 'deepseek-chat', + messages: testMessages.simple + }) + + expect(globalRegistryManagement.languageModel).toHaveBeenCalledWith('deepseek|deepseek-chat') + }) + }) + + describe('Plugin Integration', () => { + it('should execute all plugin hooks', async () => { + const pluginCalls: string[] = [] + + const testPlugin: AiPlugin = { + name: 'test-plugin', + onRequestStart: vi.fn(async () => { + pluginCalls.push('onRequestStart') + }), + transformParams: vi.fn(async (params) => { + pluginCalls.push('transformParams') + return { ...params, temperature: 0.8 } + }), + transformResult: vi.fn(async (result) => { + pluginCalls.push('transformResult') + return { ...result, text: result.text + ' [modified]' } + }), + onRequestEnd: vi.fn(async () => { + pluginCalls.push('onRequestEnd') + }) + } + + const executorWithPlugin = RuntimeExecutor.create('openai', mockProviderConfigs.openai, [testPlugin]) + + const result = await executorWithPlugin.generateText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + expect(pluginCalls).toEqual(['onRequestStart', 'transformParams', 'transformResult', 'onRequestEnd']) + + // Verify transformed parameters + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.8 + }) + ) + + // Verify transformed result + expect(result.text).toContain('[modified]') + }) + + it('should handle multiple plugins in order', async () => { + const pluginOrder: string[] = [] + + const plugin1: AiPlugin = { + name: 'plugin-1', + transformParams: vi.fn(async (params) => { + pluginOrder.push('plugin-1') + return { ...params, temperature: 0.5 } + }) + } + + const plugin2: AiPlugin = { + name: 'plugin-2', + transformParams: vi.fn(async (params) => { + pluginOrder.push('plugin-2') + return { ...params, maxTokens: 200 } + }) + } + + const executorWithPlugins = RuntimeExecutor.create('openai', mockProviderConfigs.openai, [plugin1, plugin2]) + + await executorWithPlugins.generateText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + expect(pluginOrder).toEqual(['plugin-1', 'plugin-2']) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + maxTokens: 200 + }) + ) + }) + }) + + describe('Error Handling', () => { + it('should handle API errors', async () => { + const error = new Error('API request failed') + vi.mocked(generateText).mockRejectedValue(error) + + await expect( + executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple + }) + ).rejects.toThrow('API request failed') + }) + + it('should execute onError plugin hook', async () => { + const error = new Error('Generation failed') + vi.mocked(generateText).mockRejectedValue(error) + + const errorPlugin: AiPlugin = { + name: 'error-handler', + onError: vi.fn() + } + + const executorWithPlugin = RuntimeExecutor.create('openai', mockProviderConfigs.openai, [errorPlugin]) + + await expect( + executorWithPlugin.generateText({ + model: 'gpt-4', + messages: testMessages.simple + }) + ).rejects.toThrow('Generation failed') + + expect(errorPlugin.onError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + providerId: 'openai', + modelId: 'gpt-4' + }) + ) + }) + + it('should handle model not found error', async () => { + const error = new Error('Model not found: invalid-model') + vi.mocked(globalRegistryManagement.languageModel).mockImplementation(() => { + throw error + }) + + await expect( + executor.generateText({ + model: 'invalid-model', + messages: testMessages.simple + }) + ).rejects.toThrow('Model not found') + }) + }) + + describe('Usage and Metadata', () => { + it('should return usage information', async () => { + const result = await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + expect(result.usage).toBeDefined() + expect(result.usage.inputTokens).toBe(15) + expect(result.usage.outputTokens).toBe(8) + expect(result.usage.totalTokens).toBe(23) + }) + + it('should handle warnings', async () => { + vi.mocked(generateText).mockResolvedValue(mockCompleteResponses.withWarnings as any) + + const result = await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple, + temperature: 2.5 // Unsupported value + }) + + expect(result.warnings).toBeDefined() + expect(result.warnings).toHaveLength(1) + expect(result.warnings![0].type).toBe('unsupported-setting') + }) + }) + + describe('Abort Signal', () => { + it('should support abort signal', async () => { + const abortController = new AbortController() + + await executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple, + abortSignal: abortController.signal + }) + + expect(generateText).toHaveBeenCalledWith( + expect.objectContaining({ + abortSignal: abortController.signal + }) + ) + }) + + it('should handle aborted request', async () => { + const abortError = new Error('Request aborted') + abortError.name = 'AbortError' + + vi.mocked(generateText).mockRejectedValue(abortError) + + const abortController = new AbortController() + abortController.abort() + + await expect( + executor.generateText({ + model: 'gpt-4', + messages: testMessages.simple, + abortSignal: abortController.signal + }) + ).rejects.toThrow('Request aborted') + }) + }) +}) diff --git a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts new file mode 100644 index 000000000..eae04783b --- /dev/null +++ b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts @@ -0,0 +1,525 @@ +/** + * RuntimeExecutor.streamText Comprehensive Tests + * Tests streaming text generation across all providers with various parameters + */ + +import { streamText } from 'ai' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { collectStreamChunks, createMockLanguageModel, mockProviderConfigs, testMessages } from '../../../__tests__' +import type { AiPlugin } from '../../plugins' +import { globalRegistryManagement } from '../../providers/RegistryManagement' +import { RuntimeExecutor } from '../executor' + +// Mock AI SDK +vi.mock('ai', () => ({ + streamText: vi.fn() +})) + +vi.mock('../../providers/RegistryManagement', () => ({ + globalRegistryManagement: { + languageModel: vi.fn() + }, + DEFAULT_SEPARATOR: '|' +})) + +describe('RuntimeExecutor.streamText', () => { + let executor: RuntimeExecutor<'openai'> + let mockLanguageModel: any + + beforeEach(() => { + vi.clearAllMocks() + + executor = RuntimeExecutor.create('openai', mockProviderConfigs.openai) + + mockLanguageModel = createMockLanguageModel({ + provider: 'openai', + modelId: 'gpt-4' + }) + + vi.mocked(globalRegistryManagement.languageModel).mockReturnValue(mockLanguageModel) + }) + + describe('Basic Functionality', () => { + it('should stream text with minimal parameters', async () => { + const mockStream = { + textStream: (async function* () { + yield 'Hello' + yield ' ' + yield 'World' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Hello' } + yield { type: 'text-delta', textDelta: ' ' } + yield { type: 'text-delta', textDelta: 'World' } + })(), + usage: Promise.resolve({ promptTokens: 5, completionTokens: 3, totalTokens: 8 }) + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + const result = await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + expect(streamText).toHaveBeenCalledWith({ + model: mockLanguageModel, + messages: testMessages.simple + }) + + const chunks = await collectStreamChunks(result.textStream) + expect(chunks).toEqual(['Hello', ' ', 'World']) + }) + + it('should stream with system messages', async () => { + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.withSystem + }) + + expect(streamText).toHaveBeenCalledWith({ + model: mockLanguageModel, + messages: testMessages.withSystem + }) + }) + + it('should stream multi-turn conversations', async () => { + const mockStream = { + textStream: (async function* () { + yield 'Multi-turn response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Multi-turn response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.multiTurn + }) + + expect(streamText).toHaveBeenCalled() + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: testMessages.multiTurn + }) + ) + }) + }) + + describe('Temperature Parameter', () => { + const temperatures = [0, 0.3, 0.5, 0.7, 0.9, 1.0, 1.5, 2.0] + + it.each(temperatures)('should support temperature=%s', async (temperature) => { + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + temperature + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature + }) + ) + }) + }) + + describe('Max Tokens Parameter', () => { + const maxTokensValues = [10, 50, 100, 500, 1000, 2000, 4000] + + it.each(maxTokensValues)('should support maxTokens=%s', async (maxTokens) => { + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + maxOutputTokens: maxTokens + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + maxTokens + }) + ) + }) + }) + + describe('Top P Parameter', () => { + const topPValues = [0.1, 0.3, 0.5, 0.7, 0.9, 0.95, 1.0] + + it.each(topPValues)('should support topP=%s', async (topP) => { + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + topP + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + topP + }) + ) + }) + }) + + describe('Frequency and Presence Penalty', () => { + it('should support frequency penalty', async () => { + const penalties = [-2.0, -1.0, 0, 0.5, 1.0, 1.5, 2.0] + + for (const frequencyPenalty of penalties) { + vi.clearAllMocks() + + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + frequencyPenalty + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + frequencyPenalty + }) + ) + } + }) + + it('should support presence penalty', async () => { + const penalties = [-2.0, -1.0, 0, 0.5, 1.0, 1.5, 2.0] + + for (const presencePenalty of penalties) { + vi.clearAllMocks() + + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + presencePenalty + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + presencePenalty + }) + ) + } + }) + + it('should support both penalties together', async () => { + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + frequencyPenalty: 0.5, + presencePenalty: 0.5 + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + frequencyPenalty: 0.5, + presencePenalty: 0.5 + }) + ) + }) + }) + + describe('Seed Parameter', () => { + it('should support seed for deterministic output', async () => { + const seeds = [0, 12345, 67890, 999999] + + for (const seed of seeds) { + vi.clearAllMocks() + + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + seed + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + seed + }) + ) + } + }) + }) + + describe('Abort Signal', () => { + it('should support abort signal', async () => { + const abortController = new AbortController() + + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + abortSignal: abortController.signal + }) + + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + abortSignal: abortController.signal + }) + ) + }) + + it('should handle abort during streaming', async () => { + const abortController = new AbortController() + + const mockStream = { + textStream: (async function* () { + yield 'Start' + // Simulate abort + abortController.abort() + throw new Error('Aborted') + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Start' } + throw new Error('Aborted') + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + const result = await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple, + abortSignal: abortController.signal + }) + + await expect(async () => { + // oxlint-disable-next-line no-unused-vars + for await (const _chunk of result.textStream) { + // Stream should be interrupted + } + }).rejects.toThrow('Aborted') + }) + }) + + describe('Plugin Integration', () => { + it('should execute plugins during streaming', async () => { + const pluginCalls: string[] = [] + + const testPlugin: AiPlugin = { + name: 'test-plugin', + onRequestStart: vi.fn(async () => { + pluginCalls.push('onRequestStart') + }), + transformParams: vi.fn(async (params) => { + pluginCalls.push('transformParams') + return { ...params, temperature: 0.5 } + }), + onRequestEnd: vi.fn(async () => { + pluginCalls.push('onRequestEnd') + }) + } + + const executorWithPlugin = RuntimeExecutor.create('openai', mockProviderConfigs.openai, [testPlugin]) + + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + const result = await executorWithPlugin.streamText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + // Consume stream + // oxlint-disable-next-line no-unused-vars + for await (const _chunk of result.textStream) { + // Stream chunks + } + + expect(pluginCalls).toContain('onRequestStart') + expect(pluginCalls).toContain('transformParams') + + // Verify transformed parameters were used + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5 + }) + ) + }) + }) + + describe('Full Stream with Finish Reason', () => { + it('should provide finish reason in full stream', async () => { + const mockStream = { + textStream: (async function* () { + yield 'Response' + })(), + fullStream: (async function* () { + yield { type: 'text-delta', textDelta: 'Response' } + yield { + type: 'finish', + finishReason: 'stop', + usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 } + } + })() + } + + vi.mocked(streamText).mockResolvedValue(mockStream as any) + + const result = await executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple + }) + + const fullChunks = await collectStreamChunks(result.fullStream) + + expect(fullChunks).toHaveLength(2) + expect(fullChunks[0]).toEqual({ type: 'text-delta', textDelta: 'Response' }) + expect(fullChunks[1]).toEqual({ + type: 'finish', + finishReason: 'stop', + usage: { promptTokens: 5, completionTokens: 3, totalTokens: 8 } + }) + }) + }) + + describe('Error Handling', () => { + it('should handle streaming errors', async () => { + const error = new Error('Streaming failed') + vi.mocked(streamText).mockRejectedValue(error) + + await expect( + executor.streamText({ + model: 'gpt-4', + messages: testMessages.simple + }) + ).rejects.toThrow('Streaming failed') + }) + + it('should execute onError plugin hook on failure', async () => { + const error = new Error('Stream error') + vi.mocked(streamText).mockRejectedValue(error) + + const errorPlugin: AiPlugin = { + name: 'error-handler', + onError: vi.fn() + } + + const executorWithPlugin = RuntimeExecutor.create('openai', mockProviderConfigs.openai, [errorPlugin]) + + await expect( + executorWithPlugin.streamText({ + model: 'gpt-4', + messages: testMessages.simple + }) + ).rejects.toThrow('Stream error') + + expect(errorPlugin.onError).toHaveBeenCalledWith( + error, + expect.objectContaining({ + providerId: 'openai', + modelId: 'gpt-4' + }) + ) + }) + }) +}) diff --git a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts index bc416161c..ee878f586 100644 --- a/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts +++ b/src/renderer/src/aiCore/legacy/clients/ApiClientFactory.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' -import { isNewApiProvider } from '@renderer/config/providers' import type { Provider } from '@renderer/types' +import { isNewApiProvider } from '@renderer/utils/provider' import { AihubmixAPIClient } from './aihubmix/AihubmixAPIClient' import { AnthropicAPIClient } from './anthropic/AnthropicAPIClient' diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index 1caf48320..c1c06b359 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -7,7 +7,6 @@ import { isSupportFlexServiceTierModel } from '@renderer/config/models' import { REFERENCE_PROMPT } from '@renderer/config/prompts' -import { isSupportServiceTierProvider } from '@renderer/config/providers' import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio' import { getAssistantSettings } from '@renderer/services/AssistantService' import type { @@ -48,6 +47,7 @@ import type { import { isJSON, parseJSON } from '@renderer/utils' import { addAbortController, removeAbortController } from '@renderer/utils/abortController' import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { isSupportServiceTierProvider } from '@renderer/utils/provider' import { defaultTimeout } from '@shared/config/constant' import { defaultAppHeaders } from '@shared/utils' import { isEmpty } from 'lodash' diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts index 03ec1e1ea..991c436ca 100644 --- a/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/ApiClientFactory.test.ts @@ -58,10 +58,27 @@ vi.mock('../aws/AwsBedrockAPIClient', () => ({ AwsBedrockAPIClient: vi.fn().mockImplementation(() => ({})) })) +vi.mock('@renderer/services/AssistantService.ts', () => ({ + getDefaultAssistant: () => { + return { + id: 'default', + name: 'default', + emoji: '😀', + prompt: '', + topics: [], + messages: [], + type: 'assistant', + regularPhrases: [], + settings: {} + } + } +})) + // Mock the models config to prevent circular dependency issues vi.mock('@renderer/config/models', () => ({ findTokenLimit: vi.fn(), isReasoningModel: vi.fn(), + isOpenAILLMModel: vi.fn(), SYSTEM_MODELS: { silicon: [], defaultModel: [] diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts index 49a96a8f1..fb371d9ae 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/VertexAPIClient.ts @@ -1,7 +1,8 @@ import { GoogleGenAI } from '@google/genai' import { loggerService } from '@logger' -import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI' +import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI' import type { Model, Provider, VertexProvider } from '@renderer/types' +import { isVertexProvider } from '@renderer/utils/provider' import { isEmpty } from 'lodash' import { AnthropicVertexClient } from '../anthropic/AnthropicVertexClient' diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index ad8733185..55299c18a 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -10,7 +10,6 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - getOpenAIWebSearchParams, getThinkModelType, isClaudeReasoningModel, isDeepSeekHybridInferenceModel, @@ -40,12 +39,6 @@ import { MODEL_SUPPORTED_REASONING_EFFORT, ZHIPU_RESULT_TOKENS } from '@renderer/config/models' -import { - isSupportArrayContentProvider, - isSupportDeveloperRoleProvider, - isSupportEnableThinkingProvider, - isSupportStreamOptionsProvider -} from '@renderer/config/providers' import { mapLanguageToQwenMTModel } from '@renderer/config/translate' import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' import { estimateTextTokens } from '@renderer/services/TokenService' @@ -89,6 +82,12 @@ import { openAIToolsToMcpTool } from '@renderer/utils/mcp-tools' import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' +import { + isSupportArrayContentProvider, + isSupportDeveloperRoleProvider, + isSupportEnableThinkingProvider, + isSupportStreamOptionsProvider +} from '@renderer/utils/provider' import { t } from 'i18next' import type { GenericChunk } from '../../middleware/schemas' @@ -743,7 +742,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< : {}), ...this.getProviderSpecificParameters(assistant, model), ...reasoningEffort, - ...getOpenAIWebSearchParams(model, enableWebSearch), + // ...getOpenAIWebSearchParams(model, enableWebSearch), // OpenRouter usage tracking ...(this.provider.id === 'openrouter' ? { usage: { include: true } } : {}), ...extra_body, diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts index cfbfdfd9d..8356826e2 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts @@ -12,7 +12,6 @@ import { isSupportVerbosityModel, isVisionModel } from '@renderer/config/models' -import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { estimateTextTokens } from '@renderer/services/TokenService' import type { FileMetadata, @@ -43,6 +42,7 @@ import { openAIToolsToMcpTool } from '@renderer/utils/mcp-tools' import { findFileBlocks, findImageBlocks } from '@renderer/utils/messageUtils/find' +import { isSupportDeveloperRoleProvider } from '@renderer/utils/provider' import { MB } from '@shared/config/constant' import { t } from 'i18next' import { isEmpty } from 'lodash' diff --git a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts index 7d6a7f631..c93e42fbb 100644 --- a/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts +++ b/src/renderer/src/aiCore/legacy/middleware/common/ErrorHandlerMiddleware.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import { isZhipuModel } from '@renderer/config/models' import { getStoreProviders } from '@renderer/hooks/useStore' +import { getDefaultModel } from '@renderer/services/AssistantService' import type { Chunk } from '@renderer/types/chunk' import type { CompletionsParams, CompletionsResult } from '../schemas' @@ -66,7 +67,7 @@ export const ErrorHandlerMiddleware = } function handleError(error: any, params: CompletionsParams): any { - if (isZhipuModel(params.assistant.model) && error.status && !params.enableGenerateImage) { + if (isZhipuModel(params.assistant.model || getDefaultModel()) && error.status && !params.enableGenerateImage) { return handleZhipuError(error) } diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 3f14917cd..ef112c0b4 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,10 +1,10 @@ import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import { isSupportedThinkingTokenQwenModel } from '@renderer/config/models' -import { isSupportEnableThinkingProvider } from '@renderer/config/providers' import type { MCPTool } from '@renderer/types' import { type Assistant, type Message, type Model, type Provider } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' +import { isSupportEnableThinkingProvider } from '@renderer/utils/provider' import type { LanguageModelMiddleware } from 'ai' import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' import { isEmpty } from 'lodash' diff --git a/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts b/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts new file mode 100644 index 000000000..2e7ae522c --- /dev/null +++ b/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts @@ -0,0 +1,234 @@ +import type { Message, Model } from '@renderer/types' +import type { FileMetadata } from '@renderer/types/file' +import { FileTypes } from '@renderer/types/file' +import { + AssistantMessageStatus, + type FileMessageBlock, + type ImageMessageBlock, + MessageBlockStatus, + MessageBlockType, + type ThinkingMessageBlock, + UserMessageStatus +} from '@renderer/types/newMessage' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { convertFileBlockToFilePartMock, convertFileBlockToTextPartMock } = vi.hoisted(() => ({ + convertFileBlockToFilePartMock: vi.fn(), + convertFileBlockToTextPartMock: vi.fn() +})) + +vi.mock('../fileProcessor', () => ({ + convertFileBlockToFilePart: convertFileBlockToFilePartMock, + convertFileBlockToTextPart: convertFileBlockToTextPartMock +})) + +const visionModelIds = new Set(['gpt-4o-mini', 'qwen-image-edit']) +const imageEnhancementModelIds = new Set(['qwen-image-edit']) + +vi.mock('@renderer/config/models', () => ({ + isVisionModel: (model: Model) => visionModelIds.has(model.id), + isImageEnhancementModel: (model: Model) => imageEnhancementModelIds.has(model.id) +})) + +type MockableMessage = Message & { + __mockContent?: string + __mockFileBlocks?: FileMessageBlock[] + __mockImageBlocks?: ImageMessageBlock[] + __mockThinkingBlocks?: ThinkingMessageBlock[] +} + +vi.mock('@renderer/utils/messageUtils/find', () => ({ + getMainTextContent: (message: Message) => (message as MockableMessage).__mockContent ?? '', + findFileBlocks: (message: Message) => (message as MockableMessage).__mockFileBlocks ?? [], + findImageBlocks: (message: Message) => (message as MockableMessage).__mockImageBlocks ?? [], + findThinkingBlocks: (message: Message) => (message as MockableMessage).__mockThinkingBlocks ?? [] +})) + +import { convertMessagesToSdkMessages, convertMessageToSdkParam } from '../messageConverter' + +let messageCounter = 0 +let blockCounter = 0 + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o-mini', + name: 'GPT-4o mini', + provider: 'openai', + group: 'openai', + ...overrides +}) + +const createMessage = (role: Message['role']): MockableMessage => + ({ + id: `message-${++messageCounter}`, + role, + assistantId: 'assistant-1', + topicId: 'topic-1', + createdAt: new Date(2024, 0, 1, 0, 0, messageCounter).toISOString(), + status: role === 'assistant' ? AssistantMessageStatus.SUCCESS : UserMessageStatus.SUCCESS, + blocks: [] + }) as MockableMessage + +const createFileBlock = ( + messageId: string, + overrides: Partial> & { file?: Partial } = {} +): FileMessageBlock => { + const { file, ...blockOverrides } = overrides + const timestamp = new Date(2024, 0, 1, 0, 0, ++blockCounter).toISOString() + return { + id: blockOverrides.id ?? `file-block-${blockCounter}`, + messageId, + type: MessageBlockType.FILE, + createdAt: blockOverrides.createdAt ?? timestamp, + status: blockOverrides.status ?? MessageBlockStatus.SUCCESS, + file: { + id: file?.id ?? `file-${blockCounter}`, + name: file?.name ?? 'document.txt', + origin_name: file?.origin_name ?? 'document.txt', + path: file?.path ?? '/tmp/document.txt', + size: file?.size ?? 1024, + ext: file?.ext ?? '.txt', + type: file?.type ?? FileTypes.TEXT, + created_at: file?.created_at ?? timestamp, + count: file?.count ?? 1, + ...file + }, + ...blockOverrides + } +} + +const createImageBlock = ( + messageId: string, + overrides: Partial> = {} +): ImageMessageBlock => ({ + id: overrides.id ?? `image-block-${++blockCounter}`, + messageId, + type: MessageBlockType.IMAGE, + createdAt: overrides.createdAt ?? new Date(2024, 0, 1, 0, 0, blockCounter).toISOString(), + status: overrides.status ?? MessageBlockStatus.SUCCESS, + url: overrides.url ?? 'https://example.com/image.png', + ...overrides +}) + +describe('messageConverter', () => { + beforeEach(() => { + convertFileBlockToFilePartMock.mockReset() + convertFileBlockToTextPartMock.mockReset() + convertFileBlockToFilePartMock.mockResolvedValue(null) + convertFileBlockToTextPartMock.mockResolvedValue(null) + messageCounter = 0 + blockCounter = 0 + }) + + describe('convertMessageToSdkParam', () => { + it('includes text and image parts for user messages on vision models', async () => { + const model = createModel() + const message = createMessage('user') + message.__mockContent = 'Describe this picture' + message.__mockImageBlocks = [createImageBlock(message.id, { url: 'https://example.com/cat.png' })] + + const result = await convertMessageToSdkParam(message, true, model) + + expect(result).toEqual({ + role: 'user', + content: [ + { type: 'text', text: 'Describe this picture' }, + { type: 'image', image: 'https://example.com/cat.png' } + ] + }) + }) + + it('returns file instructions as a system message when native uploads succeed', async () => { + const model = createModel() + const message = createMessage('user') + message.__mockContent = 'Summarize the PDF' + message.__mockFileBlocks = [createFileBlock(message.id)] + convertFileBlockToFilePartMock.mockResolvedValueOnce({ + type: 'file', + filename: 'document.pdf', + mediaType: 'application/pdf', + data: 'fileid://remote-file' + }) + + const result = await convertMessageToSdkParam(message, false, model) + + expect(result).toEqual([ + { + role: 'system', + content: 'fileid://remote-file' + }, + { + role: 'user', + content: [{ type: 'text', text: 'Summarize the PDF' }] + } + ]) + }) + }) + + describe('convertMessagesToSdkMessages', () => { + it('appends assistant images to the final user message for image enhancement models', async () => { + const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) + const initialUser = createMessage('user') + initialUser.__mockContent = 'Start editing' + + const assistant = createMessage('assistant') + assistant.__mockContent = 'Here is the current preview' + assistant.__mockImageBlocks = [createImageBlock(assistant.id, { url: 'https://example.com/preview.png' })] + + const finalUser = createMessage('user') + finalUser.__mockContent = 'Increase the brightness' + + const result = await convertMessagesToSdkMessages([initialUser, assistant, finalUser], model) + + expect(result).toEqual([ + { + role: 'assistant', + content: [{ type: 'text', text: 'Here is the current preview' }] + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Increase the brightness' }, + { type: 'image', image: 'https://example.com/preview.png' } + ] + } + ]) + }) + + it('preserves preceding system instructions when building enhancement payloads', async () => { + const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' }) + const fileUser = createMessage('user') + fileUser.__mockContent = 'Use this document as inspiration' + fileUser.__mockFileBlocks = [createFileBlock(fileUser.id, { file: { ext: '.pdf', type: FileTypes.DOCUMENT } })] + convertFileBlockToFilePartMock.mockResolvedValueOnce({ + type: 'file', + filename: 'reference.pdf', + mediaType: 'application/pdf', + data: 'fileid://reference' + }) + + const assistant = createMessage('assistant') + assistant.__mockContent = 'Generated previews ready' + assistant.__mockImageBlocks = [createImageBlock(assistant.id, { url: 'https://example.com/reference.png' })] + + const finalUser = createMessage('user') + finalUser.__mockContent = 'Apply the edits' + + const result = await convertMessagesToSdkMessages([fileUser, assistant, finalUser], model) + + expect(result).toEqual([ + { role: 'system', content: 'fileid://reference' }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Generated previews ready' }] + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Apply the edits' }, + { type: 'image', image: 'https://example.com/reference.png' } + ] + } + ]) + }) + }) +}) diff --git a/src/renderer/src/aiCore/prepareParams/__tests__/model-parameters.test.ts b/src/renderer/src/aiCore/prepareParams/__tests__/model-parameters.test.ts new file mode 100644 index 000000000..70b4ac84b --- /dev/null +++ b/src/renderer/src/aiCore/prepareParams/__tests__/model-parameters.test.ts @@ -0,0 +1,218 @@ +import type { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' +import { TopicType } from '@renderer/types' +import { defaultTimeout } from '@shared/config/constant' +import { describe, expect, it, vi } from 'vitest' + +import { getTemperature, getTimeout, getTopP } from '../modelParameters' + +vi.mock('@renderer/services/AssistantService', () => ({ + getAssistantSettings: (assistant: Assistant): AssistantSettings => ({ + contextCount: assistant.settings?.contextCount ?? 4096, + temperature: assistant.settings?.temperature ?? 0.7, + enableTemperature: assistant.settings?.enableTemperature ?? true, + topP: assistant.settings?.topP ?? 1, + enableTopP: assistant.settings?.enableTopP ?? false, + enableMaxTokens: assistant.settings?.enableMaxTokens ?? false, + maxTokens: assistant.settings?.maxTokens, + streamOutput: assistant.settings?.streamOutput ?? true, + toolUseMode: assistant.settings?.toolUseMode ?? 'prompt', + defaultModel: assistant.defaultModel, + customParameters: assistant.settings?.customParameters ?? [], + reasoning_effort: assistant.settings?.reasoning_effort, + reasoning_effort_cache: assistant.settings?.reasoning_effort_cache, + qwenThinkMode: assistant.settings?.qwenThinkMode + }) +})) + +vi.mock('@renderer/hooks/useSettings', () => ({ + getStoreSetting: vi.fn(), + useSettings: vi.fn(() => ({})), + useNavbarPosition: vi.fn(() => ({ navbarPosition: 'left', isLeftNavbar: true, isTopNavbar: false })) +})) + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +vi.mock('@renderer/store/settings', () => ({ + default: (state = { settings: {} }) => state +})) + +vi.mock('@renderer/store/assistants', () => ({ + default: (state = { assistants: [] }) => state +})) + +const createTopic = (assistantId: string): Topic => ({ + id: `topic-${assistantId}`, + assistantId, + name: 'topic', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + type: TopicType.Chat +}) + +const createAssistant = (settings: Assistant['settings'] = {}): Assistant => { + const assistantId = 'assistant-1' + return { + id: assistantId, + name: 'Test Assistant', + prompt: 'prompt', + topics: [createTopic(assistantId)], + type: 'assistant', + settings + } +} + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o', + provider: 'openai', + name: 'GPT-4o', + group: 'openai', + ...overrides +}) + +describe('modelParameters', () => { + describe('getTemperature', () => { + it('returns undefined when reasoning effort is enabled for Claude models', () => { + const assistant = createAssistant({ reasoning_effort: 'medium' }) + const model = createModel({ id: 'claude-opus-4', name: 'Claude Opus 4', provider: 'anthropic', group: 'claude' }) + + expect(getTemperature(assistant, model)).toBeUndefined() + }) + + it('returns undefined for models without temperature/topP support', () => { + const assistant = createAssistant({ enableTemperature: true }) + const model = createModel({ id: 'qwen-mt-large', name: 'Qwen MT', provider: 'qwen', group: 'qwen' }) + + expect(getTemperature(assistant, model)).toBeUndefined() + }) + + it('returns undefined for Claude 4.5 reasoning models when only TopP is enabled', () => { + const assistant = createAssistant({ enableTopP: true, enableTemperature: false }) + const model = createModel({ + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + group: 'claude' + }) + + expect(getTemperature(assistant, model)).toBeUndefined() + }) + + it('returns configured temperature when enabled', () => { + const assistant = createAssistant({ enableTemperature: true, temperature: 0.42 }) + const model = createModel({ id: 'gpt-4o', provider: 'openai', group: 'openai' }) + + expect(getTemperature(assistant, model)).toBe(0.42) + }) + + it('returns undefined when temperature is disabled', () => { + const assistant = createAssistant({ enableTemperature: false, temperature: 0.9 }) + const model = createModel({ id: 'gpt-4o', provider: 'openai', group: 'openai' }) + + expect(getTemperature(assistant, model)).toBeUndefined() + }) + + it('clamps temperature to max 1.0 for Zhipu models', () => { + const assistant = createAssistant({ enableTemperature: true, temperature: 2.0 }) + const model = createModel({ id: 'glm-4-plus', name: 'GLM-4 Plus', provider: 'zhipu', group: 'zhipu' }) + + expect(getTemperature(assistant, model)).toBe(1.0) + }) + + it('clamps temperature to max 1.0 for Anthropic models', () => { + const assistant = createAssistant({ enableTemperature: true, temperature: 1.5 }) + const model = createModel({ + id: 'claude-sonnet-3.5', + name: 'Claude 3.5 Sonnet', + provider: 'anthropic', + group: 'claude' + }) + + expect(getTemperature(assistant, model)).toBe(1.0) + }) + + it('clamps temperature to max 1.0 for Moonshot models', () => { + const assistant = createAssistant({ enableTemperature: true, temperature: 2.0 }) + const model = createModel({ + id: 'moonshot-v1-8k', + name: 'Moonshot v1 8k', + provider: 'moonshot', + group: 'moonshot' + }) + + expect(getTemperature(assistant, model)).toBe(1.0) + }) + + it('does not clamp temperature for OpenAI models', () => { + const assistant = createAssistant({ enableTemperature: true, temperature: 2.0 }) + const model = createModel({ id: 'gpt-4o', provider: 'openai', group: 'openai' }) + + expect(getTemperature(assistant, model)).toBe(2.0) + }) + + it('does not clamp temperature when it is already within limits', () => { + const assistant = createAssistant({ enableTemperature: true, temperature: 0.8 }) + const model = createModel({ id: 'glm-4-plus', name: 'GLM-4 Plus', provider: 'zhipu', group: 'zhipu' }) + + expect(getTemperature(assistant, model)).toBe(0.8) + }) + }) + + describe('getTopP', () => { + it('returns undefined when reasoning effort is enabled for Claude models', () => { + const assistant = createAssistant({ reasoning_effort: 'high' }) + const model = createModel({ id: 'claude-opus-4', provider: 'anthropic', group: 'claude' }) + + expect(getTopP(assistant, model)).toBeUndefined() + }) + + it('returns undefined for models without TopP support', () => { + const assistant = createAssistant({ enableTopP: true }) + const model = createModel({ id: 'qwen-mt-small', name: 'Qwen MT', provider: 'qwen', group: 'qwen' }) + + expect(getTopP(assistant, model)).toBeUndefined() + }) + + it('returns undefined for Claude 4.5 reasoning models when temperature is enabled', () => { + const assistant = createAssistant({ enableTemperature: true }) + const model = createModel({ + id: 'claude-opus-4.5', + name: 'Claude Opus 4.5', + provider: 'anthropic', + group: 'claude' + }) + + expect(getTopP(assistant, model)).toBeUndefined() + }) + + it('returns configured TopP when enabled', () => { + const assistant = createAssistant({ enableTopP: true, topP: 0.73 }) + const model = createModel({ id: 'gpt-4o', provider: 'openai', group: 'openai' }) + + expect(getTopP(assistant, model)).toBe(0.73) + }) + + it('returns undefined when TopP is disabled', () => { + const assistant = createAssistant({ enableTopP: false, topP: 0.5 }) + const model = createModel({ id: 'gpt-4o', provider: 'openai', group: 'openai' }) + + expect(getTopP(assistant, model)).toBeUndefined() + }) + }) + + describe('getTimeout', () => { + it('uses an extended timeout for flex service tier models', () => { + const model = createModel({ id: 'o3-pro', provider: 'openai', group: 'openai' }) + + expect(getTimeout(model)).toBe(15 * 1000 * 60) + }) + + it('falls back to the default timeout otherwise', () => { + const model = createModel({ id: 'gpt-4o', provider: 'openai', group: 'openai' }) + + expect(getTimeout(model)).toBe(defaultTimeout) + }) + }) +}) diff --git a/src/renderer/src/aiCore/prepareParams/header.ts b/src/renderer/src/aiCore/prepareParams/header.ts index d818c4794..19d461137 100644 --- a/src/renderer/src/aiCore/prepareParams/header.ts +++ b/src/renderer/src/aiCore/prepareParams/header.ts @@ -1,9 +1,8 @@ import { isClaude4SeriesModel, isClaude45ReasoningModel } from '@renderer/config/models' -import { isAwsBedrockProvider } from '@renderer/config/providers' -import { isVertexProvider } from '@renderer/hooks/useVertexAI' import { getProviderByModel } from '@renderer/services/AssistantService' import type { Assistant, Model } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' +import { isAwsBedrockProvider, isVertexProvider } from '@renderer/utils/provider' // https://docs.claude.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking const INTERLEAVED_THINKING_HEADER = 'interleaved-thinking-2025-05-14' diff --git a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts index b6e4b2584..4a3c3f4bb 100644 --- a/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts +++ b/src/renderer/src/aiCore/prepareParams/modelCapabilities.ts @@ -85,19 +85,6 @@ export function supportsLargeFileUpload(model: Model): boolean { }) } -/** - * 检查模型是否支持TopP - */ -export function supportsTopP(model: Model): boolean { - const provider = getProviderByModel(model) - - if (provider?.type === 'anthropic' || model?.endpoint_type === 'anthropic') { - return false - } - - return true -} - /** * 获取提供商特定的文件大小限制 */ diff --git a/src/renderer/src/aiCore/prepareParams/modelParameters.ts b/src/renderer/src/aiCore/prepareParams/modelParameters.ts index ed3f4fa21..645697bea 100644 --- a/src/renderer/src/aiCore/prepareParams/modelParameters.ts +++ b/src/renderer/src/aiCore/prepareParams/modelParameters.ts @@ -3,17 +3,27 @@ * 处理温度、TopP、超时等基础参数的获取逻辑 */ +import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { isClaude45ReasoningModel, isClaudeReasoningModel, + isMaxTemperatureOneModel, isNotSupportTemperatureAndTopP, - isSupportedFlexServiceTier + isSupportedFlexServiceTier, + isSupportedThinkingTokenClaudeModel } from '@renderer/config/models' -import { getAssistantSettings } from '@renderer/services/AssistantService' +import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' import type { Assistant, Model } from '@renderer/types' import { defaultTimeout } from '@shared/config/constant' +import { getAnthropicThinkingBudget } from '../utils/reasoning' + /** + * Claude 4.5 推理模型: + * - 只启用 temperature → 使用 temperature + * - 只启用 top_p → 使用 top_p + * - 同时启用 → temperature 生效,top_p 被忽略 + * - 都不启用 → 都不使用 * 获取温度参数 */ export function getTemperature(assistant: Assistant, model: Model): number | undefined { @@ -27,7 +37,11 @@ export function getTemperature(assistant: Assistant, model: Model): number | und return undefined } const assistantSettings = getAssistantSettings(assistant) - return assistantSettings?.enableTemperature ? assistantSettings?.temperature : undefined + let temperature = assistantSettings?.temperature + if (temperature && isMaxTemperatureOneModel(model)) { + temperature = Math.min(1, temperature) + } + return assistantSettings?.enableTemperature ? temperature : undefined } /** @@ -56,3 +70,18 @@ export function getTimeout(model: Model): number { } return defaultTimeout } + +export function getMaxTokens(assistant: Assistant, model: Model): number | undefined { + // NOTE: ai-sdk会把maxToken和budgetToken加起来 + let { maxTokens = DEFAULT_MAX_TOKENS } = getAssistantSettings(assistant) + + const provider = getProviderByModel(model) + if (isSupportedThinkingTokenClaudeModel(model) && ['anthropic', 'aws-bedrock'].includes(provider.type)) { + const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant) + const budget = getAnthropicThinkingBudget(maxTokens, reasoningEffort, model.id) + if (budget) { + maxTokens -= budget + } + } + return maxTokens +} diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 420890723..785d88c8a 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -17,11 +17,10 @@ import { isOpenRouterBuiltInWebSearchModel, isReasoningModel, isSupportedReasoningEffortModel, - isSupportedThinkingTokenClaudeModel, isSupportedThinkingTokenModel, isWebSearchModel } from '@renderer/config/models' -import { getAssistantSettings, getDefaultModel } from '@renderer/services/AssistantService' +import { getDefaultModel } from '@renderer/services/AssistantService' import store from '@renderer/store' import type { CherryWebSearchConfig } from '@renderer/store/websearch' import { type Assistant, type MCPTool, type Provider } from '@renderer/types' @@ -34,11 +33,9 @@ import { stepCountIs } from 'ai' import { getAiSdkProviderId } from '../provider/factory' import { setupToolsConfig } from '../utils/mcp' import { buildProviderOptions } from '../utils/options' -import { getAnthropicThinkingBudget } from '../utils/reasoning' import { buildProviderBuiltinWebSearchConfig } from '../utils/websearch' import { addAnthropicHeaders } from './header' -import { supportsTopP } from './modelCapabilities' -import { getTemperature, getTopP } from './modelParameters' +import { getMaxTokens, getTemperature, getTopP } from './modelParameters' const logger = loggerService.withContext('parameterBuilder') @@ -78,8 +75,6 @@ export async function buildStreamTextParams( const model = assistant.model || getDefaultModel() const aiSdkProviderId = getAiSdkProviderId(provider) - let { maxTokens } = getAssistantSettings(assistant) - // 这三个变量透传出来,交给下面启用插件/中间件 // 也可以在外部构建好再传入buildStreamTextParams // FIXME: qwen3即使关闭思考仍然会导致enableReasoning的结果为true @@ -116,20 +111,6 @@ export async function buildStreamTextParams( enableGenerateImage }) - // NOTE: ai-sdk会把maxToken和budgetToken加起来 - if ( - enableReasoning && - maxTokens !== undefined && - isSupportedThinkingTokenClaudeModel(model) && - (provider.type === 'anthropic' || provider.type === 'aws-bedrock') - ) { - const { reasoning_effort: reasoningEffort } = getAssistantSettings(assistant) - const budget = getAnthropicThinkingBudget(maxTokens, reasoningEffort, model.id) - if (budget) { - maxTokens -= budget - } - } - let webSearchPluginConfig: WebSearchPluginConfig | undefined = undefined if (enableWebSearch) { if (isBaseProvider(aiSdkProviderId)) { @@ -189,8 +170,9 @@ export async function buildStreamTextParams( // 构建基础参数 const params: StreamTextParams = { messages: sdkMessages, - maxOutputTokens: maxTokens, + maxOutputTokens: getMaxTokens(assistant, model), temperature: getTemperature(assistant, model), + topP: getTopP(assistant, model), abortSignal: options.requestOptions?.signal, headers, providerOptions, @@ -198,10 +180,6 @@ export async function buildStreamTextParams( maxRetries: 0 } - if (supportsTopP(model)) { - params.topP = getTopP(assistant, model) - } - if (tools) { params.tools = tools } diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index 39786231e..698e2f166 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -34,7 +34,7 @@ vi.mock('@renderer/utils/api', () => ({ })) })) -vi.mock('@renderer/config/providers', async (importOriginal) => { +vi.mock('@renderer/utils/provider', async (importOriginal) => { const actual = (await importOriginal()) as any return { ...actual, @@ -53,10 +53,21 @@ vi.mock('@renderer/hooks/useVertexAI', () => ({ createVertexProvider: vi.fn() })) -import { isCherryAIProvider, isPerplexityProvider } from '@renderer/config/providers' +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: vi.fn(), + getAssistantSettings: vi.fn(), + getDefaultAssistant: vi.fn().mockReturnValue({ + id: 'default', + name: 'Default Assistant', + prompt: '', + settings: {} + }) +})) + import { getProviderByModel } from '@renderer/services/AssistantService' import type { Model, Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' +import { isCherryAIProvider, isPerplexityProvider } from '@renderer/utils/provider' import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' import { getActualProvider, providerToAiSdkConfig } from '../providerConfig' diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 39138f364..00aaa6e61 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -6,14 +6,6 @@ import { type ProviderSettingsMap } from '@cherrystudio/ai-core/provider' import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' -import { - isAnthropicProvider, - isAzureOpenAIProvider, - isCherryAIProvider, - isGeminiProvider, - isNewApiProvider, - isPerplexityProvider -} from '@renderer/config/providers' import { getAwsBedrockAccessKeyId, getAwsBedrockApiKey, @@ -21,11 +13,20 @@ import { getAwsBedrockRegion, getAwsBedrockSecretAccessKey } from '@renderer/hooks/useAwsBedrock' -import { createVertexProvider, isVertexAIConfigured, isVertexProvider } from '@renderer/hooks/useVertexAI' +import { createVertexProvider, isVertexAIConfigured } from '@renderer/hooks/useVertexAI' import { getProviderByModel } from '@renderer/services/AssistantService' import store from '@renderer/store' import { isSystemProvider, type Model, type Provider, SystemProviderIds } from '@renderer/types' import { formatApiHost, formatAzureOpenAIApiHost, formatVertexApiHost, routeToEndpoint } from '@renderer/utils/api' +import { + isAnthropicProvider, + isAzureOpenAIProvider, + isCherryAIProvider, + isGeminiProvider, + isNewApiProvider, + isPerplexityProvider, + isVertexProvider +} from '@renderer/utils/provider' import { cloneDeep } from 'lodash' import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config' diff --git a/src/renderer/src/aiCore/utils/__tests__/image.test.ts b/src/renderer/src/aiCore/utils/__tests__/image.test.ts new file mode 100644 index 000000000..1c5381a5e --- /dev/null +++ b/src/renderer/src/aiCore/utils/__tests__/image.test.ts @@ -0,0 +1,121 @@ +/** + * image.ts Unit Tests + * Tests for Gemini image generation utilities + */ + +import type { Model, Provider } from '@renderer/types' +import { SystemProviderIds } from '@renderer/types' +import { describe, expect, it } from 'vitest' + +import { buildGeminiGenerateImageParams, isOpenRouterGeminiGenerateImageModel } from '../image' + +describe('image utils', () => { + describe('buildGeminiGenerateImageParams', () => { + it('should return correct response modalities', () => { + const result = buildGeminiGenerateImageParams() + + expect(result).toEqual({ + responseModalities: ['TEXT', 'IMAGE'] + }) + }) + + it('should return an object with responseModalities property', () => { + const result = buildGeminiGenerateImageParams() + + expect(result).toHaveProperty('responseModalities') + expect(Array.isArray(result.responseModalities)).toBe(true) + expect(result.responseModalities).toHaveLength(2) + }) + }) + + describe('isOpenRouterGeminiGenerateImageModel', () => { + const mockOpenRouterProvider: Provider = { + id: SystemProviderIds.openrouter, + name: 'OpenRouter', + apiKey: 'test-key', + apiHost: 'https://openrouter.ai/api/v1', + isSystem: true + } as Provider + + const mockOtherProvider: Provider = { + id: SystemProviderIds.openai, + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com/v1', + isSystem: true + } as Provider + + it('should return true for OpenRouter Gemini 2.5 Flash Image model', () => { + const model: Model = { + id: 'google/gemini-2.5-flash-image-preview', + name: 'Gemini 2.5 Flash Image', + provider: SystemProviderIds.openrouter + } as Model + + const result = isOpenRouterGeminiGenerateImageModel(model, mockOpenRouterProvider) + expect(result).toBe(true) + }) + + it('should return false for non-Gemini model on OpenRouter', () => { + const model: Model = { + id: 'openai/gpt-4', + name: 'GPT-4', + provider: SystemProviderIds.openrouter + } as Model + + const result = isOpenRouterGeminiGenerateImageModel(model, mockOpenRouterProvider) + expect(result).toBe(false) + }) + + it('should return false for Gemini model on non-OpenRouter provider', () => { + const model: Model = { + id: 'gemini-2.5-flash-image-preview', + name: 'Gemini 2.5 Flash Image', + provider: SystemProviderIds.gemini + } as Model + + const result = isOpenRouterGeminiGenerateImageModel(model, mockOtherProvider) + expect(result).toBe(false) + }) + + it('should return false for Gemini model without image suffix', () => { + const model: Model = { + id: 'google/gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + provider: SystemProviderIds.openrouter + } as Model + + const result = isOpenRouterGeminiGenerateImageModel(model, mockOpenRouterProvider) + expect(result).toBe(false) + }) + + it('should handle model ID with partial match', () => { + const model: Model = { + id: 'google/gemini-2.5-flash-image-generation', + name: 'Gemini Image Gen', + provider: SystemProviderIds.openrouter + } as Model + + const result = isOpenRouterGeminiGenerateImageModel(model, mockOpenRouterProvider) + expect(result).toBe(true) + }) + + it('should return false for custom provider', () => { + const customProvider: Provider = { + id: 'custom-provider-123', + name: 'Custom Provider', + apiKey: 'test-key', + apiHost: 'https://custom.com' + } as Provider + + const model: Model = { + id: 'gemini-2.5-flash-image-preview', + name: 'Gemini 2.5 Flash Image', + provider: 'custom-provider-123' + } as Model + + const result = isOpenRouterGeminiGenerateImageModel(model, customProvider) + expect(result).toBe(false) + }) + }) +}) diff --git a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts new file mode 100644 index 000000000..a832e9f63 --- /dev/null +++ b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts @@ -0,0 +1,435 @@ +/** + * mcp.ts Unit Tests + * Tests for MCP tools configuration and conversion utilities + */ + +import type { MCPTool } from '@renderer/types' +import type { Tool } from 'ai' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { convertMcpToolsToAiSdkTools, setupToolsConfig } from '../mcp' + +// Mock dependencies +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn() + }) + } +})) + +vi.mock('@renderer/utils/mcp-tools', () => ({ + getMcpServerByTool: vi.fn(() => ({ id: 'test-server', autoApprove: false })), + isToolAutoApproved: vi.fn(() => false), + callMCPTool: vi.fn(async () => ({ + content: [{ type: 'text', text: 'Tool executed successfully' }], + isError: false + })) +})) + +vi.mock('@renderer/utils/userConfirmation', () => ({ + requestToolConfirmation: vi.fn(async () => true) +})) + +describe('mcp utils', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('setupToolsConfig', () => { + it('should return undefined when no MCP tools provided', () => { + const result = setupToolsConfig() + expect(result).toBeUndefined() + }) + + it('should return undefined when empty MCP tools array provided', () => { + const result = setupToolsConfig([]) + expect(result).toBeUndefined() + }) + + it('should convert MCP tools to AI SDK tools format', () => { + const mcpTools: MCPTool[] = [ + { + id: 'test-tool-1', + serverId: 'test-server', + serverName: 'test-server', + name: 'test-tool', + description: 'A test tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' } + } + } + } + ] + + const result = setupToolsConfig(mcpTools) + + expect(result).not.toBeUndefined() + expect(Object.keys(result!)).toEqual(['test-tool']) + expect(result!['test-tool']).toHaveProperty('description') + expect(result!['test-tool']).toHaveProperty('inputSchema') + expect(result!['test-tool']).toHaveProperty('execute') + }) + + it('should handle multiple MCP tools', () => { + const mcpTools: MCPTool[] = [ + { + id: 'tool1-id', + serverId: 'server1', + serverName: 'server1', + name: 'tool1', + description: 'First tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + }, + { + id: 'tool2-id', + serverId: 'server2', + serverName: 'server2', + name: 'tool2', + description: 'Second tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const result = setupToolsConfig(mcpTools) + + expect(result).not.toBeUndefined() + expect(Object.keys(result!)).toHaveLength(2) + expect(Object.keys(result!)).toEqual(['tool1', 'tool2']) + }) + }) + + describe('convertMcpToolsToAiSdkTools', () => { + it('should convert single MCP tool to AI SDK tool', () => { + const mcpTools: MCPTool[] = [ + { + id: 'get-weather-id', + serverId: 'weather-server', + serverName: 'weather-server', + name: 'get-weather', + description: 'Get weather information', + type: 'mcp', + inputSchema: { + type: 'object', + properties: { + location: { type: 'string' } + }, + required: ['location'] + } + } + ] + + const result = convertMcpToolsToAiSdkTools(mcpTools) + + expect(Object.keys(result)).toEqual(['get-weather']) + + const tool = result['get-weather'] as Tool + expect(tool.description).toBe('Get weather information') + expect(tool.inputSchema).toBeDefined() + expect(typeof tool.execute).toBe('function') + }) + + it('should handle tool without description', () => { + const mcpTools: MCPTool[] = [ + { + id: 'no-desc-tool-id', + serverId: 'test-server', + serverName: 'test-server', + name: 'no-desc-tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const result = convertMcpToolsToAiSdkTools(mcpTools) + + expect(Object.keys(result)).toEqual(['no-desc-tool']) + const tool = result['no-desc-tool'] as Tool + expect(tool.description).toBe('Tool from test-server') + }) + + it('should convert empty tools array', () => { + const result = convertMcpToolsToAiSdkTools([]) + expect(result).toEqual({}) + }) + + it('should handle complex input schemas', () => { + const mcpTools: MCPTool[] = [ + { + id: 'complex-tool-id', + serverId: 'server', + serverName: 'server', + name: 'complex-tool', + description: 'Tool with complex schema', + type: 'mcp', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + tags: { + type: 'array', + items: { type: 'string' } + }, + metadata: { + type: 'object', + properties: { + key: { type: 'string' } + } + } + }, + required: ['name'] + } + } + ] + + const result = convertMcpToolsToAiSdkTools(mcpTools) + + expect(Object.keys(result)).toEqual(['complex-tool']) + const tool = result['complex-tool'] as Tool + expect(tool.inputSchema).toBeDefined() + expect(typeof tool.execute).toBe('function') + }) + + it('should preserve tool names with special characters', () => { + const mcpTools: MCPTool[] = [ + { + id: 'special-tool-id', + serverId: 'server', + serverName: 'server', + name: 'tool_with-special.chars', + description: 'Special chars tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const result = convertMcpToolsToAiSdkTools(mcpTools) + expect(Object.keys(result)).toEqual(['tool_with-special.chars']) + }) + + it('should handle multiple tools with different schemas', () => { + const mcpTools: MCPTool[] = [ + { + id: 'string-tool-id', + serverId: 'server1', + serverName: 'server1', + name: 'string-tool', + description: 'String tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: { + input: { type: 'string' } + } + } + }, + { + id: 'number-tool-id', + serverId: 'server2', + serverName: 'server2', + name: 'number-tool', + description: 'Number tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: { + count: { type: 'number' } + } + } + }, + { + id: 'boolean-tool-id', + serverId: 'server3', + serverName: 'server3', + name: 'boolean-tool', + description: 'Boolean tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: { + enabled: { type: 'boolean' } + } + } + } + ] + + const result = convertMcpToolsToAiSdkTools(mcpTools) + + expect(Object.keys(result).sort()).toEqual(['boolean-tool', 'number-tool', 'string-tool']) + expect(result['string-tool']).toBeDefined() + expect(result['number-tool']).toBeDefined() + expect(result['boolean-tool']).toBeDefined() + }) + }) + + describe('tool execution', () => { + it('should execute tool with user confirmation', async () => { + const { callMCPTool } = await import('@renderer/utils/mcp-tools') + const { requestToolConfirmation } = await import('@renderer/utils/userConfirmation') + + vi.mocked(requestToolConfirmation).mockResolvedValue(true) + vi.mocked(callMCPTool).mockResolvedValue({ + content: [{ type: 'text', text: 'Success' }], + isError: false + }) + + const mcpTools: MCPTool[] = [ + { + id: 'test-exec-tool-id', + serverId: 'test-server', + serverName: 'test-server', + name: 'test-exec-tool', + description: 'Test execution tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const tools = convertMcpToolsToAiSdkTools(mcpTools) + const tool = tools['test-exec-tool'] as Tool + const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'test-call-123' }) + + expect(requestToolConfirmation).toHaveBeenCalled() + expect(callMCPTool).toHaveBeenCalled() + expect(result).toEqual({ + content: [{ type: 'text', text: 'Success' }], + isError: false + }) + }) + + it('should handle user cancellation', async () => { + const { requestToolConfirmation } = await import('@renderer/utils/userConfirmation') + const { callMCPTool } = await import('@renderer/utils/mcp-tools') + + vi.mocked(requestToolConfirmation).mockResolvedValue(false) + + const mcpTools: MCPTool[] = [ + { + id: 'cancelled-tool-id', + serverId: 'test-server', + serverName: 'test-server', + name: 'cancelled-tool', + description: 'Tool to cancel', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const tools = convertMcpToolsToAiSdkTools(mcpTools) + const tool = tools['cancelled-tool'] as Tool + const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'cancel-call-123' }) + + expect(requestToolConfirmation).toHaveBeenCalled() + expect(callMCPTool).not.toHaveBeenCalled() + expect(result).toEqual({ + content: [ + { + type: 'text', + text: 'User declined to execute tool "cancelled-tool".' + } + ], + isError: false + }) + }) + + it('should handle tool execution error', async () => { + const { callMCPTool } = await import('@renderer/utils/mcp-tools') + const { requestToolConfirmation } = await import('@renderer/utils/userConfirmation') + + vi.mocked(requestToolConfirmation).mockResolvedValue(true) + vi.mocked(callMCPTool).mockResolvedValue({ + content: [{ type: 'text', text: 'Error occurred' }], + isError: true + }) + + const mcpTools: MCPTool[] = [ + { + id: 'error-tool-id', + serverId: 'test-server', + serverName: 'test-server', + name: 'error-tool', + description: 'Tool that errors', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const tools = convertMcpToolsToAiSdkTools(mcpTools) + const tool = tools['error-tool'] as Tool + + await expect( + tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'error-call-123' }) + ).rejects.toEqual({ + content: [{ type: 'text', text: 'Error occurred' }], + isError: true + }) + }) + + it('should auto-approve when enabled', async () => { + const { callMCPTool, isToolAutoApproved } = await import('@renderer/utils/mcp-tools') + const { requestToolConfirmation } = await import('@renderer/utils/userConfirmation') + + vi.mocked(isToolAutoApproved).mockReturnValue(true) + vi.mocked(callMCPTool).mockResolvedValue({ + content: [{ type: 'text', text: 'Auto-approved success' }], + isError: false + }) + + const mcpTools: MCPTool[] = [ + { + id: 'auto-approve-tool-id', + serverId: 'test-server', + serverName: 'test-server', + name: 'auto-approve-tool', + description: 'Auto-approved tool', + type: 'mcp', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + + const tools = convertMcpToolsToAiSdkTools(mcpTools) + const tool = tools['auto-approve-tool'] as Tool + const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'auto-call-123' }) + + expect(requestToolConfirmation).not.toHaveBeenCalled() + expect(callMCPTool).toHaveBeenCalled() + expect(result).toEqual({ + content: [{ type: 'text', text: 'Auto-approved success' }], + isError: false + }) + }) + }) +}) diff --git a/src/renderer/src/aiCore/utils/__tests__/options.test.ts b/src/renderer/src/aiCore/utils/__tests__/options.test.ts new file mode 100644 index 000000000..84ed65b0e --- /dev/null +++ b/src/renderer/src/aiCore/utils/__tests__/options.test.ts @@ -0,0 +1,542 @@ +/** + * options.ts Unit Tests + * Tests for building provider-specific options + */ + +import type { Assistant, Model, Provider } from '@renderer/types' +import { OpenAIServiceTiers, SystemProviderIds } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { buildProviderOptions } from '../options' + +// Mock dependencies +vi.mock('@cherrystudio/ai-core/provider', async (importOriginal) => { + const actual = (await importOriginal()) as object + return { + ...actual, + baseProviderIdSchema: { + safeParse: vi.fn((id) => { + const baseProviders = [ + 'openai', + 'openai-chat', + 'azure', + 'azure-responses', + 'huggingface', + 'anthropic', + 'google', + 'xai', + 'deepseek', + 'openrouter', + 'openai-compatible' + ] + if (baseProviders.includes(id)) { + return { success: true, data: id } + } + return { success: false } + }) + }, + customProviderIdSchema: { + safeParse: vi.fn((id) => { + const customProviders = ['google-vertex', 'google-vertex-anthropic', 'bedrock'] + if (customProviders.includes(id)) { + return { success: true, data: id } + } + return { success: false, error: new Error('Invalid provider') } + }) + } + } +}) + +vi.mock('../provider/factory', () => ({ + getAiSdkProviderId: vi.fn((provider) => { + // Simulate the provider ID mapping + const mapping: Record = { + [SystemProviderIds.gemini]: 'google', + [SystemProviderIds.openai]: 'openai', + [SystemProviderIds.anthropic]: 'anthropic', + [SystemProviderIds.grok]: 'xai', + [SystemProviderIds.deepseek]: 'deepseek', + [SystemProviderIds.openrouter]: 'openrouter' + } + return mapping[provider.id] || provider.id + }) +})) + +vi.mock('@renderer/config/models', async (importOriginal) => ({ + ...(await importOriginal()), + isOpenAIModel: vi.fn((model) => model.id.includes('gpt') || model.id.includes('o1')), + isQwenMTModel: vi.fn(() => false), + isSupportFlexServiceTierModel: vi.fn(() => true), + isOpenAILLMModel: vi.fn(() => true), + SYSTEM_MODELS: { + defaultModel: [ + { id: 'default-1', name: 'Default 1' }, + { id: 'default-2', name: 'Default 2' }, + { id: 'default-3', name: 'Default 3' } + ] + } +})) + +vi.mock('@renderer/utils/provider', () => ({ + isSupportServiceTierProvider: vi.fn((provider) => { + return [SystemProviderIds.openai, SystemProviderIds.groq].includes(provider.id) + }) +})) + +vi.mock('@renderer/store/settings', () => ({ + default: (state = { settings: {} }) => state +})) + +vi.mock('@renderer/hooks/useSettings', () => ({ + getStoreSetting: vi.fn((key) => { + if (key === 'openAI') { + return { summaryText: 'off', verbosity: 'medium' } as any + } + return {} + }) +})) + +vi.mock('@renderer/services/AssistantService', () => ({ + getDefaultAssistant: vi.fn(() => ({ + id: 'default', + name: 'Default Assistant', + settings: {} + })), + getAssistantSettings: vi.fn(() => ({ + reasoning_effort: 'medium', + maxTokens: 4096 + })), + getProviderByModel: vi.fn((model: Model) => ({ + id: model.provider, + name: 'Mock Provider' + })) +})) + +vi.mock('../reasoning', () => ({ + getOpenAIReasoningParams: vi.fn(() => ({ reasoningEffort: 'medium' })), + getAnthropicReasoningParams: vi.fn(() => ({ + thinking: { type: 'enabled', budgetTokens: 5000 } + })), + getGeminiReasoningParams: vi.fn(() => ({ + thinkingConfig: { include_thoughts: true } + })), + getXAIReasoningParams: vi.fn(() => ({ reasoningEffort: 'high' })), + getBedrockReasoningParams: vi.fn(() => ({ + reasoningConfig: { type: 'enabled', budgetTokens: 5000 } + })), + getReasoningEffort: vi.fn(() => ({ reasoningEffort: 'medium' })), + getCustomParameters: vi.fn(() => ({})) +})) + +vi.mock('../image', () => ({ + buildGeminiGenerateImageParams: vi.fn(() => ({ + responseModalities: ['TEXT', 'IMAGE'] + })) +})) + +vi.mock('../websearch', () => ({ + getWebSearchParams: vi.fn(() => ({ enable_search: true })) +})) + +const ensureWindowApi = () => { + const globalWindow = window as any + globalWindow.api = globalWindow.api || {} + globalWindow.api.getAppInfo = globalWindow.api.getAppInfo || vi.fn(async () => ({ notesPath: '' })) +} + +ensureWindowApi() + +describe('options utils', () => { + const mockAssistant: Assistant = { + id: 'test-assistant', + name: 'Test Assistant', + settings: {} + } as Assistant + + const mockModel: Model = { + id: 'gpt-4', + name: 'GPT-4', + provider: SystemProviderIds.openai + } as Model + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('buildProviderOptions', () => { + describe('OpenAI provider', () => { + const openaiProvider: Provider = { + id: SystemProviderIds.openai, + name: 'OpenAI', + type: 'openai-response', + apiKey: 'test-key', + apiHost: 'https://api.openai.com/v1', + isSystem: true + } as Provider + + it('should build basic OpenAI options', () => { + const result = buildProviderOptions(mockAssistant, mockModel, openaiProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('openai') + expect(result.openai).toBeDefined() + }) + + it('should include reasoning parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, mockModel, openaiProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.openai).toHaveProperty('reasoningEffort') + expect(result.openai.reasoningEffort).toBe('medium') + }) + + it('should include service tier when supported', () => { + const providerWithServiceTier: Provider = { + ...openaiProvider, + serviceTier: OpenAIServiceTiers.auto + } + + const result = buildProviderOptions(mockAssistant, mockModel, providerWithServiceTier, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.openai).toHaveProperty('serviceTier') + expect(result.openai.serviceTier).toBe(OpenAIServiceTiers.auto) + }) + }) + + describe('Anthropic provider', () => { + const anthropicProvider: Provider = { + id: SystemProviderIds.anthropic, + name: 'Anthropic', + type: 'anthropic', + apiKey: 'test-key', + apiHost: 'https://api.anthropic.com', + isSystem: true + } as Provider + + const anthropicModel: Model = { + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + provider: SystemProviderIds.anthropic + } as Model + + it('should build basic Anthropic options', () => { + const result = buildProviderOptions(mockAssistant, anthropicModel, anthropicProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('anthropic') + expect(result.anthropic).toBeDefined() + }) + + it('should include reasoning parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, anthropicModel, anthropicProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.anthropic).toHaveProperty('thinking') + expect(result.anthropic.thinking).toEqual({ + type: 'enabled', + budgetTokens: 5000 + }) + }) + }) + + describe('Google provider', () => { + const googleProvider: Provider = { + id: SystemProviderIds.gemini, + name: 'Google', + type: 'gemini', + apiKey: 'test-key', + apiHost: 'https://generativelanguage.googleapis.com', + isSystem: true, + models: [{ id: 'gemini-2.0-flash-exp' }] as Model[] + } as Provider + + const googleModel: Model = { + id: 'gemini-2.0-flash-exp', + name: 'Gemini 2.0 Flash', + provider: SystemProviderIds.gemini + } as Model + + it('should build basic Google options', () => { + const result = buildProviderOptions(mockAssistant, googleModel, googleProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('google') + expect(result.google).toBeDefined() + }) + + it('should include reasoning parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, googleModel, googleProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.google).toHaveProperty('thinkingConfig') + expect(result.google.thinkingConfig).toEqual({ + include_thoughts: true + }) + }) + + it('should include image generation parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, googleModel, googleProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: true + }) + + expect(result.google).toHaveProperty('responseModalities') + expect(result.google.responseModalities).toEqual(['TEXT', 'IMAGE']) + }) + }) + + describe('xAI provider', () => { + const xaiProvider = { + id: SystemProviderIds.grok, + name: 'xAI', + type: 'new-api', + apiKey: 'test-key', + apiHost: 'https://api.x.ai/v1', + isSystem: true, + models: [] as Model[] + } as Provider + + const xaiModel: Model = { + id: 'grok-2-latest', + name: 'Grok 2', + provider: SystemProviderIds.grok + } as Model + + it('should build basic xAI options', () => { + const result = buildProviderOptions(mockAssistant, xaiModel, xaiProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('xai') + expect(result.xai).toBeDefined() + }) + + it('should include reasoning parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, xaiModel, xaiProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.xai).toHaveProperty('reasoningEffort') + expect(result.xai.reasoningEffort).toBe('high') + }) + }) + + describe('DeepSeek provider', () => { + const deepseekProvider: Provider = { + id: SystemProviderIds.deepseek, + name: 'DeepSeek', + type: 'openai', + apiKey: 'test-key', + apiHost: 'https://api.deepseek.com', + isSystem: true + } as Provider + + const deepseekModel: Model = { + id: 'deepseek-chat', + name: 'DeepSeek Chat', + provider: SystemProviderIds.deepseek + } as Model + + it('should build basic DeepSeek options', () => { + const result = buildProviderOptions(mockAssistant, deepseekModel, deepseekProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('deepseek') + expect(result.deepseek).toBeDefined() + }) + }) + + describe('OpenRouter provider', () => { + const openrouterProvider: Provider = { + id: SystemProviderIds.openrouter, + name: 'OpenRouter', + type: 'openai', + apiKey: 'test-key', + apiHost: 'https://openrouter.ai/api/v1', + isSystem: true + } as Provider + + const openrouterModel: Model = { + id: 'openai/gpt-4', + name: 'GPT-4', + provider: SystemProviderIds.openrouter + } as Model + + it('should build basic OpenRouter options', () => { + const result = buildProviderOptions(mockAssistant, openrouterModel, openrouterProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('openrouter') + expect(result.openrouter).toBeDefined() + }) + + it('should include web search parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, openrouterModel, openrouterProvider, { + enableReasoning: false, + enableWebSearch: true, + enableGenerateImage: false + }) + + expect(result.openrouter).toHaveProperty('enable_search') + }) + }) + + describe('Custom parameters', () => { + it('should merge custom parameters', async () => { + const { getCustomParameters } = await import('../reasoning') + + vi.mocked(getCustomParameters).mockReturnValue({ + custom_param: 'custom_value', + another_param: 123 + }) + + const result = buildProviderOptions( + mockAssistant, + mockModel, + { + id: SystemProviderIds.openai, + name: 'OpenAI', + type: 'openai', + apiKey: 'test-key', + apiHost: 'https://api.openai.com/v1' + } as Provider, + { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + } + ) + + expect(result.openai).toHaveProperty('custom_param') + expect(result.openai.custom_param).toBe('custom_value') + expect(result.openai).toHaveProperty('another_param') + expect(result.openai.another_param).toBe(123) + }) + }) + + describe('Multiple capabilities', () => { + const googleProvider = { + id: SystemProviderIds.gemini, + name: 'Google', + type: 'gemini', + apiKey: 'test-key', + apiHost: 'https://generativelanguage.googleapis.com', + isSystem: true, + models: [] as Model[] + } as Provider + + const googleModel: Model = { + id: 'gemini-2.0-flash-exp', + name: 'Gemini 2.0 Flash', + provider: SystemProviderIds.gemini + } as Model + + it('should combine reasoning and image generation', () => { + const result = buildProviderOptions(mockAssistant, googleModel, googleProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: true + }) + + expect(result.google).toHaveProperty('thinkingConfig') + expect(result.google).toHaveProperty('responseModalities') + }) + + it('should handle all capabilities enabled', () => { + const result = buildProviderOptions(mockAssistant, googleModel, googleProvider, { + enableReasoning: true, + enableWebSearch: true, + enableGenerateImage: true + }) + + expect(result.google).toBeDefined() + expect(Object.keys(result.google).length).toBeGreaterThan(0) + }) + }) + + describe('Vertex AI providers', () => { + it('should map google-vertex to google', () => { + const vertexProvider = { + id: 'google-vertex', + name: 'Vertex AI', + type: 'vertexai', + apiKey: 'test-key', + apiHost: 'https://vertex-ai.googleapis.com', + models: [] as Model[] + } as Provider + + const vertexModel: Model = { + id: 'gemini-2.0-flash-exp', + name: 'Gemini 2.0 Flash', + provider: 'google-vertex' + } as Model + + const result = buildProviderOptions(mockAssistant, vertexModel, vertexProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('google') + }) + + it('should map google-vertex-anthropic to anthropic', () => { + const vertexAnthropicProvider = { + id: 'google-vertex-anthropic', + name: 'Vertex AI Anthropic', + type: 'vertex-anthropic', + apiKey: 'test-key', + apiHost: 'https://vertex-ai.googleapis.com', + models: [] as Model[] + } as Provider + + const vertexModel: Model = { + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + provider: 'google-vertex-anthropic' + } as Model + + const result = buildProviderOptions(mockAssistant, vertexModel, vertexAnthropicProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result).toHaveProperty('anthropic') + }) + }) + }) +}) diff --git a/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts b/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts index 4561414c1..1303e254a 100644 --- a/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/reasoning.test.ts @@ -1,87 +1,967 @@ -import * as models from '@renderer/config/models' +/** + * reasoning.ts Unit Tests + * Tests for reasoning parameter generation utilities + */ + +import { getStoreSetting } from '@renderer/hooks/useSettings' +import type { SettingsState } from '@renderer/store/settings' +import type { Assistant, Model, Provider } from '@renderer/types' +import { SystemProviderIds } from '@renderer/types' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getAnthropicThinkingBudget } from '../reasoning' +import { + getAnthropicReasoningParams, + getBedrockReasoningParams, + getCustomParameters, + getGeminiReasoningParams, + getOpenAIReasoningParams, + getReasoningEffort, + getXAIReasoningParams +} from '../reasoning' -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ - llm: { - providers: [] - }, - settings: {} +function defaultGetStoreSetting(key: K): SettingsState[K] { + if (key === 'openAI') { + return { + summaryText: 'auto', + verbosity: 'medium' + } as SettingsState[K] + } + return undefined as SettingsState[K] +} + +// Mock dependencies +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn() }) - }, - useAppDispatch: () => vi.fn(), - useAppSelector: () => vi.fn() + } })) +vi.mock('@renderer/store/settings', () => ({ + default: (state = { settings: {} }) => state +})) + +vi.mock('@renderer/store/llm', () => ({ + initialState: {}, + default: (state = { llm: {} }) => state +})) + +vi.mock('@renderer/config/constant', () => ({ + DEFAULT_MAX_TOKENS: 4096, + isMac: false, + isWin: false, + TOKENFLUX_HOST: 'mock-host' +})) + +vi.mock('@renderer/utils/provider', () => ({ + isSupportEnableThinkingProvider: vi.fn((provider) => { + return [SystemProviderIds.dashscope, SystemProviderIds.silicon].includes(provider.id) + }) +})) + +vi.mock('@renderer/config/models', async (importOriginal) => { + const actual: any = await importOriginal() + return { + ...actual, + isReasoningModel: vi.fn(() => false), + isOpenAIDeepResearchModel: vi.fn(() => false), + isOpenAIModel: vi.fn(() => false), + isSupportedReasoningEffortOpenAIModel: vi.fn(() => false), + isSupportedThinkingTokenQwenModel: vi.fn(() => false), + isQwenReasoningModel: vi.fn(() => false), + isSupportedThinkingTokenClaudeModel: vi.fn(() => false), + isSupportedThinkingTokenGeminiModel: vi.fn(() => false), + isSupportedThinkingTokenDoubaoModel: vi.fn(() => false), + isSupportedThinkingTokenZhipuModel: vi.fn(() => false), + isSupportedReasoningEffortModel: vi.fn(() => false), + isDeepSeekHybridInferenceModel: vi.fn(() => false), + isSupportedReasoningEffortGrokModel: vi.fn(() => false), + getThinkModelType: vi.fn(() => 'default'), + isDoubaoSeedAfter251015: vi.fn(() => false), + isDoubaoThinkingAutoModel: vi.fn(() => false), + isGrok4FastReasoningModel: vi.fn(() => false), + isGrokReasoningModel: vi.fn(() => false), + isOpenAIReasoningModel: vi.fn(() => false), + isQwenAlwaysThinkModel: vi.fn(() => false), + isSupportedThinkingTokenHunyuanModel: vi.fn(() => false), + isSupportedThinkingTokenModel: vi.fn(() => false), + isGPT51SeriesModel: vi.fn(() => false) + } +}) + vi.mock('@renderer/hooks/useSettings', () => ({ - getStoreSetting: () => undefined, - useSettings: () => ({}) + getStoreSetting: vi.fn(defaultGetStoreSetting) })) vi.mock('@renderer/services/AssistantService', () => ({ - getAssistantSettings: () => ({ maxTokens: undefined }), - getProviderByModel: () => ({ id: '' }) + getAssistantSettings: vi.fn((assistant) => ({ + maxTokens: assistant?.settings?.maxTokens || 4096, + reasoning_effort: assistant?.settings?.reasoning_effort + })), + getProviderByModel: vi.fn((model) => ({ + id: model.provider, + name: 'Test Provider' + })), + getDefaultAssistant: vi.fn(() => ({ + id: 'default', + name: 'Default Assistant', + settings: {} + })) })) +const ensureWindowApi = () => { + const globalWindow = window as any + globalWindow.api = globalWindow.api || {} + globalWindow.api.getAppInfo = globalWindow.api.getAppInfo || vi.fn(async () => ({ notesPath: '' })) +} + +ensureWindowApi() + describe('reasoning utils', () => { - describe('getAnthropicThinkingBudget', () => { - const findTokenLimitSpy = vi.spyOn(models, 'findTokenLimit') - const applyTokenLimit = (limit?: { min: number; max: number }) => findTokenLimitSpy.mockReturnValueOnce(limit) + beforeEach(() => { + vi.resetAllMocks() + }) - beforeEach(() => { - findTokenLimitSpy.mockReset() + describe('getReasoningEffort', () => { + it('should return empty object for non-reasoning model', async () => { + const model: Model = { + id: 'gpt-4', + name: 'GPT-4', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({}) }) - it('returns undefined when reasoningEffort is undefined', () => { - const result = getAnthropicThinkingBudget(8000, undefined, 'claude-model') - expect(result).toBe(undefined) - expect(findTokenLimitSpy).not.toHaveBeenCalled() + it('should disable reasoning for OpenRouter when no reasoning effort set', async () => { + const { isReasoningModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + + const model: Model = { + id: 'anthropic/claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: SystemProviderIds.openrouter + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({ reasoning: { enabled: false, exclude: true } }) }) - it('returns undefined when tokenLimit is not found', () => { - const unknownId = 'unknown-model' - applyTokenLimit(undefined) - const result = getAnthropicThinkingBudget(8000, 'medium', unknownId) - expect(result).toBe(undefined) - expect(findTokenLimitSpy).toHaveBeenCalledWith(unknownId) + it('should handle Qwen models with enable_thinking', async () => { + const { isReasoningModel, isSupportedThinkingTokenQwenModel, isQwenReasoningModel } = await import( + '@renderer/config/models' + ) + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenQwenModel).mockReturnValue(true) + vi.mocked(isQwenReasoningModel).mockReturnValue(true) + + const model: Model = { + id: 'qwen-plus', + name: 'Qwen Plus', + provider: SystemProviderIds.dashscope + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'medium' + } + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toHaveProperty('enable_thinking') }) - it('uses DEFAULT_MAX_TOKENS when maxTokens is undefined', () => { - applyTokenLimit({ min: 1000, max: 10_000 }) - const result = getAnthropicThinkingBudget(undefined, 'medium', 'claude-model') - expect(result).toBe(2048) - expect(findTokenLimitSpy).toHaveBeenCalledWith('claude-model') + it('should handle Claude models with thinking config', async () => { + const { + isSupportedThinkingTokenClaudeModel, + isReasoningModel, + isQwenReasoningModel, + isSupportedThinkingTokenGeminiModel, + isSupportedThinkingTokenDoubaoModel, + isSupportedThinkingTokenZhipuModel, + isSupportedReasoningEffortModel + } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenClaudeModel).mockReturnValue(true) + vi.mocked(isQwenReasoningModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenGeminiModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenDoubaoModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenZhipuModel).mockReturnValue(false) + vi.mocked(isSupportedReasoningEffortModel).mockReturnValue(false) + + const model: Model = { + id: 'claude-3-7-sonnet', + name: 'Claude 3.7 Sonnet', + provider: SystemProviderIds.anthropic + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'high', + maxTokens: 4096 + } + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({ + thinking: { + type: 'enabled', + budget_tokens: expect.any(Number) + } + }) }) - it('respects maxTokens limit when lower than token limit', () => { - applyTokenLimit({ min: 1000, max: 10_000 }) - const result = getAnthropicThinkingBudget(8000, 'medium', 'claude-model') - expect(result).toBe(4000) - expect(findTokenLimitSpy).toHaveBeenCalledWith('claude-model') + it('should handle Gemini Flash models with thinking budget 0', async () => { + const { + isSupportedThinkingTokenGeminiModel, + isReasoningModel, + isQwenReasoningModel, + isSupportedThinkingTokenClaudeModel, + isSupportedThinkingTokenDoubaoModel, + isSupportedThinkingTokenZhipuModel, + isOpenAIDeepResearchModel, + isSupportedThinkingTokenQwenModel, + isSupportedThinkingTokenHunyuanModel, + isDeepSeekHybridInferenceModel + } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIDeepResearchModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenGeminiModel).mockReturnValue(true) + vi.mocked(isQwenReasoningModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenClaudeModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenDoubaoModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenZhipuModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenQwenModel).mockReturnValue(false) + vi.mocked(isSupportedThinkingTokenHunyuanModel).mockReturnValue(false) + vi.mocked(isDeepSeekHybridInferenceModel).mockReturnValue(false) + + const model: Model = { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({ + extra_body: { + google: { + thinking_config: { + thinking_budget: 0 + } + } + } + }) }) - it('caps to token limit when lower than maxTokens budget', () => { - applyTokenLimit({ min: 1000, max: 5000 }) - const result = getAnthropicThinkingBudget(100_000, 'high', 'claude-model') - expect(result).toBe(4200) - expect(findTokenLimitSpy).toHaveBeenCalledWith('claude-model') + it('should handle GPT-5.1 reasoning model with effort levels', async () => { + const { + isReasoningModel, + isOpenAIDeepResearchModel, + isSupportedReasoningEffortModel, + isGPT51SeriesModel, + getThinkModelType + } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIDeepResearchModel).mockReturnValue(false) + vi.mocked(isSupportedReasoningEffortModel).mockReturnValue(true) + vi.mocked(getThinkModelType).mockReturnValue('gpt5_1') + vi.mocked(isGPT51SeriesModel).mockReturnValue(true) + + const model: Model = { + id: 'gpt-5.1', + name: 'GPT-5.1', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'none' + } + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({ + reasoningEffort: 'none' + }) }) - it('enforces minimum budget of 1024', () => { - applyTokenLimit({ min: 0, max: 500 }) - const result = getAnthropicThinkingBudget(200, 'low', 'claude-model') - expect(result).toBe(1024) - expect(findTokenLimitSpy).toHaveBeenCalledWith('claude-model') + it('should handle DeepSeek hybrid inference models', async () => { + const { isReasoningModel, isDeepSeekHybridInferenceModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isDeepSeekHybridInferenceModel).mockReturnValue(true) + + const model: Model = { + id: 'deepseek-v3.1', + name: 'DeepSeek V3.1', + provider: SystemProviderIds.silicon + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'high' + } + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({ + enable_thinking: true + }) }) - it('respects large token limits when maxTokens is high', () => { - applyTokenLimit({ min: 1024, max: 64_000 }) - const result = getAnthropicThinkingBudget(64_000, 'high', 'claude-model') - expect(result).toBe(51_200) - expect(findTokenLimitSpy).toHaveBeenCalledWith('claude-model') + it('should return medium effort for deep research models', async () => { + const { isReasoningModel, isOpenAIDeepResearchModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIDeepResearchModel).mockReturnValue(true) + + const model: Model = { + id: 'o3-deep-research', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({ reasoning_effort: 'medium' }) + }) + + it('should return empty for groq provider', async () => { + const { getProviderByModel } = await import('@renderer/services/AssistantService') + + vi.mocked(getProviderByModel).mockReturnValue({ + id: 'groq', + name: 'Groq' + } as Provider) + + const model: Model = { + id: 'groq-model', + name: 'Groq Model', + provider: 'groq' + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getReasoningEffort(assistant, model) + expect(result).toEqual({}) + }) + }) + + describe('getOpenAIReasoningParams', () => { + it('should return empty object for non-reasoning model', async () => { + const model: Model = { + id: 'gpt-4', + name: 'GPT-4', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getOpenAIReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return empty when no reasoning effort set', async () => { + const model: Model = { + id: 'o1-preview', + name: 'O1 Preview', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getOpenAIReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return reasoning effort for OpenAI models', async () => { + const { isReasoningModel, isOpenAIModel, isSupportedReasoningEffortOpenAIModel } = await import( + '@renderer/config/models' + ) + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIModel).mockReturnValue(true) + vi.mocked(isSupportedReasoningEffortOpenAIModel).mockReturnValue(true) + + const model: Model = { + id: 'gpt-5.1', + name: 'GPT 5.1', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'high' + } + } as Assistant + + const result = getOpenAIReasoningParams(assistant, model) + expect(result).toEqual({ + reasoningEffort: 'high', + reasoningSummary: 'auto' + }) + }) + + it('should include reasoning summary when not o1-pro', async () => { + const { isReasoningModel, isOpenAIModel, isSupportedReasoningEffortOpenAIModel } = await import( + '@renderer/config/models' + ) + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIModel).mockReturnValue(true) + vi.mocked(isSupportedReasoningEffortOpenAIModel).mockReturnValue(true) + + const model: Model = { + id: 'gpt-5', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'medium' + } + } as Assistant + + const result = getOpenAIReasoningParams(assistant, model) + expect(result).toEqual({ + reasoningEffort: 'medium', + reasoningSummary: 'auto' + }) + }) + + it('should not include reasoning summary for o1-pro', async () => { + const { isReasoningModel, isOpenAIDeepResearchModel, isSupportedReasoningEffortOpenAIModel } = await import( + '@renderer/config/models' + ) + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIDeepResearchModel).mockReturnValue(false) + vi.mocked(isSupportedReasoningEffortOpenAIModel).mockReturnValue(true) + vi.mocked(getStoreSetting).mockReturnValue({ summaryText: 'off' } as any) + + const model: Model = { + id: 'o1-pro', + name: 'O1 Pro', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'high' + } + } as Assistant + + const result = getOpenAIReasoningParams(assistant, model) + expect(result).toEqual({ + reasoningEffort: 'high', + reasoningSummary: undefined + }) + }) + + it('should force medium effort for deep research models', async () => { + const { isReasoningModel, isOpenAIModel, isOpenAIDeepResearchModel, isSupportedReasoningEffortOpenAIModel } = + await import('@renderer/config/models') + const { getStoreSetting } = await import('@renderer/hooks/useSettings') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isOpenAIModel).mockReturnValue(true) + vi.mocked(isOpenAIDeepResearchModel).mockReturnValue(true) + vi.mocked(isSupportedReasoningEffortOpenAIModel).mockReturnValue(true) + vi.mocked(getStoreSetting).mockReturnValue({ summaryText: 'off' } as any) + + const model: Model = { + id: 'o3-deep-research', + name: 'O3 Mini', + provider: SystemProviderIds.openai + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'high' + } + } as Assistant + + const result = getOpenAIReasoningParams(assistant, model) + expect(result).toEqual({ + reasoningEffort: 'medium', + reasoningSummary: 'off' + }) + }) + }) + + describe('getAnthropicReasoningParams', () => { + it('should return empty for non-reasoning model', async () => { + const { isReasoningModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(false) + + const model: Model = { + id: 'claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + provider: SystemProviderIds.anthropic + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getAnthropicReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return disabled thinking when no reasoning effort', async () => { + const { isReasoningModel, isSupportedThinkingTokenClaudeModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenClaudeModel).mockReturnValue(false) + + const model: Model = { + id: 'claude-3-7-sonnet', + name: 'Claude 3.7 Sonnet', + provider: SystemProviderIds.anthropic + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getAnthropicReasoningParams(assistant, model) + expect(result).toEqual({ + thinking: { + type: 'disabled' + } + }) + }) + + it('should return enabled thinking with budget for Claude models', async () => { + const { isReasoningModel, isSupportedThinkingTokenClaudeModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenClaudeModel).mockReturnValue(true) + + const model: Model = { + id: 'claude-3-7-sonnet', + name: 'Claude 3.7 Sonnet', + provider: SystemProviderIds.anthropic + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'medium', + maxTokens: 4096 + } + } as Assistant + + const result = getAnthropicReasoningParams(assistant, model) + expect(result).toEqual({ + thinking: { + type: 'enabled', + budgetTokens: 2048 + } + }) + }) + }) + + describe('getGeminiReasoningParams', () => { + it('should return empty for non-reasoning model', async () => { + const { isReasoningModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(false) + + const model: Model = { + id: 'gemini-2.0-flash', + name: 'Gemini 2.0 Flash', + provider: SystemProviderIds.gemini + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getGeminiReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should disable thinking for Flash models without reasoning effort', async () => { + const { isReasoningModel, isSupportedThinkingTokenGeminiModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenGeminiModel).mockReturnValue(true) + + const model: Model = { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + provider: SystemProviderIds.gemini + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getGeminiReasoningParams(assistant, model) + expect(result).toEqual({ + thinkingConfig: { + includeThoughts: false, + thinkingBudget: 0 + } + }) + }) + + it('should enable thinking with budget for reasoning effort', async () => { + const { isReasoningModel, isSupportedThinkingTokenGeminiModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenGeminiModel).mockReturnValue(true) + + const model: Model = { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: SystemProviderIds.gemini + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'medium' + } + } as Assistant + + const result = getGeminiReasoningParams(assistant, model) + expect(result).toEqual({ + thinkingConfig: { + thinkingBudget: 16448, + includeThoughts: true + } + }) + }) + + it('should enable thinking without budget for auto effort ratio > 1', async () => { + const { isReasoningModel, isSupportedThinkingTokenGeminiModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenGeminiModel).mockReturnValue(true) + + const model: Model = { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: SystemProviderIds.gemini + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'auto' + } + } as Assistant + + const result = getGeminiReasoningParams(assistant, model) + expect(result).toEqual({ + thinkingConfig: { + includeThoughts: true + } + }) + }) + }) + + describe('getXAIReasoningParams', () => { + it('should return empty for non-Grok model', async () => { + const { isSupportedReasoningEffortGrokModel } = await import('@renderer/config/models') + + vi.mocked(isSupportedReasoningEffortGrokModel).mockReturnValue(false) + + const model: Model = { + id: 'other-model', + name: 'Other Model', + provider: SystemProviderIds.grok + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getXAIReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return empty when no reasoning effort', async () => { + const { isSupportedReasoningEffortGrokModel } = await import('@renderer/config/models') + + vi.mocked(isSupportedReasoningEffortGrokModel).mockReturnValue(true) + + const model: Model = { + id: 'grok-2', + name: 'Grok 2', + provider: SystemProviderIds.grok + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getXAIReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return reasoning effort for Grok models', async () => { + const { isSupportedReasoningEffortGrokModel } = await import('@renderer/config/models') + + vi.mocked(isSupportedReasoningEffortGrokModel).mockReturnValue(true) + + const model: Model = { + id: 'grok-3', + name: 'Grok 3', + provider: SystemProviderIds.grok + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'high' + } + } as Assistant + + const result = getXAIReasoningParams(assistant, model) + expect(result).toHaveProperty('reasoningEffort') + expect(result.reasoningEffort).toBe('high') + }) + }) + + describe('getBedrockReasoningParams', () => { + it('should return empty for non-reasoning model', async () => { + const model: Model = { + id: 'other-model', + name: 'Other Model', + provider: 'bedrock' + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getBedrockReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return empty when no reasoning effort', async () => { + const model: Model = { + id: 'claude-3-7-sonnet', + name: 'Claude 3.7 Sonnet', + provider: 'bedrock' + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getBedrockReasoningParams(assistant, model) + expect(result).toEqual({}) + }) + + it('should return reasoning config for Claude models on Bedrock', async () => { + const { isReasoningModel, isSupportedThinkingTokenClaudeModel } = await import('@renderer/config/models') + + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(isSupportedThinkingTokenClaudeModel).mockReturnValue(true) + + const model: Model = { + id: 'claude-3-7-sonnet', + name: 'Claude 3.7 Sonnet', + provider: 'bedrock' + } as Model + + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + reasoning_effort: 'medium', + maxTokens: 4096 + } + } as Assistant + + const result = getBedrockReasoningParams(assistant, model) + expect(result).toEqual({ + reasoningConfig: { + type: 'enabled', + budgetTokens: 2048 + } + }) + }) + }) + + describe('getCustomParameters', () => { + it('should return empty object when no custom parameters', async () => { + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: {} + } as Assistant + + const result = getCustomParameters(assistant) + expect(result).toEqual({}) + }) + + it('should return custom parameters as key-value pairs', async () => { + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + customParameters: [ + { name: 'param1', value: 'value1', type: 'string' }, + { name: 'param2', value: 123, type: 'number' } + ] + } + } as Assistant + + const result = getCustomParameters(assistant) + expect(result).toEqual({ + param1: 'value1', + param2: 123 + }) + }) + + it('should parse JSON type parameters', async () => { + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + customParameters: [{ name: 'config', value: '{"key": "value"}', type: 'json' }] + } + } as Assistant + + const result = getCustomParameters(assistant) + expect(result).toEqual({ + config: { key: 'value' } + }) + }) + + it('should handle invalid JSON gracefully', async () => { + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + customParameters: [{ name: 'invalid', value: '{invalid json', type: 'json' }] + } + } as Assistant + + const result = getCustomParameters(assistant) + expect(result).toEqual({ + invalid: '{invalid json' + }) + }) + + it('should handle undefined JSON value', async () => { + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + customParameters: [{ name: 'undef', value: 'undefined', type: 'json' }] + } + } as Assistant + + const result = getCustomParameters(assistant) + expect(result).toEqual({ + undef: undefined + }) + }) + + it('should skip parameters with empty names', async () => { + const assistant: Assistant = { + id: 'test', + name: 'Test', + settings: { + customParameters: [ + { name: '', value: 'value1', type: 'string' }, + { name: ' ', value: 'value2', type: 'string' }, + { name: 'valid', value: 'value3', type: 'string' } + ] + } + } as Assistant + + const result = getCustomParameters(assistant) + expect(result).toEqual({ + valid: 'value3' + }) }) }) }) diff --git a/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts b/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts new file mode 100644 index 000000000..fa5e3c3b3 --- /dev/null +++ b/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts @@ -0,0 +1,384 @@ +/** + * websearch.ts Unit Tests + * Tests for web search parameters generation utilities + */ + +import type { CherryWebSearchConfig } from '@renderer/store/websearch' +import type { Model } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +import { buildProviderBuiltinWebSearchConfig, getWebSearchParams } from '../websearch' + +// Mock dependencies +vi.mock('@renderer/config/models', () => ({ + isOpenAIWebSearchChatCompletionOnlyModel: vi.fn((model) => model?.id?.includes('o1-pro') ?? false), + isOpenAIDeepResearchModel: vi.fn((model) => model?.id?.includes('o3-mini') ?? false) +})) + +vi.mock('@renderer/utils/blacklistMatchPattern', () => ({ + mapRegexToPatterns: vi.fn((patterns) => patterns || []) +})) + +describe('websearch utils', () => { + describe('getWebSearchParams', () => { + it('should return enhancement params for hunyuan provider', () => { + const model: Model = { + id: 'hunyuan-model', + name: 'Hunyuan Model', + provider: 'hunyuan' + } as Model + + const result = getWebSearchParams(model) + + expect(result).toEqual({ + enable_enhancement: true, + citation: true, + search_info: true + }) + }) + + it('should return search params for dashscope provider', () => { + const model: Model = { + id: 'qwen-model', + name: 'Qwen Model', + provider: 'dashscope' + } as Model + + const result = getWebSearchParams(model) + + expect(result).toEqual({ + enable_search: true, + search_options: { + forced_search: true + } + }) + }) + + it('should return web_search_options for OpenAI web search models', () => { + const model: Model = { + id: 'o1-pro', + name: 'O1 Pro', + provider: 'openai' + } as Model + + const result = getWebSearchParams(model) + + expect(result).toEqual({ + web_search_options: {} + }) + }) + + it('should return empty object for other providers', () => { + const model: Model = { + id: 'gpt-4', + name: 'GPT-4', + provider: 'openai' + } as Model + + const result = getWebSearchParams(model) + + expect(result).toEqual({}) + }) + + it('should return empty object for custom provider', () => { + const model: Model = { + id: 'custom-model', + name: 'Custom Model', + provider: 'custom-provider' + } as Model + + const result = getWebSearchParams(model) + + expect(result).toEqual({}) + }) + }) + + describe('buildProviderBuiltinWebSearchConfig', () => { + const defaultWebSearchConfig: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 50, + excludeDomains: [] + } + + describe('openai provider', () => { + it('should return low search context size for low maxResults', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 20, + excludeDomains: [] + } + + const result = buildProviderBuiltinWebSearchConfig('openai', config) + + expect(result).toEqual({ + openai: { + searchContextSize: 'low' + } + }) + }) + + it('should return medium search context size for medium maxResults', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 50, + excludeDomains: [] + } + + const result = buildProviderBuiltinWebSearchConfig('openai', config) + + expect(result).toEqual({ + openai: { + searchContextSize: 'medium' + } + }) + }) + + it('should return high search context size for high maxResults', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 80, + excludeDomains: [] + } + + const result = buildProviderBuiltinWebSearchConfig('openai', config) + + expect(result).toEqual({ + openai: { + searchContextSize: 'high' + } + }) + }) + + it('should use medium for deep research models regardless of maxResults', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 100, + excludeDomains: [] + } + + const model: Model = { + id: 'o3-mini', + name: 'O3 Mini', + provider: 'openai' + } as Model + + const result = buildProviderBuiltinWebSearchConfig('openai', config, model) + + expect(result).toEqual({ + openai: { + searchContextSize: 'medium' + } + }) + }) + }) + + describe('openai-chat provider', () => { + it('should return correct search context size', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 50, + excludeDomains: [] + } + + const result = buildProviderBuiltinWebSearchConfig('openai-chat', config) + + expect(result).toEqual({ + 'openai-chat': { + searchContextSize: 'medium' + } + }) + }) + + it('should handle deep research models', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 100, + excludeDomains: [] + } + + const model: Model = { + id: 'o3-mini', + name: 'O3 Mini', + provider: 'openai' + } as Model + + const result = buildProviderBuiltinWebSearchConfig('openai-chat', config, model) + + expect(result).toEqual({ + 'openai-chat': { + searchContextSize: 'medium' + } + }) + }) + }) + + describe('anthropic provider', () => { + it('should return anthropic search options with maxUses', () => { + const result = buildProviderBuiltinWebSearchConfig('anthropic', defaultWebSearchConfig) + + expect(result).toEqual({ + anthropic: { + maxUses: 50, + blockedDomains: undefined + } + }) + }) + + it('should include blockedDomains when excludeDomains provided', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 30, + excludeDomains: ['example.com', 'test.com'] + } + + const result = buildProviderBuiltinWebSearchConfig('anthropic', config) + + expect(result).toEqual({ + anthropic: { + maxUses: 30, + blockedDomains: ['example.com', 'test.com'] + } + }) + }) + + it('should not include blockedDomains when empty', () => { + const result = buildProviderBuiltinWebSearchConfig('anthropic', defaultWebSearchConfig) + + expect(result).toEqual({ + anthropic: { + maxUses: 50, + blockedDomains: undefined + } + }) + }) + }) + + describe('xai provider', () => { + it('should return xai search options', () => { + const result = buildProviderBuiltinWebSearchConfig('xai', defaultWebSearchConfig) + + expect(result).toEqual({ + xai: { + maxSearchResults: 50, + returnCitations: true, + sources: [{ type: 'web', excludedWebsites: [] }, { type: 'news' }, { type: 'x' }], + mode: 'on' + } + }) + }) + + it('should limit excluded websites to 5', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 40, + excludeDomains: ['site1.com', 'site2.com', 'site3.com', 'site4.com', 'site5.com', 'site6.com', 'site7.com'] + } + + const result = buildProviderBuiltinWebSearchConfig('xai', config) + + expect(result?.xai?.sources).toBeDefined() + const webSource = result?.xai?.sources?.[0] + if (webSource && webSource.type === 'web') { + expect(webSource.excludedWebsites).toHaveLength(5) + } + }) + + it('should include all sources types', () => { + const result = buildProviderBuiltinWebSearchConfig('xai', defaultWebSearchConfig) + + expect(result?.xai?.sources).toHaveLength(3) + expect(result?.xai?.sources?.[0].type).toBe('web') + expect(result?.xai?.sources?.[1].type).toBe('news') + expect(result?.xai?.sources?.[2].type).toBe('x') + }) + }) + + describe('openrouter provider', () => { + it('should return openrouter plugins config', () => { + const result = buildProviderBuiltinWebSearchConfig('openrouter', defaultWebSearchConfig) + + expect(result).toEqual({ + openrouter: { + plugins: [ + { + id: 'web', + max_results: 50 + } + ] + } + }) + }) + + it('should respect custom maxResults', () => { + const config: CherryWebSearchConfig = { + searchWithTime: true, + maxResults: 75, + excludeDomains: [] + } + + const result = buildProviderBuiltinWebSearchConfig('openrouter', config) + + expect(result).toEqual({ + openrouter: { + plugins: [ + { + id: 'web', + max_results: 75 + } + ] + } + }) + }) + }) + + describe('unsupported provider', () => { + it('should return empty object for unsupported provider', () => { + const result = buildProviderBuiltinWebSearchConfig('unsupported' as any, defaultWebSearchConfig) + + expect(result).toEqual({}) + }) + + it('should return empty object for google provider', () => { + const result = buildProviderBuiltinWebSearchConfig('google', defaultWebSearchConfig) + + expect(result).toEqual({}) + }) + }) + + describe('edge cases', () => { + it('should handle maxResults at boundary values', () => { + // Test boundary at 33 (low/medium) + const config33: CherryWebSearchConfig = { searchWithTime: true, maxResults: 33, excludeDomains: [] } + const result33 = buildProviderBuiltinWebSearchConfig('openai', config33) + expect(result33?.openai?.searchContextSize).toBe('low') + + // Test boundary at 34 (medium) + const config34: CherryWebSearchConfig = { searchWithTime: true, maxResults: 34, excludeDomains: [] } + const result34 = buildProviderBuiltinWebSearchConfig('openai', config34) + expect(result34?.openai?.searchContextSize).toBe('medium') + + // Test boundary at 66 (medium) + const config66: CherryWebSearchConfig = { searchWithTime: true, maxResults: 66, excludeDomains: [] } + const result66 = buildProviderBuiltinWebSearchConfig('openai', config66) + expect(result66?.openai?.searchContextSize).toBe('medium') + + // Test boundary at 67 (high) + const config67: CherryWebSearchConfig = { searchWithTime: true, maxResults: 67, excludeDomains: [] } + const result67 = buildProviderBuiltinWebSearchConfig('openai', config67) + expect(result67?.openai?.searchContextSize).toBe('high') + }) + + it('should handle zero maxResults', () => { + const config: CherryWebSearchConfig = { searchWithTime: true, maxResults: 0, excludeDomains: [] } + const result = buildProviderBuiltinWebSearchConfig('openai', config) + expect(result?.openai?.searchContextSize).toBe('low') + }) + + it('should handle very large maxResults', () => { + const config: CherryWebSearchConfig = { searchWithTime: true, maxResults: 1000, excludeDomains: [] } + const result = buildProviderBuiltinWebSearchConfig('openai', config) + expect(result?.openai?.searchContextSize).toBe('high') + }) + }) + }) +}) diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 2dc142cc4..1b418789e 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -1,3 +1,4 @@ +import type { BedrockProviderOptions } from '@ai-sdk/amazon-bedrock' import type { AnthropicProviderOptions } from '@ai-sdk/anthropic' import type { GoogleGenerativeAIProviderOptions } from '@ai-sdk/google' import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai' @@ -11,29 +12,26 @@ import { isSupportFlexServiceTierModel, isSupportVerbosityModel } from '@renderer/config/models' -import { isSupportServiceTierProvider } from '@renderer/config/providers' import { mapLanguageToQwenMTModel } from '@renderer/config/translate' import { getStoreSetting } from '@renderer/hooks/useSettings' -import type { RootState } from '@renderer/store' -import type { - Assistant, - GroqServiceTier, - GroqSystemProvider, - Model, - NotGroqProvider, - OpenAIServiceTier, - Provider, - ServiceTier -} from '@renderer/types' import { + type Assistant, + type GroqServiceTier, GroqServiceTiers, + type GroqSystemProvider, isGroqServiceTier, isGroqSystemProvider, isOpenAIServiceTier, isTranslateAssistant, - OpenAIServiceTiers + type Model, + type NotGroqProvider, + type OpenAIServiceTier, + OpenAIServiceTiers, + type Provider, + type ServiceTier } from '@renderer/types' import type { OpenAIVerbosity } from '@renderer/types/aiCoreTypes' +import { isSupportServiceTierProvider } from '@renderer/utils/provider' import type { JSONValue } from 'ai' import { t } from 'i18next' @@ -239,8 +237,7 @@ function buildOpenAIProviderOptions( serviceTier: OpenAIServiceTier ): OpenAIResponsesProviderOptions { const { enableReasoning } = capabilities - let providerOptions: Record = {} - + let providerOptions: OpenAIResponsesProviderOptions = {} // OpenAI 推理参数 if (enableReasoning) { const reasoningParams = getOpenAIReasoningParams(assistant, model) @@ -251,8 +248,8 @@ function buildOpenAIProviderOptions( } if (isSupportVerbosityModel(model)) { - const state: RootState = window.store?.getState() - const userVerbosity = state?.settings?.openAI?.verbosity + const openAI = getStoreSetting<'openAI'>('openAI') + const userVerbosity = openAI?.verbosity if (userVerbosity && ['low', 'medium', 'high'].includes(userVerbosity)) { const supportedVerbosity = getModelSupportedVerbosity(model) @@ -287,7 +284,7 @@ function buildAnthropicProviderOptions( } ): AnthropicProviderOptions { const { enableReasoning } = capabilities - let providerOptions: Record = {} + let providerOptions: AnthropicProviderOptions = {} // Anthropic 推理参数 if (enableReasoning) { @@ -314,7 +311,7 @@ function buildGeminiProviderOptions( } ): GoogleGenerativeAIProviderOptions { const { enableReasoning, enableGenerateImage } = capabilities - let providerOptions: Record = {} + let providerOptions: GoogleGenerativeAIProviderOptions = {} // Gemini 推理参数 if (enableReasoning) { @@ -393,9 +390,9 @@ function buildBedrockProviderOptions( enableWebSearch: boolean enableGenerateImage: boolean } -): Record { +): BedrockProviderOptions { const { enableReasoning } = capabilities - let providerOptions: Record = {} + let providerOptions: BedrockProviderOptions = {} if (enableReasoning) { const reasoningParams = getBedrockReasoningParams(assistant, model) diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 270f5aac7..6c882e9e8 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -33,13 +33,13 @@ import { isSupportedThinkingTokenZhipuModel, MODEL_SUPPORTED_REASONING_EFFORT } from '@renderer/config/models' -import { isSupportEnableThinkingProvider } from '@renderer/config/providers' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' import type { Assistant, Model } from '@renderer/types' import { EFFORT_RATIO, isSystemProvider, SystemProviderIds } from '@renderer/types' import type { OpenAISummaryText } from '@renderer/types/aiCoreTypes' import type { ReasoningEffortOptionalParams } from '@renderer/types/sdk' +import { isSupportEnableThinkingProvider } from '@renderer/utils/provider' import { toInteger } from 'lodash' const logger = loggerService.withContext('reasoning') @@ -131,7 +131,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin } // Specially for GPT-5.1. Suppose this is a OpenAI Compatible provider - if (isGPT51SeriesModel(model) && reasoningEffort === 'none') { + if (isGPT51SeriesModel(model)) { return { reasoningEffort: 'none' } diff --git a/src/renderer/src/config/__test__/reasoning.test.ts b/src/renderer/src/config/__test__/reasoning.test.ts deleted file mode 100644 index f702d33d1..000000000 --- a/src/renderer/src/config/__test__/reasoning.test.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - findTokenLimit, - isDoubaoSeedAfter251015, - isDoubaoThinkingAutoModel, - isGeminiReasoningModel, - isLingReasoningModel, - isSupportedThinkingTokenGeminiModel -} from '../models/reasoning' - -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ - llm: { - settings: {} - } - }) - } -})) - -// FIXME: Idk why it's imported. Maybe circular dependency somewhere -vi.mock('@renderer/services/AssistantService.ts', () => ({ - getDefaultAssistant: () => { - return { - id: 'default', - name: 'default', - emoji: '😀', - prompt: '', - topics: [], - messages: [], - type: 'assistant', - regularPhrases: [], - settings: {} - } - } -})) - -describe('Doubao Models', () => { - describe('isDoubaoThinkingAutoModel', () => { - it('should return false for invalid models', () => { - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1-6-251015', - name: 'doubao-seed-1-6-251015', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1-6-lite-251015', - name: 'doubao-seed-1-6-lite-251015', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1-6-thinking-250715', - name: 'doubao-seed-1-6-thinking-250715', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1-6-flash', - name: 'doubao-seed-1-6-flash', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1-6-thinking', - name: 'doubao-seed-1-6-thinking', - provider: '', - group: '' - }) - ).toBe(false) - }) - - it('should return true for valid models', () => { - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1-6-250615', - name: 'doubao-seed-1-6-250615', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isDoubaoThinkingAutoModel({ - id: 'Doubao-Seed-1.6', - name: 'Doubao-Seed-1.6', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-1-5-thinking-pro-m', - name: 'doubao-1-5-thinking-pro-m', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-seed-1.6-lite', - name: 'doubao-seed-1.6-lite', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isDoubaoThinkingAutoModel({ - id: 'doubao-1-5-thinking-pro-m-12345', - name: 'doubao-1-5-thinking-pro-m-12345', - provider: '', - group: '' - }) - ).toBe(true) - }) - }) - - describe('isDoubaoSeedAfter251015', () => { - it('should return true for models matching the pattern', () => { - expect( - isDoubaoSeedAfter251015({ - id: 'doubao-seed-1-6-251015', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isDoubaoSeedAfter251015({ - id: 'doubao-seed-1-6-lite-251015', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return false for models not matching the pattern', () => { - expect( - isDoubaoSeedAfter251015({ - id: 'doubao-seed-1-6-250615', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoSeedAfter251015({ - id: 'Doubao-Seed-1.6', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoSeedAfter251015({ - id: 'doubao-1-5-thinking-pro-m', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isDoubaoSeedAfter251015({ - id: 'doubao-seed-1-6-lite-251016', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - }) - }) -}) -describe('Ling Models', () => { - describe('isLingReasoningModel', () => { - it('should return false for ling variants', () => { - expect( - isLingReasoningModel({ - id: 'ling-1t', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isLingReasoningModel({ - id: 'ling-flash-2.0', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isLingReasoningModel({ - id: 'ling-mini-2.0', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - }) - - it('should return true for ring variants', () => { - expect( - isLingReasoningModel({ - id: 'ring-1t', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isLingReasoningModel({ - id: 'ring-flash-2.0', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isLingReasoningModel({ - id: 'ring-mini-2.0', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - }) -}) - -describe('Gemini Models', () => { - describe('isSupportedThinkingTokenGeminiModel', () => { - it('should return true for gemini 2.5 models', () => { - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-2.5-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-2.5-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-2.5-flash-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-2.5-pro-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini latest models', () => { - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-flash-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-pro-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-flash-lite-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini 3 models', () => { - // Preview versions - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-3-pro-preview', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'google/gemini-3-pro-preview', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - // Future stable versions - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-3-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-3-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'google/gemini-3-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'google/gemini-3-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return false for image and tts models', () => { - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-2.5-flash-image', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-2.5-flash-preview-tts', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - }) - - it('should return false for older gemini models', () => { - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-1.5-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-1.5-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isSupportedThinkingTokenGeminiModel({ - id: 'gemini-1.0-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - }) - }) - - describe('isGeminiReasoningModel', () => { - it('should return true for gemini thinking models', () => { - expect( - isGeminiReasoningModel({ - id: 'gemini-2.0-flash-thinking', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isGeminiReasoningModel({ - id: 'gemini-thinking-exp', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for supported thinking token gemini models', () => { - expect( - isGeminiReasoningModel({ - id: 'gemini-2.5-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isGeminiReasoningModel({ - id: 'gemini-2.5-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini-3 models', () => { - // Preview versions - expect( - isGeminiReasoningModel({ - id: 'gemini-3-pro-preview', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isGeminiReasoningModel({ - id: 'google/gemini-3-pro-preview', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - // Future stable versions - expect( - isGeminiReasoningModel({ - id: 'gemini-3-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isGeminiReasoningModel({ - id: 'gemini-3-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isGeminiReasoningModel({ - id: 'google/gemini-3-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isGeminiReasoningModel({ - id: 'google/gemini-3-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return false for older gemini models without thinking', () => { - expect( - isGeminiReasoningModel({ - id: 'gemini-1.5-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - expect( - isGeminiReasoningModel({ - id: 'gemini-1.5-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - }) - - it('should return false for undefined model', () => { - expect(isGeminiReasoningModel(undefined)).toBe(false) - }) - }) -}) - -describe('findTokenLimit', () => { - const cases: Array<{ modelId: string; expected: { min: number; max: number } }> = [ - { modelId: 'gemini-2.5-flash-lite-exp', expected: { min: 512, max: 24_576 } }, - { modelId: 'gemini-1.5-flash', expected: { min: 0, max: 24_576 } }, - { modelId: 'gemini-1.5-pro-001', expected: { min: 128, max: 32_768 } }, - { modelId: 'qwen3-235b-a22b-thinking-2507', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen3-30b-a3b-thinking-2507', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen3-vl-235b-a22b-thinking', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen3-vl-30b-a3b-thinking', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen-plus-2025-07-14', expected: { min: 0, max: 38_912 } }, - { modelId: 'qwen-plus-2025-04-28', expected: { min: 0, max: 38_912 } }, - { modelId: 'qwen3-1.7b', expected: { min: 0, max: 30_720 } }, - { modelId: 'qwen3-0.6b', expected: { min: 0, max: 30_720 } }, - { modelId: 'qwen-plus-ultra', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen-turbo-pro', expected: { min: 0, max: 38_912 } }, - { modelId: 'qwen-flash-lite', expected: { min: 0, max: 81_920 } }, - { modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } }, - { modelId: 'claude-3.7-sonnet-extended', expected: { min: 1_024, max: 64_000 } }, - { modelId: 'claude-sonnet-4.1', expected: { min: 1_024, max: 64_000 } }, - { modelId: 'claude-sonnet-4-5-20250929', expected: { min: 1_024, max: 64_000 } }, - { modelId: 'claude-opus-4-1-extended', expected: { min: 1_024, max: 32_000 } } - ] - - it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => { - expect(findTokenLimit(modelId)).toEqual(expected) - }) - - it('returns undefined for unknown models', () => { - expect(findTokenLimit('unknown-model')).toBeUndefined() - }) -}) diff --git a/src/renderer/src/config/__test__/vision.test.ts b/src/renderer/src/config/__test__/vision.test.ts deleted file mode 100644 index 79bcd629c..000000000 --- a/src/renderer/src/config/__test__/vision.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { isVisionModel } from '../models/vision' - -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ - llm: { - settings: {} - } - }) - } -})) - -// FIXME: Idk why it's imported. Maybe circular dependency somewhere -vi.mock('@renderer/services/AssistantService.ts', () => ({ - getDefaultAssistant: () => { - return { - id: 'default', - name: 'default', - emoji: '😀', - prompt: '', - topics: [], - messages: [], - type: 'assistant', - regularPhrases: [], - settings: {} - } - }, - getProviderByModel: () => null -})) - -describe('isVisionModel', () => { - describe('Gemini Models', () => { - it('should return true for gemini 1.5 models', () => { - expect( - isVisionModel({ - id: 'gemini-1.5-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-1.5-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini 2.x models', () => { - expect( - isVisionModel({ - id: 'gemini-2.0-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-2.0-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-2.5-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-2.5-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini latest models', () => { - expect( - isVisionModel({ - id: 'gemini-flash-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-pro-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-flash-lite-latest', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini 3 models', () => { - // Preview versions - expect( - isVisionModel({ - id: 'gemini-3-pro-preview', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - // Future stable versions - expect( - isVisionModel({ - id: 'gemini-3-flash', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - expect( - isVisionModel({ - id: 'gemini-3-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return true for gemini exp models', () => { - expect( - isVisionModel({ - id: 'gemini-exp-1206', - name: '', - provider: '', - group: '' - }) - ).toBe(true) - }) - - it('should return false for gemini 1.0 models', () => { - expect( - isVisionModel({ - id: 'gemini-1.0-pro', - name: '', - provider: '', - group: '' - }) - ).toBe(false) - }) - }) -}) diff --git a/src/renderer/src/config/__test__/websearch.test.ts b/src/renderer/src/config/__test__/websearch.test.ts deleted file mode 100644 index be18505a4..000000000 --- a/src/renderer/src/config/__test__/websearch.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { GEMINI_SEARCH_REGEX } from '../models/websearch' - -vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ - llm: { - settings: {} - } - }) - } -})) - -// FIXME: Idk why it's imported. Maybe circular dependency somewhere -vi.mock('@renderer/services/AssistantService.ts', () => ({ - getDefaultAssistant: () => { - return { - id: 'default', - name: 'default', - emoji: '😀', - prompt: '', - topics: [], - messages: [], - type: 'assistant', - regularPhrases: [], - settings: {} - } - }, - getProviderByModel: () => null -})) - -describe('Gemini Search Models', () => { - describe('GEMINI_SEARCH_REGEX', () => { - it('should match gemini 2.x models', () => { - expect(GEMINI_SEARCH_REGEX.test('gemini-2.0-flash')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-2.0-pro')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-flash')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-pro')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-flash-latest')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-pro-latest')).toBe(true) - }) - - it('should match gemini latest models', () => { - expect(GEMINI_SEARCH_REGEX.test('gemini-flash-latest')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-pro-latest')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-flash-lite-latest')).toBe(true) - }) - - it('should match gemini 3 models', () => { - // Preview versions - expect(GEMINI_SEARCH_REGEX.test('gemini-3-pro-preview')).toBe(true) - // Future stable versions - expect(GEMINI_SEARCH_REGEX.test('gemini-3-flash')).toBe(true) - expect(GEMINI_SEARCH_REGEX.test('gemini-3-pro')).toBe(true) - }) - - it('should not match older gemini models', () => { - expect(GEMINI_SEARCH_REGEX.test('gemini-1.5-flash')).toBe(false) - expect(GEMINI_SEARCH_REGEX.test('gemini-1.5-pro')).toBe(false) - expect(GEMINI_SEARCH_REGEX.test('gemini-1.0-pro')).toBe(false) - }) - }) -}) diff --git a/src/renderer/src/config/models/__tests__/embedding.test.ts b/src/renderer/src/config/models/__tests__/embedding.test.ts new file mode 100644 index 000000000..90db11142 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/embedding.test.ts @@ -0,0 +1,101 @@ +import type { Model } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +vi.mock('@renderer/store', () => ({ + __esModule: true, + default: { + getState: () => ({ + llm: { providers: [] }, + settings: {} + }) + }, + useAppDispatch: vi.fn(), + useAppSelector: vi.fn() +})) + +vi.mock('@renderer/store/settings', () => { + const noop = vi.fn() + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'initialState') { + return {} + } + return noop + } + } + ) +}) + +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({})), + useNavbarPosition: vi.fn(() => ({ navbarPosition: 'left' })), + useMessageStyle: vi.fn(() => ({ isBubbleStyle: false })), + getStoreSetting: vi.fn() +})) + +import { isEmbeddingModel, isRerankModel } from '../embedding' + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'test-model', + name: 'Test Model', + provider: 'openai', + group: 'Test', + ...overrides +}) + +describe('isEmbeddingModel', () => { + it('returns true for ids that match the embedding regex', () => { + expect(isEmbeddingModel(createModel({ id: 'Text-Embedding-3-Small' }))).toBe(true) + }) + + it('returns false for rerank models even if they match embedding patterns', () => { + const model = createModel({ id: 'rerank-qa', name: 'rerank-qa' }) + expect(isRerankModel(model)).toBe(true) + expect(isEmbeddingModel(model)).toBe(false) + }) + + it('honors user overrides for embedding capability', () => { + const model = createModel({ + id: 'text-embedding-3-small', + capabilities: [{ type: 'embedding', isUserSelected: false }] + }) + expect(isEmbeddingModel(model)).toBe(false) + }) + + it('uses the model name when provider is doubao', () => { + const model = createModel({ + id: 'custom-id', + name: 'BGE-Large-zh-v1.5', + provider: 'doubao' + }) + expect(isEmbeddingModel(model)).toBe(true) + }) + + it('returns false for anthropic provider models', () => { + const model = createModel({ + id: 'text-embedding-ada-002', + provider: 'anthropic' + }) + expect(isEmbeddingModel(model)).toBe(false) + }) +}) + +describe('isRerankModel', () => { + it('identifies ids that match rerank regex', () => { + expect(isRerankModel(createModel({ id: 'jina-rerank-v2-base' }))).toBe(true) + }) + + it('honors user overrides for rerank capability', () => { + const model = createModel({ + id: 'jina-rerank-v2-base', + capabilities: [{ type: 'rerank', isUserSelected: false }] + }) + expect(isRerankModel(model)).toBe(false) + }) +}) diff --git a/src/renderer/src/config/__test__/models.test.ts b/src/renderer/src/config/models/__tests__/models.test.ts similarity index 74% rename from src/renderer/src/config/__test__/models.test.ts rename to src/renderer/src/config/models/__tests__/models.test.ts index d55a3b9dd..618a31d88 100644 --- a/src/renderer/src/config/__test__/models.test.ts +++ b/src/renderer/src/config/models/__tests__/models.test.ts @@ -3,31 +3,54 @@ import { isPureGenerateImageModel, isQwenReasoningModel, isSupportedThinkingTokenQwenModel, - isVisionModel, - isWebSearchModel + isVisionModel } from '@renderer/config/models' import type { Model } from '@renderer/types' import { beforeEach, describe, expect, test, vi } from 'vitest' +vi.mock('@renderer/store/llm', () => ({ + initialState: {} +})) + +vi.mock('@renderer/store', () => ({ + default: { + getState: () => ({ + llm: { + settings: {} + } + }) + } +})) + +const getProviderByModelMock = vi.fn() +const isEmbeddingModelMock = vi.fn() +const isRerankModelMock = vi.fn() + +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: (...args: any[]) => getProviderByModelMock(...args), + getAssistantSettings: vi.fn(), + getDefaultAssistant: vi.fn().mockReturnValue({ + id: 'default', + name: 'Default Assistant', + prompt: '', + settings: {} + }) +})) + +vi.mock('@renderer/config/models/embedding', () => ({ + isEmbeddingModel: (...args: any[]) => isEmbeddingModelMock(...args), + isRerankModel: (...args: any[]) => isRerankModelMock(...args) +})) + +beforeEach(() => { + vi.clearAllMocks() + getProviderByModelMock.mockReturnValue({ type: 'openai-response' } as any) + isEmbeddingModelMock.mockReturnValue(false) + isRerankModelMock.mockReturnValue(false) +}) + // Suggested test cases describe('Qwen Model Detection', () => { - beforeEach(() => { - vi.mock('@renderer/store/llm', () => ({ - initialState: {} - })) - vi.mock('@renderer/services/AssistantService', () => ({ - getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' }) - })) - vi.mock('@renderer/store', () => ({ - default: { - getState: () => ({ - llm: { - settings: {} - } - }) - } - })) - }) test('isQwenReasoningModel', () => { expect(isQwenReasoningModel({ id: 'qwen3-thinking' } as Model)).toBe(true) expect(isQwenReasoningModel({ id: 'qwen3-instruct' } as Model)).toBe(false) @@ -56,14 +79,6 @@ describe('Qwen Model Detection', () => { }) describe('Vision Model Detection', () => { - beforeEach(() => { - vi.mock('@renderer/store/llm', () => ({ - initialState: {} - })) - vi.mock('@renderer/services/AssistantService', () => ({ - getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' }) - })) - }) test('isVisionModel', () => { expect(isVisionModel({ id: 'qwen-vl-max' } as Model)).toBe(true) expect(isVisionModel({ id: 'qwen-omni-turbo' } as Model)).toBe(true) @@ -83,17 +98,3 @@ describe('Vision Model Detection', () => { expect(isPureGenerateImageModel({ id: 'gpt-4o' } as Model)).toBe(false) }) }) - -describe('Web Search Model Detection', () => { - beforeEach(() => { - vi.mock('@renderer/store/llm', () => ({ - initialState: {} - })) - vi.mock('@renderer/services/AssistantService', () => ({ - getProviderByModel: vi.fn().mockReturnValue({ id: 'cherryai' }) - })) - }) - test('isWebSearchModel', () => { - expect(isWebSearchModel({ id: 'grok-2-image-latest' } as Model)).toBe(false) - }) -}) diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts new file mode 100644 index 000000000..8a1224260 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -0,0 +1,1125 @@ +import type { Model } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { isEmbeddingModel, isRerankModel } from '../embedding' +import { isOpenAIReasoningModel, isSupportedReasoningEffortOpenAIModel } from '../openai' +import { + findTokenLimit, + getThinkModelType, + isClaude4SeriesModel, + isClaude45ReasoningModel, + isClaudeReasoningModel, + isDeepSeekHybridInferenceModel, + isDoubaoSeedAfter251015, + isDoubaoThinkingAutoModel, + isGeminiReasoningModel, + isGrok4FastReasoningModel, + isHunyuanReasoningModel, + isLingReasoningModel, + isMiniMaxReasoningModel, + isPerplexityReasoningModel, + isQwenAlwaysThinkModel, + isReasoningModel, + isStepReasoningModel, + isSupportedReasoningEffortGrokModel, + isSupportedReasoningEffortModel, + isSupportedReasoningEffortPerplexityModel, + isSupportedThinkingTokenDoubaoModel, + isSupportedThinkingTokenGeminiModel, + isSupportedThinkingTokenModel, + isSupportedThinkingTokenQwenModel, + isSupportedThinkingTokenZhipuModel, + isZhipuReasoningModel, + MODEL_SUPPORTED_OPTIONS, + MODEL_SUPPORTED_REASONING_EFFORT +} from '../reasoning' +import { isTextToImageModel } from '../vision' + +vi.mock('@renderer/store', () => ({ + default: { + getState: () => ({ + llm: { + settings: {} + } + }) + } +})) + +// FIXME: Idk why it's imported. Maybe circular dependency somewhere +vi.mock('@renderer/services/AssistantService.ts', () => ({ + getDefaultAssistant: () => { + return { + id: 'default', + name: 'default', + emoji: '😀', + prompt: '', + topics: [], + messages: [], + type: 'assistant', + regularPhrases: [], + settings: {} + } + } +})) + +vi.mock('../embedding', () => ({ + isEmbeddingModel: vi.fn(), + isRerankModel: vi.fn() +})) + +vi.mock('../vision', () => ({ + isTextToImageModel: vi.fn() +})) + +describe('Doubao Models', () => { + describe('isDoubaoThinkingAutoModel', () => { + it('should return false for invalid models', () => { + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-251015', + name: 'doubao-seed-1-6-251015', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-lite-251015', + name: 'doubao-seed-1-6-lite-251015', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-thinking-250715', + name: 'doubao-seed-1-6-thinking-250715', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-flash', + name: 'doubao-seed-1-6-flash', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-thinking', + name: 'doubao-seed-1-6-thinking', + provider: '', + group: '' + }) + ).toBe(false) + }) + + it('should return true for valid models', () => { + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1-6-250615', + name: 'doubao-seed-1-6-250615', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'Doubao-Seed-1.6', + name: 'Doubao-Seed-1.6', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-1-5-thinking-pro-m', + name: 'doubao-1-5-thinking-pro-m', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-seed-1.6-lite', + name: 'doubao-seed-1.6-lite', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoThinkingAutoModel({ + id: 'doubao-1-5-thinking-pro-m-12345', + name: 'doubao-1-5-thinking-pro-m-12345', + provider: '', + group: '' + }) + ).toBe(true) + }) + }) + + describe('isDoubaoSeedAfter251015', () => { + it('should return true for models matching the pattern', () => { + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-251015', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-lite-251015', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return false for models not matching the pattern', () => { + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-250615', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoSeedAfter251015({ + id: 'Doubao-Seed-1.6', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-1-5-thinking-pro-m', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isDoubaoSeedAfter251015({ + id: 'doubao-seed-1-6-lite-251016', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + }) +}) + +describe('Doubao Thinking Support', () => { + it('detects thinking token support by id or name', () => { + expect(isSupportedThinkingTokenDoubaoModel(createModel({ id: 'doubao-seed-1.6-flash' }))).toBe(true) + expect( + isSupportedThinkingTokenDoubaoModel(createModel({ id: 'custom', name: 'Doubao-1-5-Thinking-Pro-M-Extra' })) + ).toBe(true) + expect(isSupportedThinkingTokenDoubaoModel(undefined)).toBe(false) + expect(isSupportedThinkingTokenDoubaoModel(createModel({ id: 'doubao-standard' }))).toBe(false) + }) +}) + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'test-model', + name: 'Test Model', + provider: 'openai', + group: 'Test', + ...overrides +}) + +const embeddingMock = vi.mocked(isEmbeddingModel) +const rerankMock = vi.mocked(isRerankModel) +const textToImageMock = vi.mocked(isTextToImageModel) + +beforeEach(() => { + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValue(false) + textToImageMock.mockReturnValue(false) +}) +describe('Ling Models', () => { + describe('isLingReasoningModel', () => { + it('should return false for ling variants', () => { + expect( + isLingReasoningModel({ + id: 'ling-1t', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isLingReasoningModel({ + id: 'ling-flash-2.0', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isLingReasoningModel({ + id: 'ling-mini-2.0', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + + it('should return true for ring variants', () => { + expect( + isLingReasoningModel({ + id: 'ring-1t', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isLingReasoningModel({ + id: 'ring-flash-2.0', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isLingReasoningModel({ + id: 'ring-mini-2.0', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + }) +}) + +describe('Claude & regional providers', () => { + it('identifies claude 4.5 variants', () => { + expect(isClaude45ReasoningModel(createModel({ id: 'claude-sonnet-4.5-preview' }))).toBe(true) + expect(isClaude45ReasoningModel(createModel({ id: 'claude-3-sonnet' }))).toBe(false) + }) + + it('identifies claude 4 variants', () => { + expect(isClaude4SeriesModel(createModel({ id: 'claude-opus-4' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'claude-4.2-sonnet-variant' }))).toBe(false) + expect(isClaude4SeriesModel(createModel({ id: 'claude-3-haiku' }))).toBe(false) + }) + + it('detects general claude reasoning support', () => { + expect(isClaudeReasoningModel(createModel({ id: 'claude-3.7-sonnet' }))).toBe(true) + expect(isClaudeReasoningModel(createModel({ id: 'claude-3-haiku' }))).toBe(false) + }) + + it('covers hunyuan reasoning heuristics', () => { + expect(isHunyuanReasoningModel(createModel({ id: 'hunyuan-a13b', provider: 'hunyuan' }))).toBe(true) + expect(isHunyuanReasoningModel(createModel({ id: 'hunyuan-lite', provider: 'hunyuan' }))).toBe(false) + }) + + it('covers perplexity reasoning detectors', () => { + expect(isPerplexityReasoningModel(createModel({ id: 'sonar-deep-research', provider: 'perplexity' }))).toBe(true) + expect(isSupportedReasoningEffortPerplexityModel(createModel({ id: 'sonar-deep-research' }))).toBe(true) + expect(isPerplexityReasoningModel(createModel({ id: 'sonar-lite' }))).toBe(false) + }) + + it('covers zhipu/minimax/step specific classifiers', () => { + expect(isSupportedThinkingTokenZhipuModel(createModel({ id: 'glm-4.6-pro' }))).toBe(true) + expect(isZhipuReasoningModel(createModel({ id: 'glm-z1' }))).toBe(true) + expect(isStepReasoningModel(createModel({ id: 'step-r1-v-mini' }))).toBe(true) + expect(isMiniMaxReasoningModel(createModel({ id: 'minimax-m2-pro' }))).toBe(true) + }) +}) + +describe('DeepSeek & Thinking Tokens', () => { + it('detects deepseek hybrid inference patterns and allowed providers', () => { + expect( + isDeepSeekHybridInferenceModel( + createModel({ + id: 'deepseek-v3.1-alpha', + provider: 'openrouter' + }) + ) + ).toBe(true) + expect(isDeepSeekHybridInferenceModel(createModel({ id: 'deepseek-v2' }))).toBe(false) + + const allowed = createModel({ id: 'deepseek-v3.1', provider: 'doubao' }) + expect(isSupportedThinkingTokenModel(allowed)).toBe(true) + + const disallowed = createModel({ id: 'deepseek-v3.1', provider: 'unknown' }) + expect(isSupportedThinkingTokenModel(disallowed)).toBe(false) + }) + + it('supports Gemini thinking models while filtering image variants', () => { + expect(isSupportedThinkingTokenModel(createModel({ id: 'gemini-2.5-flash-latest' }))).toBe(true) + expect(isSupportedThinkingTokenModel(createModel({ id: 'gemini-2.5-flash-image' }))).toBe(false) + }) +}) + +describe('Qwen & Gemini thinking coverage', () => { + it.each([ + 'qwen-plus', + 'qwen-plus-2025-07-14', + 'qwen-plus-2025-09-11', + 'qwen-turbo', + 'qwen-turbo-2025-04-28', + 'qwen-flash', + 'qwen3-8b', + 'qwen3-72b' + ])('supports thinking tokens for %s', (id) => { + expect(isSupportedThinkingTokenQwenModel(createModel({ id }))).toBe(true) + }) + + it.each(['qwen3-thinking', 'qwen3-instruct', 'qwen3-max', 'qwen3-vl-thinking'])( + 'blocks thinking tokens for %s', + (id) => { + expect(isSupportedThinkingTokenQwenModel(createModel({ id }))).toBe(false) + } + ) + + it.each(['qwen3-thinking', 'qwen3-vl-235b-thinking'])('always thinks for %s', (id) => { + expect(isQwenAlwaysThinkModel(createModel({ id }))).toBe(true) + }) + + it.each(['gemini-2.5-flash-latest', 'gemini-pro-latest', 'gemini-flash-lite-latest'])( + 'Gemini supports thinking tokens for %s', + (id) => { + expect(isSupportedThinkingTokenGeminiModel(createModel({ id }))).toBe(true) + } + ) + + it.each(['gemini-2.5-flash-image', 'gemini-2.0-tts', 'custom-model'])('Gemini excludes %s', (id) => { + expect(isSupportedThinkingTokenGeminiModel(createModel({ id }))).toBe(false) + }) +}) + +describe('GPT-5.1 Series Models', () => { + describe('getThinkModelType', () => { + it('should return gpt5_1 for GPT-5.1 models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5.1' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-preview' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-mini' }))).toBe('gpt5_1') + }) + + it('should return gpt5_1_codex for GPT-5.1 codex models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5.1-codex' }))).toBe('gpt5_1_codex') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-codex-mini' }))).toBe('gpt5_1_codex') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-codex-preview' }))).toBe('gpt5_1_codex') + }) + + it('should not misclassify GPT-5.1 chat models as reasoning', () => { + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1-chat' }))).toBe(false) + }) + }) + + describe('isSupportedReasoningEffortOpenAIModel', () => { + it('should support GPT-5.1 reasoning models', () => { + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1' }))).toBe(true) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1-codex' }))).toBe(true) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1-codex-mini' }))).toBe(true) + }) + + it('should not support GPT-5.1 chat models', () => { + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1-chat' }))).toBe(false) + }) + }) + + describe('isOpenAIReasoningModel', () => { + it('should recognize GPT-5.1 series as reasoning models', () => { + expect(isOpenAIReasoningModel(createModel({ id: 'gpt-5.1' }))).toBe(true) + expect(isOpenAIReasoningModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) + expect(isOpenAIReasoningModel(createModel({ id: 'gpt-5.1-codex' }))).toBe(true) + expect(isOpenAIReasoningModel(createModel({ id: 'gpt-5.1-codex-mini' }))).toBe(true) + }) + }) + + describe('isReasoningModel', () => { + it('should classify GPT-5.1 models as reasoning models', () => { + expect(isReasoningModel(createModel({ id: 'gpt-5.1' }))).toBe(true) + expect(isReasoningModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) + expect(isReasoningModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true) + expect(isReasoningModel(createModel({ id: 'gpt-5.1-codex' }))).toBe(true) + expect(isReasoningModel(createModel({ id: 'gpt-5.1-codex-mini' }))).toBe(true) + }) + + it('should not classify GPT-5.1 chat models as reasoning models', () => { + expect(isReasoningModel(createModel({ id: 'gpt-5.1-chat' }))).toBe(false) + }) + }) +}) + +describe('Reasoning effort helpers', () => { + it('evaluates OpenAI-specific reasoning toggles', () => { + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'o3-mini' }))).toBe(true) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'o1-mini' }))).toBe(false) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-oss-reasoning' }))).toBe(true) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) + expect(isSupportedReasoningEffortOpenAIModel(createModel({ id: 'gpt-5.1' }))).toBe(true) + }) + + it('detects OpenAI reasoning models even when not supported by effort helper', () => { + expect(isOpenAIReasoningModel(createModel({ id: 'o1-preview' }))).toBe(true) + expect(isOpenAIReasoningModel(createModel({ id: 'custom-model' }))).toBe(false) + }) + + it('aggregates other reasoning effort families', () => { + expect(isSupportedReasoningEffortModel(createModel({ id: 'o3' }))).toBe(true) + expect(isSupportedReasoningEffortModel(createModel({ id: 'grok-3-mini' }))).toBe(true) + expect(isSupportedReasoningEffortModel(createModel({ id: 'sonar-deep-research', provider: 'perplexity' }))).toBe( + true + ) + expect(isSupportedReasoningEffortModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + + it('flags grok specific helpers correctly', () => { + expect(isSupportedReasoningEffortGrokModel(createModel({ id: 'grok-3-mini' }))).toBe(true) + expect( + isSupportedReasoningEffortGrokModel(createModel({ id: 'grok-4-fast-openrouter', provider: 'openrouter' })) + ).toBe(true) + expect(isSupportedReasoningEffortGrokModel(createModel({ id: 'grok-4' }))).toBe(false) + + expect(isGrok4FastReasoningModel(createModel({ id: 'grok-4-fast' }))).toBe(true) + expect(isGrok4FastReasoningModel(createModel({ id: 'grok-4-fast-non-reasoning' }))).toBe(false) + }) +}) + +describe('isReasoningModel', () => { + it('returns false for embedding/rerank/text-to-image models', () => { + embeddingMock.mockReturnValueOnce(true) + expect(isReasoningModel(createModel())).toBe(false) + + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValueOnce(true) + expect(isReasoningModel(createModel())).toBe(false) + + rerankMock.mockReturnValue(false) + textToImageMock.mockReturnValueOnce(true) + expect(isReasoningModel(createModel())).toBe(false) + }) + + it('respects manual overrides', () => { + const forced = createModel({ + capabilities: [{ type: 'reasoning', isUserSelected: true }] + }) + expect(isReasoningModel(forced)).toBe(true) + + const disabled = createModel({ + capabilities: [{ type: 'reasoning', isUserSelected: false }] + }) + expect(isReasoningModel(disabled)).toBe(false) + }) + + it('handles doubao-specific and generic matches', () => { + const doubao = createModel({ + id: 'doubao-seed-1-6-thinking', + provider: 'doubao', + name: 'doubao-seed-1-6-thinking' + }) + expect(isReasoningModel(doubao)).toBe(true) + + const magistral = createModel({ id: 'magistral-reasoning' }) + expect(isReasoningModel(magistral)).toBe(true) + }) +}) + +describe('Thinking model classification', () => { + it('maps gpt-5 codex and name-based fallbacks', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5-codex' }))).toBe('gpt5_codex') + expect( + getThinkModelType( + createModel({ + id: 'custom-id', + name: 'Grok-4-fast Reasoning' + }) + ) + ).toBe('grok4_fast') + }) +}) + +describe('Reasoning option configuration', () => { + it('allows GPT-5.1 series models to disable reasoning', () => { + expect(MODEL_SUPPORTED_OPTIONS.gpt5_1).toContain('none') + expect(MODEL_SUPPORTED_OPTIONS.gpt5_1_codex).toContain('none') + }) + + it('restricts GPT-5 Pro reasoning to high effort only', () => { + expect(MODEL_SUPPORTED_REASONING_EFFORT.gpt5pro).toEqual(['high']) + expect(MODEL_SUPPORTED_OPTIONS.gpt5pro).toEqual(['high']) + }) +}) + +describe('getThinkModelType - Comprehensive Coverage', () => { + describe('OpenAI Deep Research models', () => { + it('should return openai_deep_research for deep research models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-4o-deep-research' }))).toBe('openai_deep_research') + expect(getThinkModelType(createModel({ id: 'gpt-4o-deep-research-preview' }))).toBe('openai_deep_research') + }) + }) + + describe('GPT-5.1 series models', () => { + it('should return gpt5_1_codex for GPT-5.1 codex models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5.1-codex' }))).toBe('gpt5_1_codex') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-codex-mini' }))).toBe('gpt5_1_codex') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-codex-preview' }))).toBe('gpt5_1_codex') + }) + + it('should return gpt5_1 for non-codex GPT-5.1 models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5.1' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-preview' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'gpt-5.1-mini' }))).toBe('gpt5_1') + }) + }) + + describe('GPT-5 series models', () => { + it('should return gpt5_codex for GPT-5 codex models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5-codex' }))).toBe('gpt5_codex') + expect(getThinkModelType(createModel({ id: 'gpt-5-codex-mini' }))).toBe('gpt5_codex') + }) + + it('should return gpt5 for non-codex GPT-5 models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5' }))).toBe('gpt5') + expect(getThinkModelType(createModel({ id: 'gpt-5-preview' }))).toBe('gpt5') + }) + + it('should return gpt5pro for GPT-5 Pro models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5-pro' }))).toBe('gpt5pro') + expect(getThinkModelType(createModel({ id: 'gpt-5-pro-preview' }))).toBe('gpt5pro') + }) + }) + + describe('OpenAI O-series models', () => { + it('should return o for supported reasoning effort OpenAI models', () => { + expect(getThinkModelType(createModel({ id: 'o3' }))).toBe('o') + expect(getThinkModelType(createModel({ id: 'o3-mini' }))).toBe('o') + expect(getThinkModelType(createModel({ id: 'o4' }))).toBe('o') + expect(getThinkModelType(createModel({ id: 'gpt-oss-reasoning' }))).toBe('o') + }) + }) + + describe('Grok models', () => { + it('should return grok4_fast for Grok 4 Fast models', () => { + expect(getThinkModelType(createModel({ id: 'grok-4-fast' }))).toBe('grok4_fast') + expect(getThinkModelType(createModel({ id: 'grok-4-fast-preview' }))).toBe('grok4_fast') + }) + + it('should return grok for other supported Grok models', () => { + expect(getThinkModelType(createModel({ id: 'grok-3-mini' }))).toBe('grok') + }) + }) + + describe('Gemini models', () => { + it('should return gemini for Flash models', () => { + expect(getThinkModelType(createModel({ id: 'gemini-2.5-flash-latest' }))).toBe('gemini') + expect(getThinkModelType(createModel({ id: 'gemini-flash-latest' }))).toBe('gemini') + expect(getThinkModelType(createModel({ id: 'gemini-flash-lite-latest' }))).toBe('gemini') + }) + + it('should return gemini_pro for Pro models', () => { + expect(getThinkModelType(createModel({ id: 'gemini-2.5-pro-latest' }))).toBe('gemini_pro') + expect(getThinkModelType(createModel({ id: 'gemini-pro-latest' }))).toBe('gemini_pro') + }) + }) + + describe('Qwen models', () => { + it('should return qwen for supported Qwen models with thinking control', () => { + expect(getThinkModelType(createModel({ id: 'qwen-plus' }))).toBe('qwen') + expect(getThinkModelType(createModel({ id: 'qwen-turbo' }))).toBe('qwen') + expect(getThinkModelType(createModel({ id: 'qwen-flash' }))).toBe('qwen') + expect(getThinkModelType(createModel({ id: 'qwen3-8b' }))).toBe('qwen') + }) + + it('should return default for always-thinking Qwen models (not controllable)', () => { + // qwen3-thinking and qwen3-vl-thinking always think and don't support thinking token control + expect(getThinkModelType(createModel({ id: 'qwen3-thinking' }))).toBe('default') + expect(getThinkModelType(createModel({ id: 'qwen3-vl-235b-thinking' }))).toBe('default') + }) + }) + + describe('Doubao models', () => { + it('should return doubao for auto-thinking Doubao models', () => { + expect(getThinkModelType(createModel({ id: 'doubao-seed-1.6' }))).toBe('doubao') + expect(getThinkModelType(createModel({ id: 'doubao-1-5-thinking-pro-m' }))).toBe('doubao') + }) + + it('should return doubao_after_251015 for seed models after 251015', () => { + expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6-251015' }))).toBe('doubao_after_251015') + expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6-lite-251015' }))).toBe('doubao_after_251015') + }) + + it('should return doubao_no_auto for other Doubao thinking models', () => { + expect(getThinkModelType(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toBe('doubao_no_auto') + }) + }) + + describe('Hunyuan models', () => { + it('should return hunyuan for supported Hunyuan models', () => { + expect(getThinkModelType(createModel({ id: 'hunyuan-a13b' }))).toBe('hunyuan') + }) + }) + + describe('Perplexity models', () => { + it('should return perplexity for supported Perplexity models', () => { + expect(getThinkModelType(createModel({ id: 'sonar-pro', provider: 'perplexity' }))).toBe('default') + }) + + it('should return openai_deep_research for sonar-deep-research (matches deep-research regex)', () => { + // Note: sonar-deep-research is caught by isOpenAIDeepResearchModel first + expect(getThinkModelType(createModel({ id: 'sonar-deep-research' }))).toBe('openai_deep_research') + }) + }) + + describe('Zhipu models', () => { + it('should return zhipu for supported Zhipu models', () => { + expect(getThinkModelType(createModel({ id: 'glm-4.5' }))).toBe('zhipu') + expect(getThinkModelType(createModel({ id: 'glm-4.6' }))).toBe('zhipu') + }) + }) + + describe('DeepSeek models', () => { + it('should return deepseek_hybrid for DeepSeek V3.1 models', () => { + expect(getThinkModelType(createModel({ id: 'deepseek-v3.1' }))).toBe('deepseek_hybrid') + expect(getThinkModelType(createModel({ id: 'deepseek-v3.1-alpha' }))).toBe('deepseek_hybrid') + expect(getThinkModelType(createModel({ id: 'deepseek-chat-v3.1' }))).toBe('deepseek_hybrid') + }) + }) + + describe('Default case', () => { + it('should return default for unsupported models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-4o' }))).toBe('default') + expect(getThinkModelType(createModel({ id: 'claude-3-opus' }))).toBe('default') + expect(getThinkModelType(createModel({ id: 'unknown-model' }))).toBe('default') + }) + }) + + describe('Name-based fallback', () => { + it('should fall back to name when id does not match', () => { + expect( + getThinkModelType( + createModel({ + id: 'custom-id', + name: 'grok-4-fast' + }) + ) + ).toBe('grok4_fast') + + expect( + getThinkModelType( + createModel({ + id: 'custom-id', + name: 'gpt-5.1-codex' + }) + ) + ).toBe('gpt5_1_codex') + + expect( + getThinkModelType( + createModel({ + id: 'custom-id', + name: 'gemini-2.5-flash-latest' + }) + ) + ).toBe('gemini') + }) + + it('should use id result when id matches', () => { + expect( + getThinkModelType( + createModel({ + id: 'gpt-5.1', + name: 'Different Name' + }) + ) + ).toBe('gpt5_1') + }) + }) + + describe('Edge cases and priority', () => { + it('should prioritize openai_deep_research over other matches', () => { + // deep-research regex is checked first + expect(getThinkModelType(createModel({ id: 'gpt-4o-deep-research', provider: 'openai' }))).toBe( + 'openai_deep_research' + ) + }) + + it('should handle case insensitivity correctly', () => { + expect(getThinkModelType(createModel({ id: 'GPT-5.1' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'Gemini-2.5-Flash-Latest' }))).toBe('gemini') + expect(getThinkModelType(createModel({ id: 'DeepSeek-V3.1' }))).toBe('deepseek_hybrid') + }) + + it('should handle special characters and separators', () => { + expect(getThinkModelType(createModel({ id: 'doubao-seed-1.6' }))).toBe('doubao') + expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6' }))).toBe('doubao') + expect(getThinkModelType(createModel({ id: 'gpt-5.1' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'deepseek-v3.1' }))).toBe('deepseek_hybrid') + expect(getThinkModelType(createModel({ id: 'deepseek-v3-1' }))).toBe('deepseek_hybrid') + }) + + it('should return default for empty or null-like inputs', () => { + expect(getThinkModelType(createModel({ id: '' }))).toBe('default') + expect(getThinkModelType(createModel({ id: 'unknown' }))).toBe('default') + }) + + it('should handle models with version suffixes', () => { + expect(getThinkModelType(createModel({ id: 'gpt-5-preview-2024' }))).toBe('gpt5') + expect(getThinkModelType(createModel({ id: 'o3-mini-2024' }))).toBe('o') + expect(getThinkModelType(createModel({ id: 'gemini-2.5-flash-latest-001' }))).toBe('gemini') + }) + + it('should prioritize GPT-5.1 over GPT-5 detection', () => { + // GPT-5.1 should be detected before GPT-5 + expect(getThinkModelType(createModel({ id: 'gpt-5.1-anything' }))).toBe('gpt5_1') + expect(getThinkModelType(createModel({ id: 'gpt-5-anything' }))).toBe('gpt5') + }) + + it('should handle Doubao priority correctly', () => { + // auto > after_251015 > no_auto + expect(getThinkModelType(createModel({ id: 'doubao-seed-1.6' }))).toBe('doubao') + expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6-251015' }))).toBe('doubao_after_251015') + expect(getThinkModelType(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toBe('doubao_no_auto') + }) + + it('should handle Qwen thinking detection correctly', () => { + // qwen3-thinking models don't support thinking control (not in isSupportedThinkingTokenQwenModel) + expect(getThinkModelType(createModel({ id: 'qwen3-thinking' }))).toBe('default') + // but qwen-plus supports thinking control + expect(getThinkModelType(createModel({ id: 'qwen-plus' }))).toBe('qwen') + }) + }) +}) + +describe('Token limit lookup', () => { + it.each([ + ['gemini-2.5-flash-lite-latest', { min: 512, max: 24576 }], + ['qwen-plus-2025-07-14', { min: 0, max: 38912 }], + ['claude-haiku-4', { min: 1024, max: 64000 }] + ])('returns configured min/max pairs for %s', (id, expected) => { + expect(findTokenLimit(id)).toEqual(expected) + }) + + it('returns undefined when regex misses', () => { + expect(findTokenLimit('unknown-model')).toBeUndefined() + }) +}) + +describe('Gemini Models', () => { + describe('isSupportedThinkingTokenGeminiModel', () => { + it('should return true for gemini 2.5 models', () => { + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-2.5-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-2.5-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-2.5-flash-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-2.5-pro-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini latest models', () => { + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-flash-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-pro-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-flash-lite-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini 3 models', () => { + // Preview versions + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-3-pro-preview', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'google/gemini-3-pro-preview', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + // Future stable versions + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-3-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-3-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'google/gemini-3-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'google/gemini-3-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return false for image and tts models', () => { + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-2.5-flash-image', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-2.5-flash-preview-tts', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + + it('should return false for older gemini models', () => { + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-1.5-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-1.5-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isSupportedThinkingTokenGeminiModel({ + id: 'gemini-1.0-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + }) + + describe('isGeminiReasoningModel', () => { + it('should return true for gemini thinking models', () => { + expect( + isGeminiReasoningModel({ + id: 'gemini-2.0-flash-thinking', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isGeminiReasoningModel({ + id: 'gemini-thinking-exp', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for supported thinking token gemini models', () => { + expect( + isGeminiReasoningModel({ + id: 'gemini-2.5-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isGeminiReasoningModel({ + id: 'gemini-2.5-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini-3 models', () => { + // Preview versions + expect( + isGeminiReasoningModel({ + id: 'gemini-3-pro-preview', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isGeminiReasoningModel({ + id: 'google/gemini-3-pro-preview', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + // Future stable versions + expect( + isGeminiReasoningModel({ + id: 'gemini-3-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isGeminiReasoningModel({ + id: 'gemini-3-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isGeminiReasoningModel({ + id: 'google/gemini-3-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isGeminiReasoningModel({ + id: 'google/gemini-3-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return false for older gemini models without thinking', () => { + expect( + isGeminiReasoningModel({ + id: 'gemini-1.5-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + expect( + isGeminiReasoningModel({ + id: 'gemini-1.5-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + + it('should return false for undefined model', () => { + expect(isGeminiReasoningModel(undefined)).toBe(false) + }) + }) +}) + +describe('findTokenLimit', () => { + const cases: Array<{ modelId: string; expected: { min: number; max: number } }> = [ + { modelId: 'gemini-2.5-flash-lite-exp', expected: { min: 512, max: 24_576 } }, + { modelId: 'gemini-1.5-flash', expected: { min: 0, max: 24_576 } }, + { modelId: 'gemini-1.5-pro-001', expected: { min: 128, max: 32_768 } }, + { modelId: 'qwen3-235b-a22b-thinking-2507', expected: { min: 0, max: 81_920 } }, + { modelId: 'qwen3-30b-a3b-thinking-2507', expected: { min: 0, max: 81_920 } }, + { modelId: 'qwen3-vl-235b-a22b-thinking', expected: { min: 0, max: 81_920 } }, + { modelId: 'qwen3-vl-30b-a3b-thinking', expected: { min: 0, max: 81_920 } }, + { modelId: 'qwen-plus-2025-07-14', expected: { min: 0, max: 38_912 } }, + { modelId: 'qwen-plus-2025-04-28', expected: { min: 0, max: 38_912 } }, + { modelId: 'qwen3-1.7b', expected: { min: 0, max: 30_720 } }, + { modelId: 'qwen3-0.6b', expected: { min: 0, max: 30_720 } }, + { modelId: 'qwen-plus-ultra', expected: { min: 0, max: 81_920 } }, + { modelId: 'qwen-turbo-pro', expected: { min: 0, max: 38_912 } }, + { modelId: 'qwen-flash-lite', expected: { min: 0, max: 81_920 } }, + { modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } }, + { modelId: 'claude-3.7-sonnet-extended', expected: { min: 1_024, max: 64_000 } }, + { modelId: 'claude-sonnet-4.1', expected: { min: 1_024, max: 64_000 } }, + { modelId: 'claude-sonnet-4-5-20250929', expected: { min: 1_024, max: 64_000 } }, + { modelId: 'claude-opus-4-1-extended', expected: { min: 1_024, max: 32_000 } } + ] + + it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => { + expect(findTokenLimit(modelId)).toEqual(expected) + }) + + it('returns undefined for unknown models', () => { + expect(findTokenLimit('unknown-model')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/config/models/__tests__/tooluse.test.ts b/src/renderer/src/config/models/__tests__/tooluse.test.ts new file mode 100644 index 000000000..e147e87f2 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/tooluse.test.ts @@ -0,0 +1,137 @@ +import type { Model } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { isEmbeddingModel, isRerankModel } from '../embedding' +import { isDeepSeekHybridInferenceModel } from '../reasoning' +import { isFunctionCallingModel } from '../tooluse' +import { isPureGenerateImageModel, isTextToImageModel } from '../vision' + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +vi.mock('@renderer/store', () => ({ + __esModule: true, + default: { + getState: () => ({ + llm: { providers: [] }, + settings: {} + }) + }, + useAppDispatch: vi.fn(), + useAppSelector: vi.fn() +})) + +vi.mock('@renderer/store/settings', () => { + const noop = vi.fn() + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'initialState') { + return {} + } + return noop + } + } + ) +}) + +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({})), + useNavbarPosition: vi.fn(() => ({ navbarPosition: 'left' })), + useMessageStyle: vi.fn(() => ({ isBubbleStyle: false })), + getStoreSetting: vi.fn() +})) + +vi.mock('../embedding', () => ({ + isEmbeddingModel: vi.fn(), + isRerankModel: vi.fn() +})) + +vi.mock('../vision', () => ({ + isPureGenerateImageModel: vi.fn(), + isTextToImageModel: vi.fn() +})) + +vi.mock('../reasoning', () => ({ + isDeepSeekHybridInferenceModel: vi.fn() +})) + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o', + name: 'gpt-4o', + provider: 'openai', + group: 'OpenAI', + ...overrides +}) + +const embeddingMock = vi.mocked(isEmbeddingModel) +const rerankMock = vi.mocked(isRerankModel) +const pureImageMock = vi.mocked(isPureGenerateImageModel) +const textToImageMock = vi.mocked(isTextToImageModel) +const deepSeekHybridMock = vi.mocked(isDeepSeekHybridInferenceModel) + +describe('isFunctionCallingModel', () => { + beforeEach(() => { + vi.clearAllMocks() + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValue(false) + pureImageMock.mockReturnValue(false) + textToImageMock.mockReturnValue(false) + deepSeekHybridMock.mockReturnValue(false) + }) + + it('returns false when the model is undefined', () => { + expect(isFunctionCallingModel(undefined as unknown as Model)).toBe(false) + }) + + it('returns false when model is classified as embedding/rerank/image', () => { + embeddingMock.mockReturnValueOnce(true) + expect(isFunctionCallingModel(createModel())).toBe(false) + }) + + it('respect manual user overrides', () => { + const model = createModel({ + capabilities: [{ type: 'function_calling', isUserSelected: false }] + }) + expect(isFunctionCallingModel(model)).toBe(false) + const enabled = createModel({ + capabilities: [{ type: 'function_calling', isUserSelected: true }] + }) + expect(isFunctionCallingModel(enabled)).toBe(true) + }) + + it('matches doubao models by name when regex applies', () => { + const doubao = createModel({ + id: 'custom-model', + name: 'Doubao-Seed-1.6-251015', + provider: 'doubao' + }) + expect(isFunctionCallingModel(doubao)).toBe(true) + }) + + it('returns true for regex matches on standard providers', () => { + expect(isFunctionCallingModel(createModel({ id: 'gpt-5' }))).toBe(true) + }) + + it('excludes explicitly blocked ids', () => { + expect(isFunctionCallingModel(createModel({ id: 'gemini-1.5-flash' }))).toBe(false) + }) + + it('forces support for trusted providers', () => { + for (const provider of ['deepseek', 'anthropic', 'kimi', 'moonshot']) { + expect(isFunctionCallingModel(createModel({ provider }))).toBe(true) + } + }) + + it('returns true when identified as deepseek hybrid inference model', () => { + deepSeekHybridMock.mockReturnValueOnce(true) + expect(isFunctionCallingModel(createModel({ id: 'deepseek-v3-1', provider: 'custom' }))).toBe(true) + }) + + it('returns false for deepseek hybrid models behind restricted system providers', () => { + deepSeekHybridMock.mockReturnValueOnce(true) + expect(isFunctionCallingModel(createModel({ id: 'deepseek-v3-1', provider: 'dashscope' }))).toBe(false) + }) +}) diff --git a/src/renderer/src/config/models/__tests__/utils.test.ts b/src/renderer/src/config/models/__tests__/utils.test.ts new file mode 100644 index 000000000..49e1e9ff5 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/utils.test.ts @@ -0,0 +1,280 @@ +import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding' +import type { Model } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + isGPT5ProModel, + isGPT5SeriesModel, + isGPT5SeriesReasoningModel, + isGPT51SeriesModel, + isOpenAIChatCompletionOnlyModel, + isOpenAILLMModel, + isOpenAIModel, + isOpenAIOpenWeightModel, + isOpenAIReasoningModel, + isSupportVerbosityModel +} from '../openai' +import { isQwenMTModel } from '../qwen' +import { + agentModelFilter, + getModelSupportedVerbosity, + groupQwenModels, + isAnthropicModel, + isGeminiModel, + isGemmaModel, + isGenerateImageModels, + isMaxTemperatureOneModel, + isNotSupportedTextDelta, + isNotSupportSystemMessageModel, + isNotSupportTemperatureAndTopP, + isSupportedFlexServiceTier, + isSupportedModel, + isSupportFlexServiceTierModel, + isVisionModels, + isZhipuModel +} from '../utils' +import { isGenerateImageModel, isTextToImageModel, isVisionModel } from '../vision' +import { isOpenAIWebSearchChatCompletionOnlyModel } from '../websearch' + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +vi.mock('@renderer/store', () => ({ + __esModule: true, + default: { + getState: () => ({ + llm: { providers: [] }, + settings: {} + }) + }, + useAppDispatch: vi.fn(), + useAppSelector: vi.fn() +})) + +vi.mock('@renderer/store/settings', () => { + const noop = vi.fn() + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'initialState') { + return {} + } + return noop + } + } + ) +}) + +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({})), + useNavbarPosition: vi.fn(() => ({ navbarPosition: 'left' })), + useMessageStyle: vi.fn(() => ({ isBubbleStyle: false })), + getStoreSetting: vi.fn() +})) + +vi.mock('@renderer/config/models/embedding', () => ({ + isEmbeddingModel: vi.fn(), + isRerankModel: vi.fn() +})) + +vi.mock('../vision', () => ({ + isGenerateImageModel: vi.fn(), + isTextToImageModel: vi.fn(), + isVisionModel: vi.fn() +})) + +vi.mock(import('../openai'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isOpenAIReasoningModel: vi.fn() + } +}) + +vi.mock('../websearch', () => ({ + isOpenAIWebSearchChatCompletionOnlyModel: vi.fn() +})) + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o', + name: 'gpt-4o', + provider: 'openai', + group: 'OpenAI', + ...overrides +}) + +const embeddingMock = vi.mocked(isEmbeddingModel) +const rerankMock = vi.mocked(isRerankModel) +const visionMock = vi.mocked(isVisionModel) +const textToImageMock = vi.mocked(isTextToImageModel) +const generateImageMock = vi.mocked(isGenerateImageModel) +const reasoningMock = vi.mocked(isOpenAIReasoningModel) +const openAIWebSearchOnlyMock = vi.mocked(isOpenAIWebSearchChatCompletionOnlyModel) + +describe('model utils', () => { + beforeEach(() => { + vi.clearAllMocks() + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValue(false) + visionMock.mockReturnValue(true) + textToImageMock.mockReturnValue(false) + generateImageMock.mockReturnValue(true) + reasoningMock.mockReturnValue(false) + openAIWebSearchOnlyMock.mockReturnValue(false) + }) + + it('detects OpenAI LLM models through reasoning and GPT prefix', () => { + expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false) + expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false) + + reasoningMock.mockReturnValueOnce(true) + expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true) + + expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true) + }) + + it('detects OpenAI models via GPT prefix or reasoning support', () => { + expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true) + reasoningMock.mockReturnValueOnce(true) + expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true) + }) + + it('evaluates support for flex service tier and alias helper', () => { + expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true) + expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false) + expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true) + expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true) + expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + + it('detects verbosity support for GPT-5+ families', () => { + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true) + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) + }) + + it('limits verbosity controls for GPT-5 Pro models', () => { + const proModel = createModel({ id: 'gpt-5-pro' }) + const previewModel = createModel({ id: 'gpt-5-preview' }) + expect(getModelSupportedVerbosity(proModel)).toEqual([undefined, 'high']) + expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high']) + expect(isGPT5ProModel(proModel)).toBe(true) + expect(isGPT5ProModel(previewModel)).toBe(false) + }) + + it('identifies OpenAI chat-completion-only models', () => { + expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true) + expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true) + expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + + it('filters unsupported OpenAI catalog entries', () => { + expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true) + expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false) + }) + + it('calculates temperature/top-p support correctly', () => { + const model = createModel({ id: 'o1' }) + reasoningMock.mockReturnValue(true) + expect(isNotSupportTemperatureAndTopP(model)).toBe(true) + + const openWeight = createModel({ id: 'gpt-oss-debug' }) + expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false) + + const chatOnly = createModel({ id: 'o1-preview' }) + reasoningMock.mockReturnValue(false) + expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true) + + const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' }) + expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true) + }) + + it('handles gemma and gemini detections plus zhipu tagging', () => { + expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true) + expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true) + expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false) + + expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true) + + expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true) + expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false) + }) + + it('groups qwen models by prefix', () => { + const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' }) + const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' }) + const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' }) + + const grouped = groupQwenModels([qwen, qwenOmni, other]) + expect(Object.keys(grouped)).toContain('qwen-7b') + expect(Object.keys(grouped)).toContain('qwen2.5') + expect(grouped.DeepSeek).toContain(other) + }) + + it('aggregates boolean helpers based on regex rules', () => { + expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true) + expect(isQwenMTModel(createModel({ id: 'qwen-mt-large' }))).toBe(true) + expect(isNotSupportedTextDelta(createModel({ id: 'qwen-mt-large' }))).toBe(true) + expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true) + expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true) + }) + + it('evaluates GPT-5 family helpers', () => { + expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true) + expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false) + expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true) + expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-prompt' }))).toBe(true) + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) + }) + + it('wraps generate/vision helpers that operate on arrays', () => { + const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })] + expect(isVisionModels(models)).toBe(true) + visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false) + expect(isVisionModels(models)).toBe(false) + + expect(isGenerateImageModels(models)).toBe(true) + generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false) + expect(isGenerateImageModels(models)).toBe(false) + }) + + it('filters models for agent usage', () => { + expect(agentModelFilter(createModel())).toBe(true) + + embeddingMock.mockReturnValueOnce(true) + expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false) + + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValueOnce(true) + expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false) + + rerankMock.mockReturnValue(false) + textToImageMock.mockReturnValueOnce(true) + expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false) + }) + + it('identifies models with maximum temperature of 1.0', () => { + // Zhipu models should have max temperature of 1.0 + expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true) + + // Anthropic models should have max temperature of 1.0 + expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true) + + // Moonshot models should have max temperature of 1.0 + expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true) + + // Other models should return false + expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false) + expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false) + expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false) + expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false) + }) +}) diff --git a/src/renderer/src/config/models/__tests__/vision.test.ts b/src/renderer/src/config/models/__tests__/vision.test.ts new file mode 100644 index 000000000..43cc3c0d4 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/vision.test.ts @@ -0,0 +1,310 @@ +import { getProviderByModel } from '@renderer/services/AssistantService' +import type { Model } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { isEmbeddingModel, isRerankModel } from '../embedding' +import { + isAutoEnableImageGenerationModel, + isDedicatedImageGenerationModel, + isGenerateImageModel, + isImageEnhancementModel, + isPureGenerateImageModel, + isTextToImageModel, + isVisionModel +} from '../vision' + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +vi.mock('@renderer/store', () => ({ + __esModule: true, + default: { + getState: () => ({ + llm: { providers: [] }, + settings: {} + }) + }, + useAppDispatch: vi.fn(), + useAppSelector: vi.fn() +})) + +vi.mock('@renderer/store/settings', () => { + const noop = vi.fn() + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'initialState') { + return {} + } + return noop + } + } + ) +}) + +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({})), + useNavbarPosition: vi.fn(() => ({ navbarPosition: 'left' })), + useMessageStyle: vi.fn(() => ({ isBubbleStyle: false })), + getStoreSetting: vi.fn() +})) + +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: vi.fn() +})) + +vi.mock('../embedding', () => ({ + isEmbeddingModel: vi.fn(), + isRerankModel: vi.fn() +})) + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o', + name: 'gpt-4o', + provider: 'openai', + group: 'OpenAI', + ...overrides +}) + +const providerMock = vi.mocked(getProviderByModel) +const embeddingMock = vi.mocked(isEmbeddingModel) +const rerankMock = vi.mocked(isRerankModel) + +describe('vision helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + providerMock.mockReturnValue({ type: 'openai-response' } as any) + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValue(false) + }) + + describe('isGenerateImageModel', () => { + it('returns false for embedding/rerank models or missing providers', () => { + embeddingMock.mockReturnValueOnce(true) + expect(isGenerateImageModel(createModel({ id: 'gpt-image-1' }))).toBe(false) + + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValueOnce(true) + expect(isGenerateImageModel(createModel({ id: 'gpt-image-1' }))).toBe(false) + + rerankMock.mockReturnValue(false) + providerMock.mockReturnValueOnce(undefined as any) + expect(isGenerateImageModel(createModel({ id: 'gpt-image-1' }))).toBe(false) + }) + + it('detects OpenAI and third-party generative image models', () => { + expect(isGenerateImageModel(createModel({ id: 'gpt-4o-mini' }))).toBe(true) + + providerMock.mockReturnValue({ type: 'custom' } as any) + expect(isGenerateImageModel(createModel({ id: 'gemini-2.5-flash-image' }))).toBe(true) + }) + + it('returns false when openai-response model is not on allow list', () => { + expect(isGenerateImageModel(createModel({ id: 'gpt-4.2-experimental' }))).toBe(false) + }) + }) + + describe('isPureGenerateImageModel', () => { + it('requires both generate and text-to-image support', () => { + expect(isPureGenerateImageModel(createModel({ id: 'gpt-image-1' }))).toBe(true) + expect(isPureGenerateImageModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + }) + + describe('text-to-image helpers', () => { + it('matches predefined keywords', () => { + expect(isTextToImageModel(createModel({ id: 'midjourney-v6' }))).toBe(true) + expect(isTextToImageModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + + it('detects models with restricted image size support and enhancement', () => { + expect(isImageEnhancementModel(createModel({ id: 'qwen-image-edit' }))).toBe(true) + expect(isImageEnhancementModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + + it('identifies dedicated and auto-enabled image generation models', () => { + expect(isDedicatedImageGenerationModel(createModel({ id: 'grok-2-image-1212' }))).toBe(true) + expect(isAutoEnableImageGenerationModel(createModel({ id: 'gemini-2.5-flash-image-ultra' }))).toBe(true) + }) + + it('returns false when models are not in dedicated or auto-enable sets', () => { + expect(isDedicatedImageGenerationModel(createModel({ id: 'gpt-4o' }))).toBe(false) + expect(isAutoEnableImageGenerationModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + }) +}) + +describe('isVisionModel', () => { + it('returns false for embedding/rerank models and honors overrides', () => { + embeddingMock.mockReturnValueOnce(true) + expect(isVisionModel(createModel({ id: 'gpt-4o' }))).toBe(false) + + embeddingMock.mockReturnValue(false) + const disabled = createModel({ + id: 'gpt-4o', + capabilities: [{ type: 'vision', isUserSelected: false }] + }) + expect(isVisionModel(disabled)).toBe(false) + + const forced = createModel({ + id: 'gpt-4o', + capabilities: [{ type: 'vision', isUserSelected: true }] + }) + expect(isVisionModel(forced)).toBe(true) + }) + + it('matches doubao models by name and general regexes by id', () => { + const doubao = createModel({ + id: 'custom-id', + provider: 'doubao', + name: 'Doubao-Seed-1-6-Lite-251015' + }) + expect(isVisionModel(doubao)).toBe(true) + + expect(isVisionModel(createModel({ id: 'gpt-4o-mini' }))).toBe(true) + }) + + it('leverages image enhancement regex when standard vision regex does not match', () => { + expect(isVisionModel(createModel({ id: 'qwen-image-edit' }))).toBe(true) + }) + + it('returns false for doubao models that fail regex checks', () => { + const doubao = createModel({ id: 'doubao-standard', provider: 'doubao', name: 'basic' }) + expect(isVisionModel(doubao)).toBe(false) + }) + describe('Gemini Models', () => { + it('should return true for gemini 1.5 models', () => { + expect( + isVisionModel({ + id: 'gemini-1.5-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-1.5-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini 2.x models', () => { + expect( + isVisionModel({ + id: 'gemini-2.0-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-2.0-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-2.5-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-2.5-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini latest models', () => { + expect( + isVisionModel({ + id: 'gemini-flash-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-pro-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-flash-lite-latest', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini 3 models', () => { + // Preview versions + expect( + isVisionModel({ + id: 'gemini-3-pro-preview', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + // Future stable versions + expect( + isVisionModel({ + id: 'gemini-3-flash', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + expect( + isVisionModel({ + id: 'gemini-3-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return true for gemini exp models', () => { + expect( + isVisionModel({ + id: 'gemini-exp-1206', + name: '', + provider: '', + group: '' + }) + ).toBe(true) + }) + + it('should return false for gemini 1.0 models', () => { + expect( + isVisionModel({ + id: 'gemini-1.0-pro', + name: '', + provider: '', + group: '' + }) + ).toBe(false) + }) + }) +}) diff --git a/src/renderer/src/config/models/__tests__/websearch.test.ts b/src/renderer/src/config/models/__tests__/websearch.test.ts new file mode 100644 index 000000000..959a58020 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/websearch.test.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const providerMock = vi.mocked(getProviderByModel) + +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: vi.fn(), + getAssistantSettings: vi.fn(), + getDefaultAssistant: vi.fn().mockReturnValue({ + id: 'default', + name: 'Default Assistant', + prompt: '', + settings: {} + }) +})) + +const isEmbeddingModel = vi.hoisted(() => vi.fn()) +const isRerankModel = vi.hoisted(() => vi.fn()) +vi.mock('../embedding', () => ({ + isEmbeddingModel: (...args: any[]) => isEmbeddingModel(...args), + isRerankModel: (...args: any[]) => isRerankModel(...args) +})) + +const isPureGenerateImageModel = vi.hoisted(() => vi.fn()) +const isTextToImageModel = vi.hoisted(() => vi.fn()) +const isGenerateImageModel = vi.hoisted(() => vi.fn()) +vi.mock('../vision', () => ({ + isPureGenerateImageModel: (...args: any[]) => isPureGenerateImageModel(...args), + isTextToImageModel: (...args: any[]) => isTextToImageModel(...args), + isGenerateImageModel: (...args: any[]) => isGenerateImageModel(...args) +})) + +const providerMocks = vi.hoisted(() => ({ + isGeminiProvider: vi.fn(), + isNewApiProvider: vi.fn(), + isOpenAICompatibleProvider: vi.fn(), + isOpenAIProvider: vi.fn(), + isVertexProvider: vi.fn(), + isAwsBedrockProvider: vi.fn() +})) + +vi.mock('@renderer/utils/provider', () => providerMocks) + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +vi.mock('@renderer/store', () => ({ + __esModule: true, + default: { + getState: () => ({ + llm: { providers: [] }, + settings: {} + }) + }, + useAppDispatch: vi.fn(), + useAppSelector: vi.fn() +})) + +vi.mock('@renderer/store/settings', () => { + const noop = vi.fn() + return new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'initialState') { + return {} + } + return noop + } + } + ) +}) + +vi.mock('@renderer/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({})), + useNavbarPosition: vi.fn(() => ({ navbarPosition: 'left' })), + useMessageStyle: vi.fn(() => ({ isBubbleStyle: false })), + getStoreSetting: vi.fn() +})) + +import { getProviderByModel } from '@renderer/services/AssistantService' +import type { Model, Provider } from '@renderer/types' +import { SystemProviderIds } from '@renderer/types' + +import { isOpenAIDeepResearchModel } from '../openai' +import { + GEMINI_SEARCH_REGEX, + isHunyuanSearchModel, + isMandatoryWebSearchModel, + isOpenAIWebSearchChatCompletionOnlyModel, + isOpenAIWebSearchModel, + isOpenRouterBuiltInWebSearchModel, + isWebSearchModel +} from '../websearch' + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o', + name: 'gpt-4o', + provider: 'openai', + group: 'OpenAI', + ...overrides +}) + +const createProvider = (overrides: Partial = {}): Provider => ({ + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: '', + apiHost: '', + models: [], + ...overrides +}) + +const resetMocks = () => { + providerMock.mockReturnValue(createProvider()) + isEmbeddingModel.mockReturnValue(false) + isRerankModel.mockReturnValue(false) + isPureGenerateImageModel.mockReturnValue(false) + isTextToImageModel.mockReturnValue(false) + providerMocks.isGeminiProvider.mockReturnValue(false) + providerMocks.isNewApiProvider.mockReturnValue(false) + providerMocks.isOpenAICompatibleProvider.mockReturnValue(false) + providerMocks.isOpenAIProvider.mockReturnValue(false) +} + +describe('websearch helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + resetMocks() + }) + + describe('isOpenAIDeepResearchModel', () => { + it('detects deep research ids for OpenAI only', () => { + expect(isOpenAIDeepResearchModel(createModel({ id: 'openai/deep-research-preview' }))).toBe(true) + expect(isOpenAIDeepResearchModel(createModel({ provider: 'openai', id: 'gpt-4o' }))).toBe(false) + expect(isOpenAIDeepResearchModel(createModel({ provider: 'openrouter', id: 'deep-research' }))).toBe(false) + }) + }) + + describe('isWebSearchModel', () => { + it('returns false for embedding/rerank/image models', () => { + isEmbeddingModel.mockReturnValueOnce(true) + expect(isWebSearchModel(createModel())).toBe(false) + + resetMocks() + isRerankModel.mockReturnValueOnce(true) + expect(isWebSearchModel(createModel())).toBe(false) + + resetMocks() + isTextToImageModel.mockReturnValueOnce(true) + expect(isWebSearchModel(createModel())).toBe(false) + }) + + it('honors user overrides', () => { + const enabled = createModel({ capabilities: [{ type: 'web_search', isUserSelected: true }] }) + expect(isWebSearchModel(enabled)).toBe(true) + + const disabled = createModel({ capabilities: [{ type: 'web_search', isUserSelected: false }] }) + expect(isWebSearchModel(disabled)).toBe(false) + }) + + it('returns false when provider lookup fails', () => { + providerMock.mockReturnValueOnce(undefined as any) + expect(isWebSearchModel(createModel())).toBe(false) + }) + + it('handles Anthropic providers on unsupported platforms', () => { + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds['aws-bedrock'] })) + const model = createModel({ id: 'claude-2-sonnet' }) + expect(isWebSearchModel(model)).toBe(false) + }) + + it('returns true for first-party Anthropic provider', () => { + providerMock.mockReturnValueOnce(createProvider({ id: 'anthropic' })) + const model = createModel({ id: 'claude-3.5-sonnet-latest', provider: 'anthropic' }) + expect(isWebSearchModel(model)).toBe(true) + }) + + it('detects OpenAI preview search models only when supported', () => { + providerMocks.isOpenAIProvider.mockReturnValue(true) + const model = createModel({ id: 'gpt-4o-search-preview' }) + expect(isWebSearchModel(model)).toBe(true) + + const nonSearch = createModel({ id: 'gpt-4o-image' }) + expect(isWebSearchModel(nonSearch)).toBe(false) + }) + + it('supports Perplexity sonar families including mandatory variants', () => { + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds.perplexity })) + expect(isWebSearchModel(createModel({ id: 'sonar-deep-research' }))).toBe(true) + }) + + it('handles AIHubMix Gemini and OpenAI search models', () => { + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds.aihubmix })) + expect(isWebSearchModel(createModel({ id: 'gemini-2.5-pro-preview' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds.aihubmix })) + const openaiSearch = createModel({ id: 'gpt-4o-search-preview' }) + expect(isWebSearchModel(openaiSearch)).toBe(true) + }) + + it('supports OpenAI-compatible or new API providers for Gemini/OpenAI models', () => { + const model = createModel({ id: 'gemini-2.5-flash-lite-latest' }) + providerMock.mockReturnValueOnce(createProvider({ id: 'custom' })) + providerMocks.isOpenAICompatibleProvider.mockReturnValueOnce(true) + expect(isWebSearchModel(model)).toBe(true) + + resetMocks() + providerMock.mockReturnValueOnce(createProvider({ id: 'custom' })) + providerMocks.isNewApiProvider.mockReturnValueOnce(true) + expect(isWebSearchModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true) + }) + + it('falls back to Gemini/Vertex provider regex matching', () => { + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds.vertexai })) + providerMocks.isGeminiProvider.mockReturnValueOnce(true) + expect(isWebSearchModel(createModel({ id: 'gemini-2.0-flash-latest' }))).toBe(true) + }) + + it('evaluates hunyuan/zhipu/dashscope/openrouter/grok providers', () => { + providerMock.mockReturnValueOnce(createProvider({ id: 'hunyuan' })) + expect(isWebSearchModel(createModel({ id: 'hunyuan-pro' }))).toBe(true) + expect(isWebSearchModel(createModel({ id: 'hunyuan-lite', provider: 'hunyuan' }))).toBe(false) + + providerMock.mockReturnValueOnce(createProvider({ id: 'zhipu' })) + expect(isWebSearchModel(createModel({ id: 'glm-4-air' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: 'dashscope' })) + expect(isWebSearchModel(createModel({ id: 'qwen-max-latest' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: 'openrouter' })) + expect(isWebSearchModel(createModel())).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: 'grok' })) + expect(isWebSearchModel(createModel({ id: 'grok-2' }))).toBe(true) + }) + }) + + describe('isMandatoryWebSearchModel', () => { + it('requires sonar ids for perplexity/openrouter providers', () => { + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds.perplexity })) + expect(isMandatoryWebSearchModel(createModel({ id: 'sonar-pro' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: SystemProviderIds.openrouter })) + expect(isMandatoryWebSearchModel(createModel({ id: 'sonar-reasoning' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: 'openai' })) + expect(isMandatoryWebSearchModel(createModel({ id: 'sonar-pro' }))).toBe(false) + }) + + it.each([ + ['perplexity', 'non-sonar'], + ['openrouter', 'gpt-4o-search-preview'] + ])('returns false for %s provider when id is %s', (providerId, modelId) => { + providerMock.mockReturnValueOnce(createProvider({ id: providerId })) + expect(isMandatoryWebSearchModel(createModel({ id: modelId }))).toBe(false) + }) + }) + + describe('isOpenRouterBuiltInWebSearchModel', () => { + it('checks for sonar ids or OpenAI chat-completion-only variants', () => { + providerMock.mockReturnValueOnce(createProvider({ id: 'openrouter' })) + expect(isOpenRouterBuiltInWebSearchModel(createModel({ id: 'sonar-reasoning' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: 'openrouter' })) + expect(isOpenRouterBuiltInWebSearchModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true) + + providerMock.mockReturnValueOnce(createProvider({ id: 'custom' })) + expect(isOpenRouterBuiltInWebSearchModel(createModel({ id: 'sonar-reasoning' }))).toBe(false) + }) + }) + + describe('OpenAI web search helpers', () => { + it('detects chat completion only variants and openai search ids', () => { + expect(isOpenAIWebSearchChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true) + expect(isOpenAIWebSearchChatCompletionOnlyModel(createModel({ id: 'gpt-4o-mini-search-preview' }))).toBe(true) + expect(isOpenAIWebSearchChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false) + + expect(isOpenAIWebSearchModel(createModel({ id: 'gpt-4.1-turbo' }))).toBe(true) + expect(isOpenAIWebSearchModel(createModel({ id: 'gpt-4o-image' }))).toBe(false) + expect(isOpenAIWebSearchModel(createModel({ id: 'gpt-5.1-chat' }))).toBe(false) + expect(isOpenAIWebSearchModel(createModel({ id: 'o3-mini' }))).toBe(true) + }) + + it.each(['gpt-4.1-preview', 'gpt-4o-2024-05-13', 'o4-mini', 'gpt-5-explorer'])( + 'treats %s as an OpenAI web search model', + (id) => { + expect(isOpenAIWebSearchModel(createModel({ id }))).toBe(true) + } + ) + + it.each(['gpt-4o-image-preview', 'gpt-4.1-nano', 'gpt-5.1-chat', 'gpt-image-1'])( + 'excludes %s from OpenAI web search', + (id) => { + expect(isOpenAIWebSearchModel(createModel({ id }))).toBe(false) + } + ) + + it.each(['gpt-4o-search-preview', 'gpt-4o-mini-search-preview'])('flags %s as chat-completion-only', (id) => { + expect(isOpenAIWebSearchChatCompletionOnlyModel(createModel({ id }))).toBe(true) + }) + }) + + describe('isHunyuanSearchModel', () => { + it('identifies hunyuan models except lite', () => { + expect(isHunyuanSearchModel(createModel({ id: 'hunyuan-pro', provider: 'hunyuan' }))).toBe(true) + expect(isHunyuanSearchModel(createModel({ id: 'hunyuan-lite', provider: 'hunyuan' }))).toBe(false) + expect(isHunyuanSearchModel(createModel())).toBe(false) + }) + + it.each(['hunyuan-standard', 'hunyuan-advanced'])('accepts %s', (suffix) => { + expect(isHunyuanSearchModel(createModel({ id: suffix, provider: 'hunyuan' }))).toBe(true) + }) + }) + + describe('provider-specific regex coverage', () => { + it.each(['qwen-turbo', 'qwen-max-0919', 'qwen3-max', 'qwen-plus-2024', 'qwq-32b'])( + 'dashscope treats %s as searchable', + (id) => { + providerMock.mockReturnValue(createProvider({ id: 'dashscope' })) + expect(isWebSearchModel(createModel({ id }))).toBe(true) + } + ) + + it.each(['qwen-1.5-chat', 'custom-model'])('dashscope ignores %s', (id) => { + providerMock.mockReturnValue(createProvider({ id: 'dashscope' })) + expect(isWebSearchModel(createModel({ id }))).toBe(false) + }) + + it.each(['sonar', 'sonar-pro', 'sonar-reasoning-pro', 'sonar-deep-research'])( + 'perplexity provider supports %s', + (id) => { + providerMock.mockReturnValue(createProvider({ id: SystemProviderIds.perplexity })) + expect(isWebSearchModel(createModel({ id }))).toBe(true) + } + ) + + it.each([ + 'gemini-2.0-flash-latest', + 'gemini-2.5-flash-lite-latest', + 'gemini-flash-lite-latest', + 'gemini-pro-latest' + ])('Gemini provider supports %s', (id) => { + providerMock.mockReturnValue(createProvider({ id: SystemProviderIds.vertexai })) + providerMocks.isGeminiProvider.mockReturnValue(true) + expect(isWebSearchModel(createModel({ id }))).toBe(true) + }) + }) + + describe('Gemini Search Models', () => { + describe('GEMINI_SEARCH_REGEX', () => { + it('should match gemini 2.x models', () => { + expect(GEMINI_SEARCH_REGEX.test('gemini-2.0-flash')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-2.0-pro')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-flash')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-pro')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-flash-latest')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-2.5-pro-latest')).toBe(true) + }) + + it('should match gemini latest models', () => { + expect(GEMINI_SEARCH_REGEX.test('gemini-flash-latest')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-pro-latest')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-flash-lite-latest')).toBe(true) + }) + + it('should match gemini 3 models', () => { + // Preview versions + expect(GEMINI_SEARCH_REGEX.test('gemini-3-pro-preview')).toBe(true) + // Future stable versions + expect(GEMINI_SEARCH_REGEX.test('gemini-3-flash')).toBe(true) + expect(GEMINI_SEARCH_REGEX.test('gemini-3-pro')).toBe(true) + }) + + it('should not match older gemini models', () => { + expect(GEMINI_SEARCH_REGEX.test('gemini-1.5-flash')).toBe(false) + expect(GEMINI_SEARCH_REGEX.test('gemini-1.5-pro')).toBe(false) + expect(GEMINI_SEARCH_REGEX.test('gemini-1.0-pro')).toBe(false) + }) + }) + }) +}) diff --git a/src/renderer/src/config/models/index.ts b/src/renderer/src/config/models/index.ts index 53e2a6090..23d887849 100644 --- a/src/renderer/src/config/models/index.ts +++ b/src/renderer/src/config/models/index.ts @@ -1,6 +1,8 @@ export * from './default' export * from './embedding' export * from './logo' +export * from './openai' +export * from './qwen' export * from './reasoning' export * from './tooluse' export * from './utils' diff --git a/src/renderer/src/config/models/openai.ts b/src/renderer/src/config/models/openai.ts new file mode 100644 index 000000000..4fc223405 --- /dev/null +++ b/src/renderer/src/config/models/openai.ts @@ -0,0 +1,107 @@ +import type { Model } from '@renderer/types' +import { getLowerBaseModelName } from '@renderer/utils' + +export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini'] + +export function isOpenAILLMModel(model: Model): boolean { + if (!model) { + return false + } + const modelId = getLowerBaseModelName(model.id) + + if (modelId.includes('gpt-4o-image')) { + return false + } + if (isOpenAIReasoningModel(model)) { + return true + } + if (modelId.includes('gpt')) { + return true + } + return false +} + +export function isOpenAIModel(model: Model): boolean { + if (!model) { + return false + } + const modelId = getLowerBaseModelName(model.id) + + return modelId.includes('gpt') || isOpenAIReasoningModel(model) +} + +export const isGPT5ProModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-5-pro') +} + +export const isOpenAIOpenWeightModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-oss') +} + +export const isGPT5SeriesModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-5') && !modelId.includes('gpt-5.1') +} + +export const isGPT5SeriesReasoningModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return isGPT5SeriesModel(model) && !modelId.includes('chat') +} + +export const isGPT51SeriesModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('gpt-5.1') +} + +export function isSupportVerbosityModel(model: Model): boolean { + const modelId = getLowerBaseModelName(model.id) + return (isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat') +} + +export function isOpenAIChatCompletionOnlyModel(model: Model): boolean { + if (!model) { + return false + } + + const modelId = getLowerBaseModelName(model.id) + return ( + modelId.includes('gpt-4o-search-preview') || + modelId.includes('gpt-4o-mini-search-preview') || + modelId.includes('o1-mini') || + modelId.includes('o1-preview') + ) +} + +export function isOpenAIReasoningModel(model: Model): boolean { + const modelId = getLowerBaseModelName(model.id, '/') + return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1') +} + +export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean { + const modelId = getLowerBaseModelName(model.id) + return ( + (modelId.includes('o1') && !(modelId.includes('o1-preview') || modelId.includes('o1-mini'))) || + modelId.includes('o3') || + modelId.includes('o4') || + modelId.includes('gpt-oss') || + ((isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat')) + ) +} + +const OPENAI_DEEP_RESEARCH_MODEL_REGEX = /deep[-_]?research/ + +export function isOpenAIDeepResearchModel(model?: Model): boolean { + if (!model) { + return false + } + + const providerId = model.provider + if (providerId !== 'openai' && providerId !== 'openai-chat') { + return false + } + + const modelId = getLowerBaseModelName(model.id, '/') + return OPENAI_DEEP_RESEARCH_MODEL_REGEX.test(modelId) +} diff --git a/src/renderer/src/config/models/qwen.ts b/src/renderer/src/config/models/qwen.ts new file mode 100644 index 000000000..53b64fe95 --- /dev/null +++ b/src/renderer/src/config/models/qwen.ts @@ -0,0 +1,7 @@ +import type { Model } from '@renderer/types' +import { getLowerBaseModelName } from '@renderer/utils' + +export const isQwenMTModel = (model: Model): boolean => { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('qwen-mt') +} diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index a4e422814..3a85fad8f 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -8,9 +8,16 @@ import type { import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils' import { isEmbeddingModel, isRerankModel } from './embedding' -import { isGPT5ProModel, isGPT5SeriesModel, isGPT51SeriesModel } from './utils' +import { + isGPT5ProModel, + isGPT5SeriesModel, + isGPT51SeriesModel, + isOpenAIDeepResearchModel, + isOpenAIReasoningModel, + isSupportedReasoningEffortOpenAIModel +} from './openai' +import { GEMINI_FLASH_MODEL_REGEX } from './utils' import { isTextToImageModel } from './vision' -import { GEMINI_FLASH_MODEL_REGEX, isOpenAIDeepResearchModel } from './websearch' // Reasoning models export const REASONING_REGEX = @@ -535,22 +542,6 @@ export function isReasoningModel(model?: Model): boolean { return REASONING_REGEX.test(modelId) || false } -export function isOpenAIReasoningModel(model: Model): boolean { - const modelId = getLowerBaseModelName(model.id, '/') - return isSupportedReasoningEffortOpenAIModel(model) || modelId.includes('o1') -} - -export function isSupportedReasoningEffortOpenAIModel(model: Model): boolean { - const modelId = getLowerBaseModelName(model.id) - return ( - (modelId.includes('o1') && !(modelId.includes('o1-preview') || modelId.includes('o1-mini'))) || - modelId.includes('o3') || - modelId.includes('o4') || - modelId.includes('gpt-oss') || - ((isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat')) - ) -} - export const THINKING_TOKEN_MAP: Record = { // Gemini models 'gemini-2\\.5-flash-lite.*$': { min: 512, max: 24576 }, diff --git a/src/renderer/src/config/models/tooluse.ts b/src/renderer/src/config/models/tooluse.ts index 76c441e9f..fa9c15e0a 100644 --- a/src/renderer/src/config/models/tooluse.ts +++ b/src/renderer/src/config/models/tooluse.ts @@ -66,10 +66,6 @@ export function isFunctionCallingModel(model?: Model): boolean { return isUserSelectedModelType(model, 'function_calling')! } - if (model.provider === 'qiniu') { - return ['deepseek-v3-tool', 'deepseek-v3-0324', 'qwq-32b', 'qwen2.5-72b-instruct'].includes(modelId) - } - if (model.provider === 'doubao' || modelId.includes('doubao')) { return FUNCTION_CALLING_REGEX.test(modelId) || FUNCTION_CALLING_REGEX.test(model.name) } diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 6c75d4925..e4c02a1ea 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -1,44 +1,14 @@ import type OpenAI from '@cherrystudio/openai' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models/embedding' -import type { Model } from '@renderer/types' +import { type Model, SystemProviderIds } from '@renderer/types' import type { OpenAIVerbosity, ValidOpenAIVerbosity } from '@renderer/types/aiCoreTypes' import { getLowerBaseModelName } from '@renderer/utils' -import { WEB_SEARCH_PROMPT_FOR_OPENROUTER } from '../prompts' -import { getWebSearchTools } from '../tools' -import { isOpenAIReasoningModel } from './reasoning' +import { isOpenAIChatCompletionOnlyModel, isOpenAIOpenWeightModel, isOpenAIReasoningModel } from './openai' +import { isQwenMTModel } from './qwen' import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision' -import { isOpenAIWebSearchChatCompletionOnlyModel } from './websearch' export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i - -export const OPENAI_NO_SUPPORT_DEV_ROLE_MODELS = ['o1-preview', 'o1-mini'] - -export function isOpenAILLMModel(model: Model): boolean { - if (!model) { - return false - } - const modelId = getLowerBaseModelName(model.id) - - if (modelId.includes('gpt-4o-image')) { - return false - } - if (isOpenAIReasoningModel(model)) { - return true - } - if (modelId.includes('gpt')) { - return true - } - return false -} - -export function isOpenAIModel(model: Model): boolean { - if (!model) { - return false - } - const modelId = getLowerBaseModelName(model.id) - - return modelId.includes('gpt') || isOpenAIReasoningModel(model) -} +export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$', 'i') export function isSupportFlexServiceTierModel(model: Model): boolean { if (!model) { @@ -53,33 +23,6 @@ export function isSupportedFlexServiceTier(model: Model): boolean { return isSupportFlexServiceTierModel(model) } -export function isSupportVerbosityModel(model: Model): boolean { - const modelId = getLowerBaseModelName(model.id) - return (isGPT5SeriesModel(model) || isGPT51SeriesModel(model)) && !modelId.includes('chat') -} - -export function isOpenAIChatCompletionOnlyModel(model: Model): boolean { - if (!model) { - return false - } - - const modelId = getLowerBaseModelName(model.id) - return ( - modelId.includes('gpt-4o-search-preview') || - modelId.includes('gpt-4o-mini-search-preview') || - modelId.includes('o1-mini') || - modelId.includes('o1-preview') - ) -} - -export function isGrokModel(model?: Model): boolean { - if (!model) { - return false - } - const modelId = getLowerBaseModelName(model.id) - return modelId.includes('grok') -} - export function isSupportedModel(model: OpenAI.Models.Model): boolean { if (!model) { return false @@ -106,53 +49,6 @@ export function isNotSupportTemperatureAndTopP(model: Model): boolean { return false } -export function getOpenAIWebSearchParams(model: Model, isEnableWebSearch?: boolean): Record { - if (!isEnableWebSearch) { - return {} - } - - const webSearchTools = getWebSearchTools(model) - - if (model.provider === 'grok') { - return { - search_parameters: { - mode: 'auto', - return_citations: true, - sources: [{ type: 'web' }, { type: 'x' }, { type: 'news' }] - } - } - } - - if (model.provider === 'hunyuan') { - return { enable_enhancement: true, citation: true, search_info: true } - } - - if (model.provider === 'dashscope') { - return { - enable_search: true, - search_options: { - forced_search: true - } - } - } - - if (isOpenAIWebSearchChatCompletionOnlyModel(model)) { - return { - web_search_options: {} - } - } - - if (model.provider === 'openrouter') { - return { - plugins: [{ id: 'web', search_prompts: WEB_SEARCH_PROMPT_FOR_OPENROUTER }] - } - } - - return { - tools: webSearchTools - } -} - export function isGemmaModel(model?: Model): boolean { if (!model) { return false @@ -162,12 +58,14 @@ export function isGemmaModel(model?: Model): boolean { return modelId.includes('gemma-') || model.group === 'Gemma' } -export function isZhipuModel(model?: Model): boolean { - if (!model) { - return false - } +export function isZhipuModel(model: Model): boolean { + const modelId = getLowerBaseModelName(model.id) + return modelId.includes('glm') || model.provider === SystemProviderIds.zhipu +} - return model.provider === 'zhipu' +export function isMoonshotModel(model: Model): boolean { + const modelId = getLowerBaseModelName(model.id) + return ['moonshot', 'kimi'].some((m) => modelId.includes(m)) } /** @@ -213,11 +111,6 @@ export const isAnthropicModel = (model?: Model): boolean => { return modelId.startsWith('claude') } -export const isQwenMTModel = (model: Model): boolean => { - const modelId = getLowerBaseModelName(model.id) - return modelId.includes('qwen-mt') -} - export const isNotSupportedTextDelta = (model: Model): boolean => { return isQwenMTModel(model) } @@ -226,21 +119,6 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => { return isQwenMTModel(model) || isGemmaModel(model) } -export const isGPT5SeriesModel = (model: Model) => { - const modelId = getLowerBaseModelName(model.id) - return modelId.includes('gpt-5') && !modelId.includes('gpt-5.1') -} - -export const isGPT5SeriesReasoningModel = (model: Model) => { - const modelId = getLowerBaseModelName(model.id) - return isGPT5SeriesModel(model) && !modelId.includes('chat') -} - -export const isGPT51SeriesModel = (model: Model) => { - const modelId = getLowerBaseModelName(model.id) - return modelId.includes('gpt-5.1') -} - // GPT-5 verbosity configuration // gpt-5-pro only supports 'high', other GPT-5 models support all levels export const MODEL_SUPPORTED_VERBOSITY: Record = { @@ -264,11 +142,6 @@ export const isGeminiModel = (model: Model) => { return modelId.includes('gemini') } -export const isOpenAIOpenWeightModel = (model: Model) => { - const modelId = getLowerBaseModelName(model.id) - return modelId.includes('gpt-oss') -} - // zhipu 视觉推理模型用这组 special token 标记推理结果 export const ZHIPU_RESULT_TOKENS = ['<|begin_of_box|>', '<|end_of_box|>'] as const @@ -276,7 +149,9 @@ export const agentModelFilter = (model: Model): boolean => { return !isEmbeddingModel(model) && !isRerankModel(model) && !isTextToImageModel(model) } -export const isGPT5ProModel = (model: Model) => { - const modelId = getLowerBaseModelName(model.id) - return modelId.includes('gpt-5-pro') +export const isMaxTemperatureOneModel = (model: Model): boolean => { + if (isZhipuModel(model) || isAnthropicModel(model) || isMoonshotModel(model)) { + return true + } + return false } diff --git a/src/renderer/src/config/models/websearch.ts b/src/renderer/src/config/models/websearch.ts index 65f938bcc..5cac2489c 100644 --- a/src/renderer/src/config/models/websearch.ts +++ b/src/renderer/src/config/models/websearch.ts @@ -2,26 +2,26 @@ import { getProviderByModel } from '@renderer/services/AssistantService' import type { Model } from '@renderer/types' import { SystemProviderIds } from '@renderer/types' import { getLowerBaseModelName, isUserSelectedModelType } from '@renderer/utils' - import { isGeminiProvider, isNewApiProvider, isOpenAICompatibleProvider, isOpenAIProvider, - isVertexAiProvider -} from '../providers' + isVertexProvider +} from '@renderer/utils/provider' + +export { GEMINI_FLASH_MODEL_REGEX } from './utils' + import { isEmbeddingModel, isRerankModel } from './embedding' import { isClaude4SeriesModel } from './reasoning' import { isAnthropicModel } from './utils' -import { isPureGenerateImageModel, isTextToImageModel } from './vision' +import { isGenerateImageModel, isPureGenerateImageModel, isTextToImageModel } from './vision' const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp( `\\b(?:claude-3(-|\\.)(7|5)-sonnet(?:-[\\w-]+)|claude-3(-|\\.)5-haiku(?:-[\\w-]+)|claude-(haiku|sonnet|opus)-4(?:-[\\w-]+)?)\\b`, 'i' ) -export const GEMINI_FLASH_MODEL_REGEX = new RegExp('gemini.*-flash.*$') - export const GEMINI_SEARCH_REGEX = new RegExp( 'gemini-(?:2.*(?:-latest)?|3-(?:flash|pro)(?:-preview)?|flash-latest|pro-latest|flash-lite-latest)(?:-[\\w-]+)*$', 'i' @@ -35,29 +35,14 @@ export const PERPLEXITY_SEARCH_MODELS = [ 'sonar-deep-research' ] -const OPENAI_DEEP_RESEARCH_MODEL_REGEX = /deep[-_]?research/ - -export function isOpenAIDeepResearchModel(model?: Model): boolean { - if (!model) { - return false - } - - const providerId = model.provider - if (providerId !== 'openai' && providerId !== 'openai-chat') { - return false - } - - const modelId = getLowerBaseModelName(model.id, '/') - return OPENAI_DEEP_RESEARCH_MODEL_REGEX.test(modelId) -} - export function isWebSearchModel(model: Model): boolean { if ( !model || isEmbeddingModel(model) || isRerankModel(model) || isTextToImageModel(model) || - isPureGenerateImageModel(model) + isPureGenerateImageModel(model) || + isGenerateImageModel(model) ) { return false } @@ -76,7 +61,7 @@ export function isWebSearchModel(model: Model): boolean { // bedrock不支持 if (isAnthropicModel(model) && !(provider.id === SystemProviderIds['aws-bedrock'])) { - if (isVertexAiProvider(provider)) { + if (isVertexProvider(provider)) { return isClaude4SeriesModel(model) } return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(modelId) @@ -114,7 +99,7 @@ export function isWebSearchModel(model: Model): boolean { } } - if (isGeminiProvider(provider) || isVertexAiProvider(provider)) { + if (isGeminiProvider(provider) || isVertexProvider(provider)) { return GEMINI_SEARCH_REGEX.test(modelId) } diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index b21721e71..1e91b93c1 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -59,15 +59,8 @@ import VoyageAIProviderLogo from '@renderer/assets/images/providers/voyageai.png import XirangProviderLogo from '@renderer/assets/images/providers/xirang.png' import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' -import type { - AtLeast, - AzureOpenAIProvider, - Provider, - ProviderType, - SystemProvider, - SystemProviderId -} from '@renderer/types' -import { isSystemProvider, OpenAIServiceTiers, SystemProviderIds } from '@renderer/types' +import type { AtLeast, SystemProvider, SystemProviderId } from '@renderer/types' +import { OpenAIServiceTiers } from '@renderer/types' import { TOKENFLUX_HOST } from './constant' import { glm45FlashModel, qwen38bModel, SYSTEM_MODELS } from './models' @@ -1441,153 +1434,3 @@ export const PROVIDER_URLS: Record = { } } } - -const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [ - 'deepseek', - 'baichuan', - 'minimax', - 'xirang', - 'poe', - 'cephalon' -] as const satisfies SystemProviderId[] - -/** - * 判断提供商是否支持 message 的 content 为数组类型。 Only for OpenAI Chat Completions API. - */ -export const isSupportArrayContentProvider = (provider: Provider) => { - return ( - provider.apiOptions?.isNotSupportArrayContent !== true && - !NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS.some((pid) => pid === provider.id) - ) -} - -const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe', 'qiniu'] as const satisfies SystemProviderId[] - -/** - * 判断提供商是否支持 developer 作为 message role。 Only for OpenAI API. - */ -export const isSupportDeveloperRoleProvider = (provider: Provider) => { - return ( - provider.apiOptions?.isSupportDeveloperRole === true || - (isSystemProvider(provider) && !NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS.some((pid) => pid === provider.id)) - ) -} - -const NOT_SUPPORT_STREAM_OPTIONS_PROVIDERS = ['mistral'] as const satisfies SystemProviderId[] - -/** - * 判断提供商是否支持 stream_options 参数。Only for OpenAI API. - */ -export const isSupportStreamOptionsProvider = (provider: Provider) => { - return ( - provider.apiOptions?.isNotSupportStreamOptions !== true && - !NOT_SUPPORT_STREAM_OPTIONS_PROVIDERS.some((pid) => pid === provider.id) - ) -} - -const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [ - 'ollama', - 'lmstudio', - 'nvidia' -] as const satisfies SystemProviderId[] - -/** - * 判断提供商是否支持使用 enable_thinking 参数来控制 Qwen3 等模型的思考。 Only for OpenAI Chat Completions API. - */ -export const isSupportEnableThinkingProvider = (provider: Provider) => { - return ( - provider.apiOptions?.isNotSupportEnableThinking !== true && - !NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER.some((pid) => pid === provider.id) - ) -} - -const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[] - -/** - * 判断提供商是否支持 service_tier 设置。 Only for OpenAI API. - */ -export const isSupportServiceTierProvider = (provider: Provider) => { - return ( - provider.apiOptions?.isSupportServiceTier === true || - (isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id)) - ) -} - -const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [ - 'gemini', - 'vertexai', - 'anthropic', - 'new-api' -] as const satisfies ProviderType[] - -export const isSupportUrlContextProvider = (provider: Provider) => { - return ( - SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type) || - provider.id === SystemProviderIds.cherryin - ) -} - -const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[] - -/** 判断是否是使用 Gemini 原生搜索工具的 provider. 目前假设只有官方 API 使用原生工具 */ -export const isGeminiWebSearchProvider = (provider: Provider) => { - return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id) -} - -export const isNewApiProvider = (provider: Provider) => { - return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api' -} - -export function isCherryAIProvider(provider: Provider): boolean { - return provider.id === 'cherryai' -} - -export function isPerplexityProvider(provider: Provider): boolean { - return provider.id === 'perplexity' -} - -/** - * 判断是否为 OpenAI 兼容的提供商 - * @param {Provider} provider 提供商对象 - * @returns {boolean} 是否为 OpenAI 兼容提供商 - */ -export function isOpenAICompatibleProvider(provider: Provider): boolean { - return ['openai', 'new-api', 'mistral'].includes(provider.type) -} - -export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider { - return provider.type === 'azure-openai' -} - -export function isOpenAIProvider(provider: Provider): boolean { - return provider.type === 'openai-response' -} - -export function isAnthropicProvider(provider: Provider): boolean { - return provider.type === 'anthropic' -} - -export function isGeminiProvider(provider: Provider): boolean { - return provider.type === 'gemini' -} - -export function isVertexAiProvider(provider: Provider): boolean { - return provider.type === 'vertexai' -} - -export function isAIGatewayProvider(provider: Provider): boolean { - return provider.type === 'ai-gateway' -} - -export function isAwsBedrockProvider(provider: Provider): boolean { - return provider.type === 'aws-bedrock' -} - -const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[] - -export const isSupportAPIVersionProvider = (provider: Provider) => { - if (isSystemProvider(provider)) { - return !NOT_SUPPORT_API_VERSION_PROVIDERS.some((pid) => pid === provider.id) - } - return provider.apiOptions?.isNotSupportAPIVersion !== false -} diff --git a/src/renderer/src/config/tools.ts b/src/renderer/src/config/tools.ts deleted file mode 100644 index 98cb5f7a1..000000000 --- a/src/renderer/src/config/tools.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ChatCompletionTool } from '@cherrystudio/openai/resources' -import type { Model } from '@renderer/types' - -import { WEB_SEARCH_PROMPT_FOR_ZHIPU } from './prompts' - -export function getWebSearchTools(model: Model): ChatCompletionTool[] { - if (model?.provider === 'zhipu') { - if (model.id === 'glm-4-alltools') { - return [ - { - type: 'web_browser', - web_browser: { - browser: 'auto' - } - } as unknown as ChatCompletionTool - ] - } - return [ - { - type: 'web_search', - web_search: { - enable: true, - search_result: true, - search_prompt: WEB_SEARCH_PROMPT_FOR_ZHIPU - } - } as unknown as ChatCompletionTool - ] - } - - if (model?.id.includes('gemini')) { - return [ - { - type: 'function', - function: { - name: 'googleSearch' - } - } - ] - } - return [] -} - -export function getUrlContextTools(model: Model): ChatCompletionTool[] { - if (model.id.includes('gemini')) { - return [ - { - type: 'function', - function: { - name: 'urlContext' - } - } - ] - } - - return [] -} diff --git a/src/renderer/src/hooks/useVertexAI.ts b/src/renderer/src/hooks/useVertexAI.ts index 17b83118f..f902ccd75 100644 --- a/src/renderer/src/hooks/useVertexAI.ts +++ b/src/renderer/src/hooks/useVertexAI.ts @@ -38,13 +38,6 @@ export function getVertexAIServiceAccount() { return store.getState().llm.settings.vertexai.serviceAccount } -/** - * 类型守卫:检查 Provider 是否为 VertexProvider - */ -export function isVertexProvider(provider: Provider): provider is VertexProvider { - return provider.type === 'vertexai' -} - /** * 创建 VertexProvider 对象,整合单独的配置 * @param baseProvider 基础的 provider 配置 diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx index fb99ccc34..17906d3c7 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx @@ -2,7 +2,6 @@ import { ActionIconButton } from '@renderer/components/Buttons' import type { QuickPanelListItem } from '@renderer/components/QuickPanel' import { QuickPanelReservedSymbol, useQuickPanel } from '@renderer/components/QuickPanel' import { isGeminiModel } from '@renderer/config/models' -import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/config/providers' import { useAssistant } from '@renderer/hooks/useAssistant' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useTimer } from '@renderer/hooks/useTimer' @@ -11,6 +10,7 @@ import { getProviderByModel } from '@renderer/services/AssistantService' import { EventEmitter } from '@renderer/services/EventService' import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' +import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/utils/provider' import { Form, Input, Tooltip } from 'antd' import { CircleX, Hammer, Plus } from 'lucide-react' import type { FC } from 'react' diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx index c59a02d61..21300d8fd 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx @@ -9,7 +9,6 @@ import { isOpenAIWebSearchModel, isWebSearchModel } from '@renderer/config/models' -import { isGeminiWebSearchProvider } from '@renderer/config/providers' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' @@ -19,6 +18,7 @@ import WebSearchService from '@renderer/services/WebSearchService' import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { isToolUseModeFunction } from '@renderer/utils/assistant' +import { isGeminiWebSearchProvider } from '@renderer/utils/provider' import { Globe } from 'lucide-react' import { useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/pages/home/Inputbar/tools/urlContextTool.tsx b/src/renderer/src/pages/home/Inputbar/tools/urlContextTool.tsx index bb38e67b0..f044e92fc 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/urlContextTool.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/urlContextTool.tsx @@ -1,7 +1,7 @@ import { isAnthropicModel, isGeminiModel } from '@renderer/config/models' -import { isSupportUrlContextProvider } from '@renderer/config/providers' import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types' import { getProviderByModel } from '@renderer/services/AssistantService' +import { isSupportUrlContextProvider } from '@renderer/utils/provider' import UrlContextButton from './components/UrlContextbutton' diff --git a/src/renderer/src/pages/home/Inputbar/tools/webSearchTool.tsx b/src/renderer/src/pages/home/Inputbar/tools/webSearchTool.tsx index 112bb4798..e6427fa00 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/webSearchTool.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/webSearchTool.tsx @@ -1,4 +1,4 @@ -import { isMandatoryWebSearchModel } from '@renderer/config/models' +import { isMandatoryWebSearchModel, isWebSearchModel } from '@renderer/config/models' import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types' import WebSearchButton from './components/WebSearchButton' @@ -15,7 +15,7 @@ const webSearchTool = defineTool({ label: (t) => t('chat.input.web_search.label'), visibleInScopes: [TopicType.Chat], - condition: ({ model }) => !isMandatoryWebSearchModel(model), + condition: ({ model }) => isWebSearchModel(model) && !isMandatoryWebSearchModel(model), render: function WebSearchToolRender(context) { const { assistant, quickPanelController } = context diff --git a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx index b6ecf88c7..fac346261 100644 --- a/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx +++ b/src/renderer/src/pages/home/Tabs/components/OpenAISettingsGroup.tsx @@ -5,7 +5,6 @@ import { isSupportFlexServiceTierModel, isSupportVerbosityModel } from '@renderer/config/models' -import { isSupportServiceTierProvider } from '@renderer/config/providers' import { useProvider } from '@renderer/hooks/useProvider' import { SettingDivider, SettingRow } from '@renderer/pages/settings' import { CollapsibleSettingGroup } from '@renderer/pages/settings/SettingGroup' @@ -15,6 +14,7 @@ import { setOpenAISummaryText, setOpenAIVerbosity } from '@renderer/store/settin import type { GroqServiceTier, Model, OpenAIServiceTier, ServiceTier } from '@renderer/types' import { GroqServiceTiers, OpenAIServiceTiers, SystemProviderIds } from '@renderer/types' import type { OpenAISummaryText, OpenAIVerbosity } from '@renderer/types/aiCoreTypes' +import { isSupportServiceTierProvider } from '@renderer/utils/provider' import { Tooltip } from 'antd' import { CircleHelp } from 'lucide-react' import type { FC } from 'react' diff --git a/src/renderer/src/pages/paintings/NewApiPage.tsx b/src/renderer/src/pages/paintings/NewApiPage.tsx index c1d8f160f..c7240e845 100644 --- a/src/renderer/src/pages/paintings/NewApiPage.tsx +++ b/src/renderer/src/pages/paintings/NewApiPage.tsx @@ -6,7 +6,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' -import { getProviderLogo, isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers' +import { getProviderLogo, PROVIDER_URLS } from '@renderer/config/providers' import { LanguagesEnum } from '@renderer/config/translate' import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' @@ -28,6 +28,7 @@ import { setGenerating } from '@renderer/store/runtime' import type { PaintingAction, PaintingsState } from '@renderer/types' import type { FileMetadata } from '@renderer/types' import { getErrorMessage, uuid } from '@renderer/utils' +import { isNewApiProvider } from '@renderer/utils/provider' import { Avatar, Button, Empty, InputNumber, Segmented, Select, Upload } from 'antd' import TextArea from 'antd/es/input/TextArea' import type { FC } from 'react' diff --git a/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx b/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx index aedd7a418..662994687 100644 --- a/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx +++ b/src/renderer/src/pages/paintings/PaintingsRoutePage.tsx @@ -1,10 +1,10 @@ import { loggerService } from '@logger' -import { isNewApiProvider } from '@renderer/config/providers' import { useAllProviders } from '@renderer/hooks/useProvider' import { useAppDispatch } from '@renderer/store' import { setDefaultPaintingProvider } from '@renderer/store/settings' import { updateTab } from '@renderer/store/tabs' import type { PaintingProvider, SystemProviderId } from '@renderer/types' +import { isNewApiProvider } from '@renderer/utils/provider' import type { FC } from 'react' import { useEffect, useMemo, useState } from 'react' import { Route, Routes, useParams } from 'react-router-dom' diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx index 820973441..deed2c4a1 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx @@ -17,10 +17,10 @@ import { isVisionModel, isWebSearchModel } from '@renderer/config/models' -import { isNewApiProvider } from '@renderer/config/providers' import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth' import type { Model, ModelCapability, ModelType, Provider } from '@renderer/types' import { getDefaultGroupName, getDifference, getUnion, uniqueObjectArray } from '@renderer/utils' +import { isNewApiProvider } from '@renderer/utils/provider' import type { ModalProps } from 'antd' import { Button, Divider, Flex, Form, Input, InputNumber, message, Modal, Select, Switch, Tooltip } from 'antd' import { cloneDeep } from 'lodash' diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsList.tsx index fed943319..6bbab405e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsList.tsx @@ -3,10 +3,10 @@ import ModelIdWithTags from '@renderer/components/ModelIdWithTags' import CustomTag from '@renderer/components/Tags/CustomTag' import { DynamicVirtualList } from '@renderer/components/VirtualList' import { getModelLogoById } from '@renderer/config/models' -import { isNewApiProvider } from '@renderer/config/providers' import FileItem from '@renderer/pages/files/FileItem' import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup' import type { Model, Provider } from '@renderer/types' +import { isNewApiProvider } from '@renderer/utils/provider' import { Button, Flex, Tooltip } from 'antd' import { Avatar } from 'antd' import { ChevronRight, Minus, Plus } from 'lucide-react' diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx index e2ae51394..69b5ca26f 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx @@ -13,7 +13,6 @@ import { isWebSearchModel, SYSTEM_MODELS } from '@renderer/config/models' -import { isNewApiProvider } from '@renderer/config/providers' import { useProvider } from '@renderer/hooks/useProvider' import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup' import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup' @@ -21,6 +20,7 @@ import { fetchModels } from '@renderer/services/ApiService' import type { Model, Provider } from '@renderer/types' import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName } from '@renderer/utils' import { isFreeModel } from '@renderer/utils/model' +import { isNewApiProvider } from '@renderer/utils/provider' import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { groupBy, isEmpty, uniqBy } from 'lodash' diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx index ad7923c6b..b2455a8ad 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx @@ -2,7 +2,7 @@ import CollapsibleSearchBar from '@renderer/components/CollapsibleSearchBar' import { LoadingIcon, StreamlineGoodHealthAndWellBeing } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' import CustomTag from '@renderer/components/Tags/CustomTag' -import { isNewApiProvider, PROVIDER_URLS } from '@renderer/config/providers' +import { PROVIDER_URLS } from '@renderer/config/providers' import { useProvider } from '@renderer/hooks/useProvider' import { getProviderLabel } from '@renderer/i18n/label' import { SettingHelpLink, SettingHelpText, SettingHelpTextRow, SettingSubtitle } from '@renderer/pages/settings' @@ -13,6 +13,7 @@ import ManageModelsPopup from '@renderer/pages/settings/ProviderSettings/ModelLi import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup' import type { Model } from '@renderer/types' import { filterModelsByKeywords } from '@renderer/utils' +import { isNewApiProvider } from '@renderer/utils/provider' import { Button, Flex, Spin, Tooltip } from 'antd' import { groupBy, isEmpty, sortBy, toPairs } from 'lodash' import { ListCheck, Plus } from 'lucide-react' diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx index 486753f78..f7d29c772 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/NewApiAddModelPopup.tsx @@ -1,11 +1,11 @@ import { TopView } from '@renderer/components/TopView' import { endpointTypeOptions } from '@renderer/config/endpointTypes' import { isNotSupportedTextDelta } from '@renderer/config/models' -import { isNewApiProvider } from '@renderer/config/providers' import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth' import { useProvider } from '@renderer/hooks/useProvider' import type { EndpointType, Model, Provider } from '@renderer/types' import { getDefaultGroupName } from '@renderer/utils' +import { isNewApiProvider } from '@renderer/utils/provider' import type { FormProps } from 'antd' import { Button, Flex, Form, Input, Modal, Select } from 'antd' import { find } from 'lodash' diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index cdd71936f..6f46b8144 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -4,21 +4,10 @@ import { HStack } from '@renderer/components/Layout' import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' import Selector from '@renderer/components/Selector' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { - isAIGatewayProvider, - isAnthropicProvider, - isAzureOpenAIProvider, - isGeminiProvider, - isNewApiProvider, - isOpenAICompatibleProvider, - isOpenAIProvider, - isSupportAPIVersionProvider, - PROVIDER_URLS -} from '@renderer/config/providers' +import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' import { useTimer } from '@renderer/hooks/useTimer' -import { isVertexProvider } from '@renderer/hooks/useVertexAI' import i18n from '@renderer/i18n' import AnthropicSettings from '@renderer/pages/settings/ProviderSettings/AnthropicSettings' import { ModelList } from '@renderer/pages/settings/ProviderSettings/ModelList' @@ -39,6 +28,17 @@ import { validateApiHost } from '@renderer/utils' import { formatErrorMessage } from '@renderer/utils/error' +import { + isAIGatewayProvider, + isAnthropicProvider, + isAzureOpenAIProvider, + isGeminiProvider, + isNewApiProvider, + isOpenAICompatibleProvider, + isOpenAIProvider, + isSupportAPIVersionProvider, + isVertexProvider +} from '@renderer/utils/provider' import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' import Link from 'antd/es/typography/Link' import { debounce, isEmpty } from 'lodash' @@ -287,7 +287,7 @@ const ProviderSetting: FC = ({ providerId }) => { } if (isAzureOpenAIProvider(provider)) { - const apiVersion = provider.apiVersion + const apiVersion = provider.apiVersion || '' const path = !['preview', 'v1'].includes(apiVersion) ? `/v1/chat/completion?apiVersion=v1` : `/v1/responses?apiVersion=v1` diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 685ecf632..96881c56b 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -6,7 +6,7 @@ import { MAX_CONTEXT_COUNT, UNLIMITED_CONTEXT_COUNT } from '@renderer/config/constant' -import { isQwenMTModel } from '@renderer/config/models' +import { isQwenMTModel } from '@renderer/config/models/qwen' import { CHERRYAI_PROVIDER } from '@renderer/config/providers' import { UNKNOWN } from '@renderer/config/translate' import { getStoreProviders } from '@renderer/hooks/useStore' diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index ed065c3a1..ef35027ff 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -4,7 +4,6 @@ import { ModernAiProvider } from '@renderer/aiCore' import AiProvider from '@renderer/aiCore/legacy' import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant' import { getEmbeddingMaxContext } from '@renderer/config/embedings' -import { isAzureOpenAIProvider, isGeminiProvider } from '@renderer/config/providers' import { addSpan, endSpan } from '@renderer/services/SpanManagerService' import store from '@renderer/store' import type { @@ -18,6 +17,7 @@ import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' import { routeToEndpoint } from '@renderer/utils' import type { ExtractResults } from '@renderer/utils/extract' +import { isAzureOpenAIProvider, isGeminiProvider } from '@renderer/utils/provider' import { isEmpty } from 'lodash' import { getProviderByModel } from './AssistantService' diff --git a/src/renderer/src/services/ProviderService.ts b/src/renderer/src/services/ProviderService.ts index 6ec4fa4cc..c394e2afe 100644 --- a/src/renderer/src/services/ProviderService.ts +++ b/src/renderer/src/services/ProviderService.ts @@ -21,6 +21,7 @@ export function getProviderNameById(pid: string) { } } +//FIXME: 和 AssistantService.ts 中的同名函数冲突 export function getProviderByModel(model?: Model) { const id = model?.provider const provider = getStoreProviders().find((p) => p.id === id) diff --git a/src/renderer/src/services/__tests__/ApiService.test.ts b/src/renderer/src/services/__tests__/ApiService.test.ts index 160f93327..1e9792cdc 100644 --- a/src/renderer/src/services/__tests__/ApiService.test.ts +++ b/src/renderer/src/services/__tests__/ApiService.test.ts @@ -95,9 +95,20 @@ vi.mock('@renderer/services/AssistantService', () => ({ })) })) -vi.mock('@renderer/utils', () => ({ - getLowerBaseModelName: vi.fn((name) => name.toLowerCase()) -})) +vi.mock(import('@renderer/utils'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getLowerBaseModelName: vi.fn((name) => name.toLowerCase()) + } +}) + +vi.mock(import('@renderer/config/providers'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual + } +}) vi.mock('@renderer/config/prompts', () => ({ WEB_SEARCH_PROMPT_FOR_OPENROUTER: 'mock-prompt' @@ -108,10 +119,6 @@ vi.mock('@renderer/config/systemModels', () => ({ GENERATE_IMAGE_MODELS: [] })) -vi.mock('@renderer/config/tools', () => ({ - getWebSearchTools: vi.fn(() => []) -})) - // Mock store modules vi.mock('@renderer/store/assistants', () => ({ default: (state = { assistants: [] }) => state diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 13755fdaf..228be2e37 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -10,12 +10,7 @@ import { } from '@renderer/config/models' import { BUILTIN_OCR_PROVIDERS, BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' -import { - isSupportArrayContentProvider, - isSupportDeveloperRoleProvider, - isSupportStreamOptionsProvider, - SYSTEM_PROVIDERS -} from '@renderer/config/providers' +import { SYSTEM_PROVIDERS } from '@renderer/config/providers' import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import db from '@renderer/databases' import i18n from '@renderer/i18n' @@ -32,6 +27,11 @@ import type { } from '@renderer/types' import { isBuiltinMCPServer, isSystemProvider, SystemProviderIds } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' +import { + isSupportArrayContentProvider, + isSupportDeveloperRoleProvider, + isSupportStreamOptionsProvider +} from '@renderer/utils/provider' import { defaultByPassRules, UpgradeChannel } from '@shared/config/constant' import { isEmpty } from 'lodash' import { createMigrate } from 'redux-persist' diff --git a/src/renderer/src/utils/__tests__/code-language.ts b/src/renderer/src/utils/__tests__/code-language.test.ts similarity index 100% rename from src/renderer/src/utils/__tests__/code-language.ts rename to src/renderer/src/utils/__tests__/code-language.test.ts diff --git a/src/renderer/src/utils/__tests__/provider.test.ts b/src/renderer/src/utils/__tests__/provider.test.ts new file mode 100644 index 000000000..eef97ce67 --- /dev/null +++ b/src/renderer/src/utils/__tests__/provider.test.ts @@ -0,0 +1,171 @@ +import { type AzureOpenAIProvider, type Provider, SystemProviderIds } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +import { + getClaudeSupportedProviders, + isAIGatewayProvider, + isAnthropicProvider, + isAzureOpenAIProvider, + isCherryAIProvider, + isGeminiProvider, + isGeminiWebSearchProvider, + isNewApiProvider, + isOpenAICompatibleProvider, + isOpenAIProvider, + isPerplexityProvider, + isSupportAPIVersionProvider, + isSupportArrayContentProvider, + isSupportDeveloperRoleProvider, + isSupportEnableThinkingProvider, + isSupportServiceTierProvider, + isSupportStreamOptionsProvider, + isSupportUrlContextProvider +} from '../provider' + +vi.mock('@renderer/store/settings', () => ({ + default: (state = { settings: {} }) => state +})) + +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: vi.fn(), + getAssistantSettings: vi.fn(), + getDefaultAssistant: vi.fn().mockReturnValue({ + id: 'default', + name: 'Default Assistant', + prompt: '', + settings: {} + }) +})) + +const createProvider = (overrides: Partial = {}): Provider => ({ + id: 'custom', + type: 'openai', + name: 'Custom Provider', + apiKey: 'key', + apiHost: 'https://api.example.com', + models: [], + ...overrides +}) + +const createSystemProvider = (overrides: Partial = {}): Provider => + createProvider({ + id: SystemProviderIds.openai, + isSystem: true, + ...overrides + }) + +describe('provider utils', () => { + it('filters Claude supported providers', () => { + const providers = [ + createProvider({ id: 'anthropic-official', type: 'anthropic' }), + createProvider({ id: 'custom-host', anthropicApiHost: 'https://anthropic.local' }), + createProvider({ id: 'aihubmix' }), + createProvider({ id: 'other' }) + ] + + expect(getClaudeSupportedProviders(providers)).toEqual(providers.slice(0, 3)) + }) + + it('evaluates message array content support', () => { + expect(isSupportArrayContentProvider(createProvider())).toBe(true) + + expect(isSupportArrayContentProvider(createProvider({ apiOptions: { isNotSupportArrayContent: true } }))).toBe( + false + ) + + expect(isSupportArrayContentProvider(createSystemProvider({ id: SystemProviderIds.deepseek }))).toBe(false) + }) + + it('evaluates developer role support', () => { + expect(isSupportDeveloperRoleProvider(createProvider({ apiOptions: { isSupportDeveloperRole: true } }))).toBe(true) + expect(isSupportDeveloperRoleProvider(createSystemProvider())).toBe(true) + expect(isSupportDeveloperRoleProvider(createSystemProvider({ id: SystemProviderIds.poe }))).toBe(false) + }) + + it('checks stream options support', () => { + expect(isSupportStreamOptionsProvider(createProvider())).toBe(true) + expect(isSupportStreamOptionsProvider(createProvider({ apiOptions: { isNotSupportStreamOptions: true } }))).toBe( + false + ) + expect(isSupportStreamOptionsProvider(createSystemProvider({ id: SystemProviderIds.mistral }))).toBe(false) + }) + + it('checks enable thinking support', () => { + expect(isSupportEnableThinkingProvider(createProvider())).toBe(true) + expect(isSupportEnableThinkingProvider(createProvider({ apiOptions: { isNotSupportEnableThinking: true } }))).toBe( + false + ) + expect(isSupportEnableThinkingProvider(createSystemProvider({ id: SystemProviderIds.nvidia }))).toBe(false) + }) + + it('determines service tier support', () => { + expect(isSupportServiceTierProvider(createProvider({ apiOptions: { isSupportServiceTier: true } }))).toBe(true) + expect(isSupportServiceTierProvider(createSystemProvider())).toBe(true) + expect(isSupportServiceTierProvider(createSystemProvider({ id: SystemProviderIds.github }))).toBe(false) + }) + + it('detects URL context capable providers', () => { + expect(isSupportUrlContextProvider(createProvider({ type: 'gemini' }))).toBe(true) + expect( + isSupportUrlContextProvider( + createSystemProvider({ id: SystemProviderIds.cherryin, type: 'openai', isSystem: true }) + ) + ).toBe(true) + expect(isSupportUrlContextProvider(createProvider())).toBe(false) + }) + + it('identifies Gemini web search providers', () => { + expect(isGeminiWebSearchProvider(createSystemProvider({ id: SystemProviderIds.gemini, type: 'gemini' }))).toBe(true) + expect(isGeminiWebSearchProvider(createSystemProvider({ id: SystemProviderIds.vertexai, type: 'vertexai' }))).toBe( + true + ) + expect(isGeminiWebSearchProvider(createSystemProvider())).toBe(false) + }) + + it('detects New API providers by id or type', () => { + expect(isNewApiProvider(createProvider({ id: SystemProviderIds['new-api'] }))).toBe(true) + expect(isNewApiProvider(createProvider({ id: SystemProviderIds.cherryin }))).toBe(true) + expect(isNewApiProvider(createProvider({ type: 'new-api' }))).toBe(true) + expect(isNewApiProvider(createProvider())).toBe(false) + }) + + it('detects specific provider ids', () => { + expect(isCherryAIProvider(createProvider({ id: 'cherryai' }))).toBe(true) + expect(isCherryAIProvider(createProvider())).toBe(false) + + expect(isPerplexityProvider(createProvider({ id: SystemProviderIds.perplexity }))).toBe(true) + expect(isPerplexityProvider(createProvider())).toBe(false) + }) + + it('recognizes OpenAI compatible providers', () => { + expect(isOpenAICompatibleProvider(createProvider({ type: 'openai' }))).toBe(true) + expect(isOpenAICompatibleProvider(createProvider({ type: 'new-api' }))).toBe(true) + expect(isOpenAICompatibleProvider(createProvider({ type: 'mistral' }))).toBe(true) + expect(isOpenAICompatibleProvider(createProvider({ type: 'anthropic' }))).toBe(false) + }) + + it('narrows Azure OpenAI providers', () => { + const azureProvider = { + ...createProvider({ type: 'azure-openai' }), + apiVersion: '2024-06-01' + } as AzureOpenAIProvider + expect(isAzureOpenAIProvider(azureProvider)).toBe(true) + expect(isAzureOpenAIProvider(createProvider())).toBe(false) + }) + + it('checks provider type helpers', () => { + expect(isOpenAIProvider(createProvider({ type: 'openai-response' }))).toBe(true) + expect(isOpenAIProvider(createProvider())).toBe(false) + + expect(isAnthropicProvider(createProvider({ type: 'anthropic' }))).toBe(true) + expect(isGeminiProvider(createProvider({ type: 'gemini' }))).toBe(true) + expect(isAIGatewayProvider(createProvider({ type: 'ai-gateway' }))).toBe(true) + }) + + it('computes API version support', () => { + expect(isSupportAPIVersionProvider(createSystemProvider())).toBe(true) + expect(isSupportAPIVersionProvider(createSystemProvider({ id: SystemProviderIds.github }))).toBe(false) + expect(isSupportAPIVersionProvider(createProvider())).toBe(true) + expect(isSupportAPIVersionProvider(createProvider({ apiOptions: { isNotSupportAPIVersion: false } }))).toBe(false) + }) +}) diff --git a/src/renderer/src/utils/__tests__/topicKnowledge.test.ts b/src/renderer/src/utils/__tests__/topicKnowledge.test.ts index 0e6305341..bb7c0f888 100644 --- a/src/renderer/src/utils/__tests__/topicKnowledge.test.ts +++ b/src/renderer/src/utils/__tests__/topicKnowledge.test.ts @@ -3,6 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CONTENT_TYPES } from '../knowledge' +// Mock modules to prevent circular dependencies during test loading +vi.mock('@renderer/components/Popups/SaveToKnowledgePopup', () => ({ + default: {} +})) + +vi.mock('@renderer/pages/home/Messages/MessageMenubar', () => ({ + default: {} +})) + // Simple mocks vi.mock('@renderer/hooks/useTopic', () => ({ TopicManager: { diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts index b8d761f8a..e53fc524d 100644 --- a/src/renderer/src/utils/provider.ts +++ b/src/renderer/src/utils/provider.ts @@ -1,6 +1,159 @@ import { CLAUDE_SUPPORTED_PROVIDERS } from '@renderer/pages/code' -import type { Provider } from '@renderer/types' +import type { AzureOpenAIProvider, ProviderType, VertexProvider } from '@renderer/types' +import { isSystemProvider, type Provider, type SystemProviderId, SystemProviderIds } from '@renderer/types' export const getClaudeSupportedProviders = (providers: Provider[]) => { - return providers.filter((p) => p.type === 'anthropic' || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id)) + return providers.filter( + (p) => p.type === 'anthropic' || !!p.anthropicApiHost || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id) + ) +} + +const NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS = [ + 'deepseek', + 'baichuan', + 'minimax', + 'xirang', + 'poe', + 'cephalon' +] as const satisfies SystemProviderId[] + +/** + * 判断提供商是否支持 message 的 content 为数组类型。 Only for OpenAI Chat Completions API. + */ +export const isSupportArrayContentProvider = (provider: Provider) => { + return ( + provider.apiOptions?.isNotSupportArrayContent !== true && + !NOT_SUPPORT_ARRAY_CONTENT_PROVIDERS.some((pid) => pid === provider.id) + ) +} + +const NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS = ['poe', 'qiniu'] as const satisfies SystemProviderId[] + +/** + * 判断提供商是否支持 developer 作为 message role。 Only for OpenAI API. + */ +export const isSupportDeveloperRoleProvider = (provider: Provider) => { + return ( + provider.apiOptions?.isSupportDeveloperRole === true || + (isSystemProvider(provider) && !NOT_SUPPORT_DEVELOPER_ROLE_PROVIDERS.some((pid) => pid === provider.id)) + ) +} + +const NOT_SUPPORT_STREAM_OPTIONS_PROVIDERS = ['mistral'] as const satisfies SystemProviderId[] + +/** + * 判断提供商是否支持 stream_options 参数。Only for OpenAI API. + */ +export const isSupportStreamOptionsProvider = (provider: Provider) => { + return ( + provider.apiOptions?.isNotSupportStreamOptions !== true && + !NOT_SUPPORT_STREAM_OPTIONS_PROVIDERS.some((pid) => pid === provider.id) + ) +} + +const NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER = [ + 'ollama', + 'lmstudio', + 'nvidia' +] as const satisfies SystemProviderId[] + +/** + * 判断提供商是否支持使用 enable_thinking 参数来控制 Qwen3 等模型的思考。 Only for OpenAI Chat Completions API. + */ +export const isSupportEnableThinkingProvider = (provider: Provider) => { + return ( + provider.apiOptions?.isNotSupportEnableThinking !== true && + !NOT_SUPPORT_QWEN3_ENABLE_THINKING_PROVIDER.some((pid) => pid === provider.id) + ) +} + +const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[] + +/** + * 判断提供商是否支持 service_tier 设置。 Only for OpenAI API. + */ +export const isSupportServiceTierProvider = (provider: Provider) => { + return ( + provider.apiOptions?.isSupportServiceTier === true || + (isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id)) + ) +} + +const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [ + 'gemini', + 'vertexai', + 'anthropic', + 'new-api' +] as const satisfies ProviderType[] + +export const isSupportUrlContextProvider = (provider: Provider) => { + return ( + SUPPORT_URL_CONTEXT_PROVIDER_TYPES.some((type) => type === provider.type) || + provider.id === SystemProviderIds.cherryin + ) +} + +const SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS = ['gemini', 'vertexai'] as const satisfies SystemProviderId[] + +/** 判断是否是使用 Gemini 原生搜索工具的 provider. 目前假设只有官方 API 使用原生工具 */ +export const isGeminiWebSearchProvider = (provider: Provider) => { + return SUPPORT_GEMINI_NATIVE_WEB_SEARCH_PROVIDERS.some((id) => id === provider.id) +} + +export const isNewApiProvider = (provider: Provider) => { + return ['new-api', 'cherryin'].includes(provider.id) || provider.type === 'new-api' +} + +export function isCherryAIProvider(provider: Provider): boolean { + return provider.id === 'cherryai' +} + +export function isPerplexityProvider(provider: Provider): boolean { + return provider.id === 'perplexity' +} + +/** + * 判断是否为 OpenAI 兼容的提供商 + * @param {Provider} provider 提供商对象 + * @returns {boolean} 是否为 OpenAI 兼容提供商 + */ +export function isOpenAICompatibleProvider(provider: Provider): boolean { + return ['openai', 'new-api', 'mistral'].includes(provider.type) +} + +export function isAzureOpenAIProvider(provider: Provider): provider is AzureOpenAIProvider { + return provider.type === 'azure-openai' +} + +export function isOpenAIProvider(provider: Provider): boolean { + return provider.type === 'openai-response' +} + +export function isVertexProvider(provider: Provider): provider is VertexProvider { + return provider.type === 'vertexai' +} + +export function isAwsBedrockProvider(provider: Provider): boolean { + return provider.type === 'aws-bedrock' +} + +export function isAnthropicProvider(provider: Provider): boolean { + return provider.type === 'anthropic' +} + +export function isGeminiProvider(provider: Provider): boolean { + return provider.type === 'gemini' +} + +export function isAIGatewayProvider(provider: Provider): boolean { + return provider.type === 'ai-gateway' +} + +const NOT_SUPPORT_API_VERSION_PROVIDERS = ['github', 'copilot', 'perplexity'] as const satisfies SystemProviderId[] + +export const isSupportAPIVersionProvider = (provider: Provider) => { + if (isSystemProvider(provider)) { + return !NOT_SUPPORT_API_VERSION_PROVIDERS.some((pid) => pid === provider.id) + } + return provider.apiOptions?.isNotSupportAPIVersion !== false } diff --git a/tests/renderer.setup.ts b/tests/renderer.setup.ts index bd6227128..9e10e5363 100644 --- a/tests/renderer.setup.ts +++ b/tests/renderer.setup.ts @@ -15,8 +15,9 @@ vi.mock('@logger', async () => { }) // Mock uuid globally for renderer tests +let uuidCounter = 0 vi.mock('uuid', () => ({ - v4: () => 'test-uuid-' + Date.now() + v4: () => 'test-uuid-' + ++uuidCounter })) vi.mock('axios', () => { diff --git a/yarn.lock b/yarn.lock index ce6788110..def971fd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -410,6 +410,15 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/test-server@npm:^0.0.1": + version: 0.0.1 + resolution: "@ai-sdk/test-server@npm:0.0.1" + dependencies: + msw: "npm:^2.7.0" + checksum: 10c0/465fbb0444825f169333c98b2f0b12fe51914b6525f2d36fd4a2b5b03d2ac736060519fd14e0fcffdcba615d8b563bc39ddeb11fea6b1e6218419693ce62e029 + languageName: node + linkType: hard + "@ai-sdk/xai@npm:^2.0.31": version: 2.0.31 resolution: "@ai-sdk/xai@npm:2.0.31" @@ -3756,6 +3765,68 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^1.0.2": + version: 1.0.2 + resolution: "@inquirer/ansi@npm:1.0.2" + checksum: 10c0/8e408cc628923aa93402e66657482ccaa2ad5174f9db526d9a8b443f9011e9cd8f70f0f534f5fe3857b8a9df3bce1e25f66c96f666d6750490bd46e2b4f3b829 + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^5.0.0": + version: 5.1.20 + resolution: "@inquirer/confirm@npm:5.1.20" + dependencies: + "@inquirer/core": "npm:^10.3.1" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/390cca939f9e9f21cb785624302d4cfa4c009ae67d77a899c71fbe25ec06ee5658a6007559ac78e5c07726b0d4256ab1da8d3549ce677fa111d3ab8a8d1737ff + languageName: node + linkType: hard + +"@inquirer/core@npm:^10.3.1": + version: 10.3.1 + resolution: "@inquirer/core@npm:10.3.1" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/077626de567236c67e15947f02fa4266d56aa47f2778b2a3b3637c541752c00ef78ad9bd3614de50d5a8501eb442807f75a0864101ca786df8f39c00b1b6c86d + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.15": + version: 1.0.15 + resolution: "@inquirer/figures@npm:1.0.15" + checksum: 10c0/6e39a040d260ae234ae220180b7994ff852673e20be925f8aa95e78c7934d732b018cbb4d0ec39e600a410461bcb93dca771e7de23caa10630d255692e440f69 + languageName: node + linkType: hard + +"@inquirer/type@npm:^3.0.10": + version: 3.0.10 + resolution: "@inquirer/type@npm:3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/a846c7a570e3bf2657d489bcc5dcdc3179d24c7323719de1951dcdb722400ac76e5b2bfe9765d0a789bc1921fac810983d7999f021f30a78a6a174c23fc78dc9 + languageName: node + linkType: hard + "@isaacs/balanced-match@npm:^4.0.1": version: 4.0.1 resolution: "@isaacs/balanced-match@npm:4.0.1" @@ -4741,6 +4812,20 @@ __metadata: languageName: node linkType: hard +"@mswjs/interceptors@npm:^0.40.0": + version: 0.40.0 + resolution: "@mswjs/interceptors@npm:0.40.0" + dependencies: + "@open-draft/deferred-promise": "npm:^2.2.0" + "@open-draft/logger": "npm:^0.3.0" + "@open-draft/until": "npm:^2.0.0" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + strict-event-emitter: "npm:^0.5.1" + checksum: 10c0/4500f17b65910b2633182fdb15a81ccb6ccd4488a8c45bc2f7acdaaff4621c3cce5362e6b59ddc4fa28d315d0efb0608fd1f0d536bc5345141f8ac03fd7fab22 + languageName: node + linkType: hard + "@mux/mux-data-google-ima@npm:0.2.8": version: 0.2.8 resolution: "@mux/mux-data-google-ima@npm:0.2.8" @@ -4973,6 +5058,30 @@ __metadata: languageName: node linkType: hard +"@open-draft/deferred-promise@npm:^2.2.0": + version: 2.2.0 + resolution: "@open-draft/deferred-promise@npm:2.2.0" + checksum: 10c0/eafc1b1d0fc8edb5e1c753c5e0f3293410b40dde2f92688211a54806d4136887051f39b98c1950370be258483deac9dfd17cf8b96557553765198ef2547e4549 + languageName: node + linkType: hard + +"@open-draft/logger@npm:^0.3.0": + version: 0.3.0 + resolution: "@open-draft/logger@npm:0.3.0" + dependencies: + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.0" + checksum: 10c0/90010647b22e9693c16258f4f9adb034824d1771d3baa313057b9a37797f571181005bc50415a934eaf7c891d90ff71dcd7a9d5048b0b6bb438f31bef2c7c5c1 + languageName: node + linkType: hard + +"@open-draft/until@npm:^2.0.0": + version: 2.1.0 + resolution: "@open-draft/until@npm:2.1.0" + checksum: 10c0/61d3f99718dd86bb393fee2d7a785f961dcaf12f2055f0c693b27f4d0cd5f7a03d498a6d9289773b117590d794a43cd129366fd8e99222e4832f67b1653d54cf + languageName: node + linkType: hard + "@openrouter/ai-sdk-provider@npm:^1.2.0": version: 1.2.0 resolution: "@openrouter/ai-sdk-provider@npm:1.2.0" @@ -8835,6 +8944,13 @@ __metadata: languageName: node linkType: hard +"@types/statuses@npm:^2.0.4": + version: 2.0.6 + resolution: "@types/statuses@npm:2.0.6" + checksum: 10c0/dd88c220b0e2c6315686289525fd61472d2204d2e4bef4941acfb76bda01d3066f749ac74782aab5b537a45314fcd7d6261eefa40b6ec872691f5803adaa608d + languageName: node + linkType: hard + "@types/stylis@npm:4.2.5": version: 4.2.5 resolution: "@types/stylis@npm:4.2.5" @@ -9912,6 +10028,7 @@ __metadata: "@ai-sdk/mistral": "npm:^2.0.23" "@ai-sdk/openai": "patch:@ai-sdk/openai@npm%3A2.0.64#~/.yarn/patches/@ai-sdk-openai-npm-2.0.64-48f99f5bf3.patch" "@ai-sdk/perplexity": "npm:^2.0.17" + "@ai-sdk/test-server": "npm:^0.0.1" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.30#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.30-b50a299674.patch" "@anthropic-ai/sdk": "npm:^0.41.0" @@ -11656,6 +11773,13 @@ __metadata: languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -12093,6 +12217,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^1.0.2": + version: 1.0.2 + resolution: "cookie@npm:1.0.2" + checksum: 10c0/fd25fe79e8fbcfcaf6aa61cd081c55d144eeeba755206c058682257cb38c4bd6795c6620de3f064c740695bb65b7949ebb1db7a95e4636efb8357a335ad3f54b + languageName: node + linkType: hard + "copy-to-clipboard@npm:^3.3.3": version: 3.3.3 resolution: "copy-to-clipboard@npm:3.3.3" @@ -15631,6 +15762,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^16.8.1": + version: 16.12.0 + resolution: "graphql@npm:16.12.0" + checksum: 10c0/b6fffa4e8a4e4a9933ebe85e7470b346dbf49050c1a482fac5e03e4a1a7bed2ecd3a4c97e29f04457af929464bc5e4f2aac991090c2f320111eef26e902a5c75 + languageName: node + linkType: hard + "gray-matter@npm:^4.0.3": version: 4.0.3 resolution: "gray-matter@npm:4.0.3" @@ -15928,6 +16066,13 @@ __metadata: languageName: node linkType: hard +"headers-polyfill@npm:^4.0.2": + version: 4.0.3 + resolution: "headers-polyfill@npm:4.0.3" + checksum: 10c0/53e85b2c6385f8d411945fb890c5369f1469ce8aa32a6e8d28196df38568148de640c81cf88cbc7c67767103dd9acba48f4f891982da63178fc6e34560022afe + languageName: node + linkType: hard + "hls-video-element@npm:^1.5.6": version: 1.5.7 resolution: "hls-video-element@npm:1.5.7" @@ -16507,6 +16652,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.2.0": + version: 1.2.0 + resolution: "is-node-process@npm:1.2.0" + checksum: 10c0/5b24fda6776d00e42431d7bcd86bce81cb0b6cabeb944142fe7b077a54ada2e155066ad06dbe790abdb397884bdc3151e04a9707b8cd185099efbc79780573ed + languageName: node + linkType: hard + "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" @@ -19299,6 +19451,39 @@ __metadata: languageName: node linkType: hard +"msw@npm:^2.7.0": + version: 2.12.1 + resolution: "msw@npm:2.12.1" + dependencies: + "@inquirer/confirm": "npm:^5.0.0" + "@mswjs/interceptors": "npm:^0.40.0" + "@open-draft/deferred-promise": "npm:^2.2.0" + "@types/statuses": "npm:^2.0.4" + cookie: "npm:^1.0.2" + graphql: "npm:^16.8.1" + headers-polyfill: "npm:^4.0.2" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + path-to-regexp: "npm:^6.3.0" + picocolors: "npm:^1.1.1" + rettime: "npm:^0.7.0" + statuses: "npm:^2.0.2" + strict-event-emitter: "npm:^0.5.1" + tough-cookie: "npm:^6.0.0" + type-fest: "npm:^4.26.1" + until-async: "npm:^3.0.2" + yargs: "npm:^17.7.2" + peerDependencies: + typescript: ">= 4.8.x" + peerDependenciesMeta: + typescript: + optional: true + bin: + msw: cli/index.js + checksum: 10c0/822f4fc0cb2bdade39a67045d56b32fc7b15f30814a64c637a3c55d99358a4c1d61ed00d21fafafbbee320ad600e5a048d938b195e0cef5c59e016a040595176 + languageName: node + linkType: hard + "mustache@npm:^4.2.0": version: 4.2.0 resolution: "mustache@npm:4.2.0" @@ -19308,6 +19493,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "mute-stream@npm:3.0.0" + checksum: 10c0/12cdb36a101694c7a6b296632e6d93a30b74401873cf7507c88861441a090c71c77a58f213acadad03bc0c8fa186639dec99d68a14497773a8744320c136e701 + languageName: node + linkType: hard + "mux-embed@npm:5.9.0": version: 5.9.0 resolution: "mux-embed@npm:5.9.0" @@ -19919,6 +20111,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 10c0/5976ca7740349cb8c71bd3382e2a762b1aeca6f33dc984d9d896acdf3c61f78c3afcf1bfe9cc633a7b3c4b295ec94d292048f83ea2b2594fae4496656eba992c + languageName: node + linkType: hard + "oxlint-tsgolint@npm:^0.2.0": version: 0.2.0 resolution: "oxlint-tsgolint@npm:0.2.0" @@ -20318,6 +20517,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^6.3.0": + version: 6.3.0 + resolution: "path-to-regexp@npm:6.3.0" + checksum: 10c0/73b67f4638b41cde56254e6354e46ae3a2ebc08279583f6af3d96fe4664fc75788f74ed0d18ca44fa4a98491b69434f9eee73b97bb5314bd1b5adb700f5c18d6 + languageName: node + linkType: hard + "path-to-regexp@npm:^8.0.0": version: 8.2.0 resolution: "path-to-regexp@npm:8.2.0" @@ -22489,6 +22695,13 @@ __metadata: languageName: node linkType: hard +"rettime@npm:^0.7.0": + version: 0.7.0 + resolution: "rettime@npm:0.7.0" + checksum: 10c0/1460539d49415c37e46884bf1db7a5da974b239c1bd6976e1cf076fad169067dc8f55cd2572aec504433162f3627b6d8123eea977d110476258045d620bd051b + languageName: node + linkType: hard + "reusify@npm:^1.0.4": version: 1.1.0 resolution: "reusify@npm:1.1.0" @@ -23500,6 +23713,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:^2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f + languageName: node + linkType: hard + "std-env@npm:^3.9.0": version: 3.9.0 resolution: "std-env@npm:3.9.0" @@ -23541,6 +23761,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.5.1": + version: 0.5.1 + resolution: "strict-event-emitter@npm:0.5.1" + checksum: 10c0/f5228a6e6b6393c57f52f62e673cfe3be3294b35d6f7842fc24b172ae0a6e6c209fa83241d0e433fc267c503bc2f4ffdbe41a9990ff8ffd5ac425ec0489417f7 + languageName: node + linkType: hard + "strict-url-sanitise@npm:^0.0.1": version: 0.0.1 resolution: "strict-url-sanitise@npm:0.0.1" @@ -24249,6 +24476,13 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^7.0.17": + version: 7.0.17 + resolution: "tldts-core@npm:7.0.17" + checksum: 10c0/39dd6f5852f241c88391dc462dd236fa8241309a76dbf2486afdba0f172358260b16b98c126d1d06e1d9ee9015d83448ed7c4e2885e5e5c06c368f6503bb6a97 + languageName: node + linkType: hard + "tldts@npm:^6.1.32": version: 6.1.86 resolution: "tldts@npm:6.1.86" @@ -24260,6 +24494,17 @@ __metadata: languageName: node linkType: hard +"tldts@npm:^7.0.5": + version: 7.0.17 + resolution: "tldts@npm:7.0.17" + dependencies: + tldts-core: "npm:^7.0.17" + bin: + tldts: bin/cli.js + checksum: 10c0/0ef2a40058a11c27a5b310489009002e57cd0789c2cf383c04ecf808e1523d442d9d9688ac0337c64b261609478b7fd85ddcd692976c8f763747a5e1c7c1c451 + languageName: node + linkType: hard + "tmp-promise@npm:^3.0.2": version: 3.0.3 resolution: "tmp-promise@npm:3.0.3" @@ -24349,6 +24594,15 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^6.0.0": + version: 6.0.0 + resolution: "tough-cookie@npm:6.0.0" + dependencies: + tldts: "npm:^7.0.5" + checksum: 10c0/7b17a461e9c2ac0d0bea13ab57b93b4346d0b8c00db174c963af1e46e4ea8d04148d2a55f2358fc857db0c0c65208a98e319d0c60693e32e0c559a9d9cf20cb5 + languageName: node + linkType: hard + "tr46@npm:^5.1.0": version: 5.1.0 resolution: "tr46@npm:5.1.0" @@ -24635,6 +24889,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.26.1": + version: 4.41.0 + resolution: "type-fest@npm:4.41.0" + checksum: 10c0/f5ca697797ed5e88d33ac8f1fec21921839871f808dc59345c9cf67345bfb958ce41bd821165dbf3ae591cedec2bf6fe8882098dfdd8dc54320b859711a2c1e4 + languageName: node + linkType: hard + "type-fest@npm:^4.39.1": version: 4.40.0 resolution: "type-fest@npm:4.40.0" @@ -24996,6 +25257,13 @@ __metadata: languageName: node linkType: hard +"until-async@npm:^3.0.2": + version: 3.0.2 + resolution: "until-async@npm:3.0.2" + checksum: 10c0/61c8b03895dbe18fe3d90316d0a1894e0c131ea4b1673f6ce78eed993d0bb81bbf4b7adf8477e9ff7725782a76767eed9d077561cfc9f89b4a1ebe61f7c9828e + languageName: node + linkType: hard + "unzip-crx-3@npm:^0.2.0": version: 0.2.0 resolution: "unzip-crx-3@npm:0.2.0" @@ -25768,6 +26036,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^6.2.0": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/baad244e6e33335ea24e86e51868fe6823626e3a3c88d9a6674642afff1d34d9a154c917e74af8d845fd25d170c4ea9cf69a47133c3f3656e1252b3d462d9f6c + languageName: node + linkType: hard + "wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -26009,7 +26288,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.5.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -26050,6 +26329,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors-cjs@npm:^2.1.3": + version: 2.1.3 + resolution: "yoctocolors-cjs@npm:2.1.3" + checksum: 10c0/584168ef98eb5d913473a4858dce128803c4a6cd87c0f09e954fa01126a59a33ab9e513b633ad9ab953786ed16efdd8c8700097a51635aafaeed3fef7712fa79 + languageName: node + linkType: hard + "youtube-video-element@npm:^1.6.1": version: 1.6.2 resolution: "youtube-video-element@npm:1.6.2"