fix(openrouter): support GPT-5.1/5.2 reasoning effort 'none' for OpenRouter and improve error handling (#12088)

This commit is contained in:
Phantom 2025-12-24 14:18:41 +08:00 committed by GitHub
parent 89a6d817f1
commit d9171e0596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 198 additions and 28 deletions

View File

@ -14,7 +14,6 @@ import {
isDoubaoSeedAfter251015,
isDoubaoThinkingAutoModel,
isGemini3ThinkingTokenModel,
isGPT51SeriesModel,
isGrok4FastReasoningModel,
isOpenAIDeepResearchModel,
isOpenAIModel,
@ -32,7 +31,8 @@ import {
isSupportedThinkingTokenMiMoModel,
isSupportedThinkingTokenModel,
isSupportedThinkingTokenQwenModel,
isSupportedThinkingTokenZhipuModel
isSupportedThinkingTokenZhipuModel,
isSupportNoneReasoningEffortModel
} from '@renderer/config/models'
import { getStoreSetting } from '@renderer/hooks/useSettings'
import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService'
@ -74,9 +74,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
if (reasoningEffort === 'none') {
// openrouter: use reasoning
if (model.provider === SystemProviderIds.openrouter) {
// 'none' is not an available value for effort for now.
// I think they should resolve this issue soon, so I'll just go ahead and use this value.
if (isGPT51SeriesModel(model) && reasoningEffort === 'none') {
if (isSupportNoneReasoningEffortModel(model) && reasoningEffort === 'none') {
return { reasoning: { effort: 'none' } }
}
return { reasoning: { enabled: false, exclude: true } }
@ -120,8 +118,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
return { thinking: { type: 'disabled' } }
}
// Specially for GPT-5.1. Suppose this is a OpenAI Compatible provider
if (isGPT51SeriesModel(model)) {
// GPT 5.1, GPT 5.2, or newer
if (isSupportNoneReasoningEffortModel(model)) {
return {
reasoningEffort: 'none'
}

View File

@ -0,0 +1,139 @@
import type { Model } from '@renderer/types'
import { describe, expect, it, vi } from 'vitest'
import { isSupportNoneReasoningEffortModel } from '../openai'
// Mock store and settings to avoid initialization issues
vi.mock('@renderer/store', () => ({
__esModule: true,
default: {
getState: () => ({
llm: { providers: [] },
settings: {}
})
}
}))
vi.mock('@renderer/hooks/useStore', () => ({
getStoreProviders: vi.fn(() => [])
}))
const createModel = (overrides: Partial<Model> = {}): Model => ({
id: 'gpt-4o',
name: 'gpt-4o',
provider: 'openai',
group: 'OpenAI',
...overrides
})
describe('OpenAI Model Detection', () => {
describe('isSupportNoneReasoningEffortModel', () => {
describe('should return true for GPT-5.1 and GPT-5.2 reasoning models', () => {
it('returns true for GPT-5.1 base model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1' }))).toBe(true)
})
it('returns true for GPT-5.1 mini model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-mini-preview' }))).toBe(true)
})
it('returns true for GPT-5.1 preview model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true)
})
it('returns true for GPT-5.2 base model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2' }))).toBe(true)
})
it('returns true for GPT-5.2 mini model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-mini' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-mini-preview' }))).toBe(true)
})
it('returns true for GPT-5.2 preview model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-preview' }))).toBe(true)
})
})
describe('should return false for pro variants', () => {
it('returns false for GPT-5.1-pro models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-pro' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1-Pro' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-pro-preview' }))).toBe(false)
})
it('returns false for GPT-5.2-pro models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-pro' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2-Pro' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-pro-preview' }))).toBe(false)
})
})
describe('should return false for chat variants', () => {
it('returns false for GPT-5.1-chat models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-chat' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1-Chat' }))).toBe(false)
})
it('returns false for GPT-5.2-chat models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-chat' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2-Chat' }))).toBe(false)
})
})
describe('should return false for GPT-5 series (non-5.1/5.2)', () => {
it('returns false for GPT-5 base model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5' }))).toBe(false)
})
it('returns false for GPT-5 pro model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5-pro' }))).toBe(false)
})
it('returns false for GPT-5 preview model', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5-preview' }))).toBe(false)
})
})
describe('should return false for other OpenAI models', () => {
it('returns false for GPT-4 models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-4o' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false)
})
it('returns false for o1 models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o1' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o1-mini' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o1-preview' }))).toBe(false)
})
it('returns false for o3 models', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o3' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o3-mini' }))).toBe(false)
})
})
describe('edge cases', () => {
it('handles models with version suffixes', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-2025-01-01' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-latest' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-pro-2025-01-01' }))).toBe(false)
})
it('handles models with OpenRouter prefixes', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.1' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.2-mini' }))).toBe(true)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.1-pro' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.1-chat' }))).toBe(false)
})
it('handles mixed case with chat and pro', () => {
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1-CHAT' }))).toBe(false)
expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2-PRO' }))).toBe(false)
})
})
})
})

View File

