diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index cfc908754..d839da896 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -10,7 +10,7 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - getThinkModelType, + getModelSupportedReasoningEffortOptions, isDeepSeekHybridInferenceModel, isDoubaoThinkingAutoModel, isGPT5SeriesModel, @@ -33,7 +33,6 @@ import { isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenZhipuModel, isVisionModel, - MODEL_SUPPORTED_REASONING_EFFORT, ZHIPU_RESULT_TOKENS } from '@renderer/config/models' import { mapLanguageToQwenMTModel } from '@renderer/config/translate' @@ -304,16 +303,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // Grok models/Perplexity models/OpenAI models if (isSupportedReasoningEffortModel(model)) { // 检查模型是否支持所选选项 - const modelType = getThinkModelType(model) - const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType] - if (supportedOptions.includes(reasoningEffort)) { + const supportedOptions = getModelSupportedReasoningEffortOptions(model) + if (supportedOptions?.includes(reasoningEffort)) { return { reasoning_effort: reasoningEffort } } else { // 如果不支持,fallback到第一个支持的值 return { - reasoning_effort: supportedOptions[0] + reasoning_effort: supportedOptions?.[0] } } } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 996d67676..f18240571 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -8,7 +8,7 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - getThinkModelType, + getModelSupportedReasoningEffortOptions, isDeepSeekHybridInferenceModel, isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, @@ -30,8 +30,7 @@ import { isSupportedThinkingTokenHunyuanModel, isSupportedThinkingTokenModel, isSupportedThinkingTokenQwenModel, - isSupportedThinkingTokenZhipuModel, - MODEL_SUPPORTED_REASONING_EFFORT + isSupportedThinkingTokenZhipuModel } from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' @@ -330,16 +329,15 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin // Grok models/Perplexity models/OpenAI models, use reasoning_effort if (isSupportedReasoningEffortModel(model)) { // 检查模型是否支持所选选项 - const modelType = getThinkModelType(model) - const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType] - if (supportedOptions.includes(reasoningEffort)) { + const supportedOptions = getModelSupportedReasoningEffortOptions(model) + if (supportedOptions?.includes(reasoningEffort)) { return { reasoningEffort } } else { // 如果不支持,fallback到第一个支持的值 return { - reasoningEffort: supportedOptions[0] + reasoningEffort: supportedOptions?.[0] } } } diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 350758c3e..5a60676b6 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -5,6 +5,7 @@ import { isEmbeddingModel, isRerankModel } from '../embedding' import { isOpenAIReasoningModel, isSupportedReasoningEffortOpenAIModel } from '../openai' import { findTokenLimit, + getModelSupportedReasoningEffortOptions, getThinkModelType, isClaude4SeriesModel, isClaude45ReasoningModel, @@ -1651,3 +1652,355 @@ describe('isGemini3ThinkingTokenModel', () => { ).toBe(false) }) }) + +describe('getModelSupportedReasoningEffortOptions', () => { + describe('Edge cases', () => { + it('should return undefined for undefined model', () => { + expect(getModelSupportedReasoningEffortOptions(undefined)).toBeUndefined() + }) + + it('should return undefined for null model', () => { + expect(getModelSupportedReasoningEffortOptions(null)).toBeUndefined() + }) + + it('should return undefined for non-reasoning models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-4o' }))).toBeUndefined() + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'claude-3-opus' }))).toBeUndefined() + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'random-model' }))).toBeUndefined() + }) + }) + + describe('OpenAI models', () => { + it('should return correct options for o-series models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'o3' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'o3-mini' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'o4' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-oss-reasoning' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for deep research models', () => { + // Note: Deep research models need to be actual OpenAI reasoning models to be detected + // 'sonar-deep-research' from Perplexity is the primary deep research model + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'sonar-deep-research' }))).toEqual(['medium']) + }) + + it('should return correct options for GPT-5 models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-preview' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for GPT-5 Pro models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-pro' }))).toEqual(['high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-pro-preview' }))).toEqual(['high']) + }) + + it('should return correct options for GPT-5 Codex models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-codex' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-codex-mini' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for GPT-5.1 models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-preview' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-mini' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for GPT-5.1 Codex models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-codex' }))).toEqual([ + 'none', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-codex-mini' }))).toEqual([ + 'none', + 'medium', + 'high' + ]) + }) + }) + + describe('Grok models', () => { + it('should return correct options for Grok 3 mini', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'grok-3-mini' }))).toEqual(['low', 'high']) + }) + + it('should return correct options for Grok 4 Fast', () => { + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'grok-4-fast', provider: 'openrouter' })) + ).toEqual(['none', 'auto']) + }) + }) + + describe('Gemini models', () => { + it('should return correct options for Gemini Flash models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-flash-latest' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high', + 'auto' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-flash-latest' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high', + 'auto' + ]) + }) + + it('should return correct options for Gemini Pro models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-pro-latest' }))).toEqual([ + 'low', + 'medium', + 'high', + 'auto' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-pro-latest' }))).toEqual([ + 'low', + 'medium', + 'high', + 'auto' + ]) + }) + + it('should return correct options for Gemini 3 models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-flash' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-pro-preview' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + }) + }) + + describe('Qwen models', () => { + it('should return correct options for controllable Qwen models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen-plus' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen-turbo' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen-flash' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen3-8b' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return undefined for always-thinking Qwen models', () => { + // These models always think and don't support thinking token control + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen3-thinking' }))).toBeUndefined() + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen3-vl-235b-thinking' }))).toBeUndefined() + }) + }) + + describe('Doubao models', () => { + it('should return correct options for auto-thinking Doubao models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-seed-1.6' }))).toEqual([ + 'none', + 'auto', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-1-5-thinking-pro-m' }))).toEqual([ + 'none', + 'auto', + 'high' + ]) + }) + + it('should return correct options for Doubao models after 251015', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-seed-1-6-251015' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-seed-1-6-lite-251015' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for other Doubao thinking models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toEqual([ + 'none', + 'high' + ]) + }) + }) + + describe('Other providers', () => { + it('should return correct options for Hunyuan models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'hunyuan-a13b' }))).toEqual(['none', 'auto']) + }) + + it('should return correct options for Zhipu models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'glm-4.5' }))).toEqual(['none', 'auto']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'glm-4.6' }))).toEqual(['none', 'auto']) + }) + + it('should return correct options for Perplexity models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'sonar-deep-research' }))).toEqual(['medium']) + }) + + it('should return correct options for DeepSeek hybrid models', () => { + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'deepseek-v3.1', provider: 'deepseek' })) + ).toEqual(['none', 'auto']) + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'deepseek-v3.2', provider: 'openrouter' })) + ).toEqual(['none', 'auto']) + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'deepseek-chat', provider: 'deepseek' })) + ).toEqual(['none', 'auto']) + }) + }) + + describe('Name-based fallback', () => { + it('should fall back to name when id does not match', () => { + // Grok 4 Fast requires openrouter provider to be recognized + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'custom-id', + name: 'grok-4-fast', + provider: 'openrouter' + }) + ) + ).toEqual(['none', 'auto']) + + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'custom-id', + name: 'gpt-5.1' + }) + ) + ).toEqual(['none', 'low', 'medium', 'high']) + + // Qwen models work well for name-based fallback + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'custom-id', + name: 'qwen-plus' + }) + ) + ).toEqual(['none', 'low', 'medium', 'high']) + }) + + it('should use id result when id matches', () => { + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'gpt-5.1', + name: 'Different Name' + }) + ) + ).toEqual(['none', 'low', 'medium', 'high']) + + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'o3-mini', + name: 'Some other name' + }) + ) + ).toEqual(['low', 'medium', 'high']) + }) + }) + + describe('Case sensitivity', () => { + it('should handle case insensitive model IDs', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'GPT-5.1' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'O3-MINI' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'Gemini-2.5-Flash-Latest' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high', + 'auto' + ]) + }) + }) + + describe('Integration with MODEL_SUPPORTED_OPTIONS', () => { + it('should return values that match MODEL_SUPPORTED_OPTIONS configuration', () => { + // Verify that returned values match the configuration + const model = createModel({ id: 'o3' }) + const result = getModelSupportedReasoningEffortOptions(model) + expect(result).toEqual(MODEL_SUPPORTED_OPTIONS.o) + + const gpt5Model = createModel({ id: 'gpt-5' }) + const gpt5Result = getModelSupportedReasoningEffortOptions(gpt5Model) + expect(gpt5Result).toEqual(MODEL_SUPPORTED_OPTIONS.gpt5) + + const geminiModel = createModel({ id: 'gemini-2.5-flash-latest' }) + const geminiResult = getModelSupportedReasoningEffortOptions(geminiModel) + expect(geminiResult).toEqual(MODEL_SUPPORTED_OPTIONS.gemini) + }) + }) +}) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index d06d58a08..a5e47ef3b 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -1,6 +1,7 @@ import type { Model, ReasoningEffortConfig, + ReasoningEffortOption, SystemProviderId, ThinkingModelType, ThinkingOptionConfig @@ -28,7 +29,7 @@ export const REASONING_REGEX = // 模型类型到支持的reasoning_effort的映射表 // TODO: refactor this. too many identical options -export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { +export const MODEL_SUPPORTED_REASONING_EFFORT = { default: ['low', 'medium', 'high'] as const, o: ['low', 'medium', 'high'] as const, openai_deep_research: ['medium'] as const, @@ -54,7 +55,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { zhipu: ['auto'] as const, perplexity: ['low', 'medium', 'high'] as const, deepseek_hybrid: ['auto'] as const -} as const +} as const satisfies ReasoningEffortConfig // 模型类型到支持选项的映射表 export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { @@ -166,6 +167,64 @@ export const getThinkModelType = (model: Model): ThinkingModelType => { } } +const _getModelSupportedReasoningEffortOptions = (model: Model): ReasoningEffortOption[] | undefined => { + if (!isSupportedReasoningEffortModel(model) && !isSupportedThinkingTokenModel(model)) { + return undefined + } + // use private function to avoid redundant function calling + const thinkingType = _getThinkModelType(model) + return MODEL_SUPPORTED_OPTIONS[thinkingType] +} + +/** + * Gets the supported reasoning effort options for a given model. + * + * This function determines which reasoning effort levels a model supports based on its type. + * It works with models that support either `reasoning_effort` parameter (like OpenAI o-series) + * or thinking token control (like Claude, Gemini, Qwen, etc.). + * + * The function implements a fallback mechanism: it first checks the model's `id`, and if that + * doesn't match any known patterns, it falls back to checking the model's `name`. + * + * @param model - The model to check for reasoning effort support. Can be undefined or null. + * @returns An array of supported reasoning effort options, or undefined if: + * - The model is null/undefined + * - The model doesn't support reasoning effort or thinking tokens + * + * @example + * // OpenAI o-series models support low, medium, high + * getModelSupportedReasoningEffortOptions({ id: 'o3-mini', ... }) + * // Returns: ['low', 'medium', 'high'] + * + * @example + * // GPT-5.1 models support none, low, medium, high + * getModelSupportedReasoningEffortOptions({ id: 'gpt-5.1', ... }) + * // Returns: ['none', 'low', 'medium', 'high'] + * + * @example + * // Gemini Flash models support none, low, medium, high, auto + * getModelSupportedReasoningEffortOptions({ id: 'gemini-2.5-flash-latest', ... }) + * // Returns: ['none', 'low', 'medium', 'high', 'auto'] + * + * @example + * // Non-reasoning models return undefined + * getModelSupportedReasoningEffortOptions({ id: 'gpt-4o', ... }) + * // Returns: undefined + * + * @example + * // Name fallback when id doesn't match + * getModelSupportedReasoningEffortOptions({ id: 'custom-id', name: 'gpt-5.1', ... }) + * // Returns: ['none', 'low', 'medium', 'high'] + */ +export const getModelSupportedReasoningEffortOptions = ( + model: Model | undefined | null +): ReasoningEffortOption[] | undefined => { + if (!model) return undefined + + const { idResult, nameResult } = withModelIdAndNameAsId(model, _getModelSupportedReasoningEffortOptions) + return idResult ?? nameResult +} + function _isSupportedThinkingTokenModel(model: Model): boolean { // Specifically for DeepSeek V3.1. White list for now if (isDeepSeekHybridInferenceModel(model)) { @@ -201,12 +260,14 @@ function _isSupportedThinkingTokenModel(model: Model): boolean { } /** 用于判断是否支持控制思考,但不一定以reasoning_effort的方式 */ +// TODO: rename it export function isSupportedThinkingTokenModel(model?: Model): boolean { if (!model) return false const { idResult, nameResult } = withModelIdAndNameAsId(model, _isSupportedThinkingTokenModel) return idResult || nameResult } +// TODO: it should be merged in isSupportedThinkingTokenModel export function isSupportedReasoningEffortModel(model?: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 5ae3710e8..233b3c19c 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -6,6 +6,7 @@ import { MAX_CONTEXT_COUNT, UNLIMITED_CONTEXT_COUNT } from '@renderer/config/constant' +import { getModelSupportedReasoningEffortOptions } from '@renderer/config/models' import { isQwenMTModel } from '@renderer/config/models/qwen' import { UNKNOWN } from '@renderer/config/translate' import { getStoreProviders } from '@renderer/hooks/useStore' @@ -54,7 +55,11 @@ export function getDefaultAssistant(): Assistant { } } -export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage, text: string): TranslateAssistant { +export function getDefaultTranslateAssistant( + targetLanguage: TranslateLanguage, + text: string, + _settings?: Partial +): TranslateAssistant { const model = getTranslateModel() const assistant: Assistant = getDefaultAssistant() @@ -68,9 +73,12 @@ export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage, throw new Error('Unknown target language') } + const reasoningEffort = getModelSupportedReasoningEffortOptions(model)?.[0] const settings = { - temperature: 0.7 - } + temperature: 0.7, + reasoning_effort: reasoningEffort, + ..._settings + } satisfies Partial const getTranslateContent = (model: Model, text: string, targetLanguage: TranslateLanguage): string => { if (isQwenMTModel(model)) { diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index a5abb2bae..328f1a8ed 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -1,8 +1,10 @@ import { loggerService } from '@logger' import { db } from '@renderer/databases' import type { + AssistantSettings, CustomTranslateLanguage, FetchChatCompletionRequestOptions, + ReasoningEffortOption, TranslateHistory, TranslateLanguage, TranslateLanguageCode @@ -20,6 +22,10 @@ import { getDefaultTranslateAssistant } from './AssistantService' const logger = loggerService.withContext('TranslateService') +type TranslateOptions = { + reasoningEffort: ReasoningEffortOption +} + /** * 翻译文本到目标语言 * @param text - 需要翻译的文本内容 @@ -33,10 +39,14 @@ export const translateText = async ( text: string, targetLanguage: TranslateLanguage, onResponse?: (text: string, isComplete: boolean) => void, - abortKey?: string + abortKey?: string, + options?: TranslateOptions ) => { let abortError - const assistant = getDefaultTranslateAssistant(targetLanguage, text) + const assistantSettings: Partial | undefined = options + ? { reasoning_effort: options?.reasoningEffort } + : undefined + const assistant = getDefaultTranslateAssistant(targetLanguage, text, assistantSettings) const signal = abortKey ? readyToAbort(abortKey) : undefined