Compare commits

..

1 Commits

Author SHA1 Message Date
suyao
163ae9c085 fix: properly handle thinking mode switching in qwenThinkingMiddleware
Fix issue #11612 where Ollama couldn't disable Qwen3 thinking mode.
The middleware now properly removes existing /think or /no_think suffixes
before adding the correct suffix based on current reasoning_effort setting.

Previously, messages with existing suffixes were skipped, causing thinking
mode to persist even when user switched back to non-thinking mode.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 20:43:14 +08:00
9 changed files with 47 additions and 61 deletions

View File

@@ -9,7 +9,6 @@ import {
import { REFERENCE_PROMPT } from '@renderer/config/prompts'
import { getLMStudioKeepAliveTime } from '@renderer/hooks/useLMStudio'
import { getAssistantSettings } from '@renderer/services/AssistantService'
import type { RootState } from '@renderer/store'
import type {
Assistant,
GenerateImageParams,
@@ -246,20 +245,23 @@ export abstract class BaseApiClient<
protected getVerbosity(model?: Model): OpenAIVerbosity {
try {
const state = window.store?.getState() as RootState
const state = window.store?.getState()
const verbosity = state?.settings?.openAI?.verbosity
// If model is provided, check if the verbosity is supported by the model
if (model) {
const supportedVerbosity = getModelSupportedVerbosity(model)
// Use user's verbosity if supported, otherwise use the first supported option
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
if (verbosity && ['low', 'medium', 'high'].includes(verbosity)) {
// If model is provided, check if the verbosity is supported by the model
if (model) {
const supportedVerbosity = getModelSupportedVerbosity(model)
// Use user's verbosity if supported, otherwise use the first supported option
return supportedVerbosity.includes(verbosity) ? verbosity : supportedVerbosity[0]
}
return verbosity
}
return verbosity
} catch (error) {
logger.warn('Failed to get verbosity from state. Fallback to undefined.', error as Error)
return undefined
logger.warn('Failed to get verbosity from state:', error as Error)
}
return 'medium'
}
protected getTimeout(model: Model) {

View File

@@ -32,6 +32,7 @@ import {
isSupportedThinkingTokenModel,
isSupportedThinkingTokenQwenModel,
isSupportedThinkingTokenZhipuModel,
isSupportVerbosityModel,
isVisionModel,
MODEL_SUPPORTED_REASONING_EFFORT,
ZHIPU_RESULT_TOKENS
@@ -713,8 +714,13 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
...modalities,
// groq 有不同的 service tier 配置,不符合 openai 接口类型
service_tier: this.getServiceTier(model) as OpenAIServiceTier,
// verbosity. getVerbosity ensure the returned value is valid.
verbosity: this.getVerbosity(model),
...(isSupportVerbosityModel(model)
? {
text: {
verbosity: this.getVerbosity(model)
}
}
: {}),
...this.getProviderSpecificParameters(assistant, model),
...reasoningEffort,
// ...getOpenAIWebSearchParams(model, enableWebSearch),

View File

@@ -23,8 +23,10 @@ export function qwenThinkingMiddleware(enableThinking: boolean): LanguageModelMi
// Process content array
if (Array.isArray(message.content)) {
for (const part of message.content) {
if (part.type === 'text' && !part.text.endsWith('/think') && !part.text.endsWith('/no_think')) {
part.text += suffix
if (part.type === 'text') {
// Remove any existing thinking suffixes first, then add the correct one
const cleanText = part.text.replace(/\s*\/think\s*$/, '').replace(/\s*\/no_think\s*$/, '')
part.text = cleanText + suffix
}
}
}

View File

@@ -222,22 +222,18 @@ describe('model utils', () => {
describe('getModelSupportedVerbosity', () => {
it('returns only "high" for GPT-5 Pro models', () => {
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, null, 'high'])
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([
undefined,
null,
'high'
])
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, 'high'])
expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([undefined, 'high'])
})
it('returns all levels for non-Pro GPT-5 models', () => {
const previewModel = createModel({ id: 'gpt-5-preview' })
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, null, 'low', 'medium', 'high'])
expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high'])
})
it('returns all levels for GPT-5.1 models', () => {
const gpt51Model = createModel({ id: 'gpt-5.1-preview' })
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, null, 'low', 'medium', 'high'])
expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, 'low', 'medium', 'high'])
})
it('returns only undefined for non-GPT-5 models', () => {

View File

@@ -10,8 +10,7 @@ import {
isGPT51SeriesModel,
isOpenAIChatCompletionOnlyModel,
isOpenAIOpenWeightModel,
isOpenAIReasoningModel,
isSupportVerbosityModel
isOpenAIReasoningModel
} from './openai'
import { isQwenMTModel } from './qwen'
import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision'
@@ -155,10 +154,10 @@ const MODEL_SUPPORTED_VERBOSITY: readonly {
* For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported.
* For GPT-5.1 series models, 'low', 'medium', and 'high' are supported.
* @param model - The model to check
* @returns An array of supported verbosity levels, always including `undefined` as the first element and `null` when applicable
* @returns An array of supported verbosity levels, always including `undefined` as the first element
*/
export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => {
if (!model || !isSupportVerbosityModel(model)) {
if (!model) {
return [undefined]
}
@@ -166,7 +165,7 @@ export const getModelSupportedVerbosity = (model: Model | undefined | null): Ope
for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) {
if (validator(model)) {
supportedValues = [null, ...values]
supportedValues = [...values]
break
}
}

View File

@@ -24,12 +24,12 @@ import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
type VerbosityOption = {
value: NonNullable<OpenAIVerbosity> | 'undefined' | 'null'
value: NonNullable<OpenAIVerbosity> | 'undefined'
label: string
}
type SummaryTextOption = {
value: NonNullable<OpenAISummaryText> | 'undefined' | 'null'
value: NonNullable<OpenAISummaryText> | 'undefined'
label: string
}
@@ -85,10 +85,6 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
value: 'undefined',
label: t('common.ignore')
},
{
value: 'null',
label: t('common.off')
},
{
value: 'auto',
label: t('settings.openai.summary_text_mode.auto')
@@ -109,10 +105,6 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
value: 'undefined',
label: t('common.ignore')
},
{
value: 'null',
label: t('common.off')
},
{
value: 'low',
label: t('settings.openai.verbosity.low')
@@ -211,9 +203,9 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
</Tooltip>
</SettingRowTitleSmall>
<Selector
value={toOptionValue(summaryText)}
value={summaryText}
onChange={(value) => {
setSummaryText(toRealValue(value))
setSummaryText(value as OpenAISummaryText)
}}
options={summaryTextOptions}
/>
@@ -230,9 +222,9 @@ const OpenAISettingsGroup: FC<Props> = ({ model, providerId, SettingGroup, Setti
</Tooltip>
</SettingRowTitleSmall>
<Selector
value={toOptionValue(verbosity)}
value={verbosity}
onChange={(value) => {
setVerbosity(toRealValue(value))
setVerbosity(value as OpenAIVerbosity)
}}
options={verbosityOptions}
/>

View File

@@ -2906,23 +2906,6 @@ const migrateConfig = {
logger.error('migrate 179 error', error as Error)
return state
}
},
'180': (state: RootState) => {
try {
// @ts-expect-error
if (state.settings.openAI.summaryText === 'undefined') {
state.settings.openAI.summaryText = undefined
}
// @ts-expect-error
if (state.settings.openAI.verbosity === 'undefined') {
state.settings.openAI.summaryText = undefined
}
logger.info('migrate 180 success')
return state
} catch (error) {
logger.error('migrate 180 error', error as Error)
return state
}
}
}

View File

@@ -1,5 +1,5 @@
import type OpenAI from '@cherrystudio/openai'
import type { NotUndefined } from '@types'
import type { NotNull, NotUndefined } from '@types'
import type { ImageModel, LanguageModel } from 'ai'
import type { generateObject, generateText, ModelMessage, streamObject, streamText } from 'ai'
import * as z from 'zod'
@@ -32,15 +32,17 @@ export type GenerateObjectParams = Omit<Parameters<typeof generateObject>[0], 'm
export type AiSdkModel = LanguageModel | ImageModel
// The original type unite both undefined and null.
// I pick undefined as the unique falsy type since they seem like share the same meaning according to OpenAI API docs.
// Parameter would not be passed into request if it's undefined.
export type OpenAIVerbosity = OpenAI.Responses.ResponseTextConfig['verbosity']
export type OpenAIVerbosity = NotNull<OpenAI.Responses.ResponseTextConfig['verbosity']>
export type ValidOpenAIVerbosity = NotUndefined<OpenAIVerbosity>
export type OpenAIReasoningEffort = OpenAI.ReasoningEffort
// The original type unite both undefined and null.
// I pick undefined as the unique falsy type since they seem like share the same meaning according to OpenAI API docs.
// Parameter would not be passed into request if it's undefined.
export type OpenAISummaryText = OpenAI.Reasoning['summary']
export type OpenAISummaryText = NotNull<OpenAI.Reasoning['summary']>
const AiSdkParamsSchema = z.enum([
'maxOutputTokens',

View File

@@ -128,6 +128,10 @@ export type OpenAIExtraBody = {
source_lang: 'auto'
target_lang: string
}
// for gpt-5 series models verbosity control
text?: {
verbosity?: 'low' | 'medium' | 'high'
}
}
// image is for openrouter. audio is ignored for now
export type OpenAIModality = OpenAI.ChatCompletionModality | 'image'