@ -77,6 +77,34 @@ export function isSupportVerbosityModel(model: Model): boolean {
)
}
/**
* Determines if a model supports the "none" reasoning effort parameter.
*
* This applies to GPT-5.1 and GPT-5.2 series reasoning models (non-chat, non-pro variants).
* These models allow setting reasoning_effort to "none" to skip reasoning steps.
*
* @param model - The model to check
* @returns true if the model supports "none" reasoning effort, false otherwise
*
* @example
* ```ts
* // Returns true
* isSupportNoneReasoningEffortModel({ id: 'gpt-5.1', provider: 'openai' })
* isSupportNoneReasoningEffortModel({ id: 'gpt-5.2-mini', provider: 'openai' })
*
* // Returns false
* isSupportNoneReasoningEffortModel({ id: 'gpt-5.1-pro', provider: 'openai' })
* isSupportNoneReasoningEffortModel({ id: 'gpt-5.1-chat', provider: 'openai' })
* isSupportNoneReasoningEffortModel({ id: 'gpt-5-pro', provider: 'openai' })
* ```
*/
export function isSupportNoneReasoningEffortModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id)
return (
(isGPT51SeriesModel(model) || isGPT52SeriesModel(model)) && !modelId.includes('chat') && !modelId.includes('pro')
)
}
export function isOpenAIChatCompletionOnlyModel(model: Model): boolean {
if (!model) {
return false

View File

@ -30,8 +30,7 @@ import {
} from '@renderer/types'
import { getFileExtension, isTextFile, runAsyncFunction, uuid } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { isAbortError } from '@renderer/utils/error'
import { formatErrorMessage } from '@renderer/utils/error'
import { formatErrorMessageWithPrefix, isAbortError } from '@renderer/utils/error'
import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input'
import {
createInputScrollHandler,
@ -181,7 +180,7 @@ const TranslatePage: FC = () => {
window.toast.info(t('translate.info.aborted'))
} else {
logger.error('Failed to translate text', e as Error)
window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.error.failed')))
}
setTranslating(false)
return
@ -202,11 +201,11 @@ const TranslatePage: FC = () => {
await saveTranslateHistory(text, translated, actualSourceLanguage.langCode, actualTargetLanguage.langCode)
} catch (e) {
logger.error('Failed to save translate history', e as Error)
window.toast.error(t('translate.history.error.save') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.history.error.save')))
}
} catch (e) {
logger.error('Failed to translate', e as Error)
window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.error.unknown')))
}
},
[autoCopy, copy, dispatch, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating]
@ -266,7 +265,7 @@ const TranslatePage: FC = () => {
await translate(text, actualSourceLanguage, actualTargetLanguage)
} catch (error) {
logger.error('Translation error:', error as Error)
window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(error))
window.toast.error(formatErrorMessageWithPrefix(error, t('translate.error.failed')))
return
} finally {
setTranslating(false)
@ -427,7 +426,7 @@ const TranslatePage: FC = () => {
setAutoDetectionMethod(method)
} catch (e) {
logger.error('Failed to update auto detection method setting.', e as Error)
window.toast.error(t('translate.error.detect.update_setting') + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.error.detect.update_setting')))
}
}
@ -498,7 +497,7 @@ const TranslatePage: FC = () => {
isText = await isTextFile(file.path)
} catch (e) {
logger.error('Failed to check file type.', e as Error)
window.toast.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.check_type')))
return
}
} else {
@ -530,11 +529,11 @@ const TranslatePage: FC = () => {
setText(text + result)
} catch (e) {
logger.error('Failed to read file.', e as Error)
window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.unknown')))
}
} catch (e) {
logger.error('Failed to read file.', e as Error)
window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.unknown')))
}
}
const promise = _readFile()
@ -578,7 +577,7 @@ const TranslatePage: FC = () => {
await processFile(file)
} catch (e) {
logger.error('Unknown error when selecting file.', e as Error)
window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.unknown')))
} finally {
clearFiles()
setIsProcessing(false)

View File

@ -42,7 +42,7 @@ export const translateText = async (
abortKey?: string,
options?: TranslateOptions
) => {
let abortError
let error
const assistantSettings: Partial<AssistantSettings> | undefined = options
? { reasoning_effort: options?.reasoningEffort }
: undefined
@ -58,8 +58,8 @@ export const translateText = async (
} else if (chunk.type === ChunkType.TEXT_COMPLETE) {
completed = true
} else if (chunk.type === ChunkType.ERROR) {
error = chunk.error
if (isAbortError(chunk.error)) {
abortError = chunk.error
completed = true
}
}
@ -84,8 +84,8 @@ export const translateText = async (
}
}
if (abortError) {
throw abortError
if (error !== undefined && !isAbortError(error)) {
throw error
}
const trimmedText = translatedText.trim()

View File

@ -1,3 +1,4 @@
import { loggerService } from '@logger'
import type { McpError } from '@modelcontextprotocol/sdk/types.js'
import type { AgentServerError } from '@renderer/types'
import { AgentServerErrorSchema } from '@renderer/types'
@ -20,7 +21,7 @@ import { ZodError } from 'zod'
import { parseJSON } from './json'
import { safeSerialize } from './serialize'
// const logger = loggerService.withContext('Utils:error')
const logger = loggerService.withContext('Utils:error')
export function getErrorDetails(err: any, seen = new WeakSet()): any {
// Handle circular references
@ -65,11 +66,16 @@ export function formatErrorMessage(error: unknown): string {
delete detailedError?.stack
delete detailedError?.request_id
const formattedJson = JSON.stringify(detailedError, null, 2)
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
return detailedError.message ? detailedError.message : `Error Details:\n${formattedJson}`
if (detailedError) {
const formattedJson = JSON.stringify(detailedError, null, 2)
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
return detailedError.message ? detailedError.message : `Error Details:\n${formattedJson}`
} else {
logger.warn('Get detailed error failed.')
return ''
}
}
export function getErrorMessage(error: unknown): string {