From ae11490f87b50d67be36484e3b8ca08a90e86057 Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 1 Mar 2025 21:22:12 +0800 Subject: [PATCH] feat: Add reasoning effort control for Claude 3.7 (#2540) * feat: Add reasoning effort control for Anthropic models with Anthropic Provider and OpenAI Provider - Add reasoning effort settings with low/medium/high options - Implement reasoning effort for Claude 3.7 Sonnet models - Update localization tips for reasoning effort - Enhance provider handling of reasoning effort parameters * fix: Extract o1-mini and o1-preview * fix: Add OpenAI o-series model to ReasoningModel * fix: Improve OpenAI o-series model detection * style: Reduce font size * fix: Add default token handling using DEFAULT_MAX_TOKENS * fix: Add beta parameter for Anthropic reasoning models --- package.json | 2 +- src/renderer/src/config/models.ts | 8 ++ src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/ja-jp.json | 2 +- src/renderer/src/i18n/locales/ru-ru.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 2 +- .../src/pages/home/Tabs/SettingsTab.tsx | 69 ++++++++++- .../AssistantModelSettings.tsx | 2 + .../src/providers/AnthropicProvider.ts | 110 +++++++++++++++++- src/renderer/src/providers/OpenAIProvider.ts | 55 ++++++++- yarn.lock | 13 +-- 12 files changed, 244 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 397cda47..960106ed 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "webdav": "4.11.4" }, "devDependencies": { - "@anthropic-ai/sdk": "^0.24.3", + "@anthropic-ai/sdk": "^0.38.0", "@electron-toolkit/eslint-config-prettier": "^2.0.0", "@electron-toolkit/eslint-config-ts": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1", diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index e6416b8d..a4be203c 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -1839,6 +1839,10 @@ export function isVisionModel(model: Model): boolean { return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false } +export function isOpenAIoSeries(model: Model): boolean { + return ['o1', 'o1-2024-12-17'].includes(model.id) || model.id.includes('o3') +} + export function isReasoningModel(model: Model): boolean { if (!model) { return false @@ -1848,6 +1852,10 @@ export function isReasoningModel(model: Model): boolean { return REASONING_REGEX.test(model.name) || model.type?.includes('reasoning') || false } + if (model.id.includes('claude-3-7-sonnet') || model.id.includes('claude-3.7-sonnet') || isOpenAIoSeries(model)) { + return true + } + return REASONING_REGEX.test(model.id) || model.type?.includes('reasoning') || false } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 09ca2589..58977755 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -52,7 +52,7 @@ "settings.reasoning_effort.low": "low", "settings.reasoning_effort.medium": "medium", "settings.reasoning_effort.off": "off", - "settings.reasoning_effort.tip": "Only supports reasoning models", + "settings.reasoning_effort.tip": "Only supports OpenAI o-series and Anthropic reasoning models", "title": "Assistants" }, "auth": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index fabf93f9..f92212e3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -52,7 +52,7 @@ "settings.reasoning_effort.low": "短い", "settings.reasoning_effort.medium": "中程度", "settings.reasoning_effort.off": "オフ", - "settings.reasoning_effort.tip": "この設定は推論モデルのみサポートしています", + "settings.reasoning_effort.tip": "OpenAIのoシリーズとAnthropicの推論モデルのみサポートしています", "title": "アシスタント" }, "auth": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 201e18cd..df88a529 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -52,7 +52,7 @@ "settings.reasoning_effort.low": "Короткая", "settings.reasoning_effort.medium": "Средняя", "settings.reasoning_effort.off": "Выключено", - "settings.reasoning_effort.tip": "Эта настройка поддерживается только моделями с рассуждением", + "settings.reasoning_effort.tip": "Поддерживается только моделями с рассуждением OpenAI o-series и Anthropic", "title": "Ассистенты" }, "auth": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 7b18b9a5..a946d876 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -52,7 +52,7 @@ "settings.reasoning_effort.low": "短", "settings.reasoning_effort.medium": "中", "settings.reasoning_effort.off": "关", - "settings.reasoning_effort.tip": "该设置仅支持推理模型", + "settings.reasoning_effort.tip": "仅支持 OpenAI o-series 和 Anthropic 推理模型", "title": "助手" }, "auth": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 279f0e78..a88733de 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -52,7 +52,7 @@ "settings.reasoning_effort.low": "短", "settings.reasoning_effort.medium": "中", "settings.reasoning_effort.off": "關", - "settings.reasoning_effort.tip": "該設置僅支持推理模型", + "settings.reasoning_effort.tip": "僅支持OpenAI o系列和Anthropic推理模型", "title": "助手" }, "auth": { diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 616454f1..14618e55 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -32,7 +32,7 @@ import { } from '@renderer/store/settings' import { Assistant, AssistantSettings, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { modalConfirm } from '@renderer/utils' -import { Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd' +import { Col, InputNumber, Row, Segmented, Select, Slider, Switch, Tooltip } from 'antd' import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -51,6 +51,7 @@ const SettingsTab: FC = (props) => { const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0) const [fontSizeValue, setFontSizeValue] = useState(fontSize) const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true) + const [reasoningEffort, setReasoningEffort] = useState(assistant?.settings?.reasoning_effort) const { t } = useTranslation() const dispatch = useAppDispatch() @@ -96,9 +97,14 @@ const SettingsTab: FC = (props) => { } } + const onReasoningEffortChange = (value) => { + updateAssistantSettings({ reasoning_effort: value }) + } + const onReset = () => { setTemperature(DEFAULT_TEMPERATURE) setContextCount(DEFAULT_CONTEXTCOUNT) + setReasoningEffort(undefined) updateAssistant({ ...assistant, settings: { @@ -109,6 +115,7 @@ const SettingsTab: FC = (props) => { maxTokens: DEFAULT_MAX_TOKENS, streamOutput: true, hideMessages: false, + reasoning_effort: undefined, customParameters: [] } }) @@ -120,6 +127,7 @@ const SettingsTab: FC = (props) => { setEnableMaxTokens(assistant?.settings?.enableMaxTokens ?? false) setMaxTokens(assistant?.settings?.maxTokens ?? DEFAULT_MAX_TOKENS) setStreamOutput(assistant?.settings?.streamOutput ?? true) + setReasoningEffort(assistant?.settings?.reasoning_effort) }, [assistant]) return ( @@ -223,6 +231,45 @@ const SettingsTab: FC = (props) => { )} + + + + + + + + + + + + value={reasoningEffort} + onChange={(value) => { + setReasoningEffort(value) + onReasoningEffortChange(value) + }} + options={[ + { + value: 'low', + label: t('assistants.settings.reasoning_effort.low') + }, + { + value: 'medium', + label: t('assistants.settings.reasoning_effort.medium') + }, + { + value: 'high', + label: t('assistants.settings.reasoning_effort.high') + }, + { + value: undefined, + label: t('assistants.settings.reasoning_effort.off') + } + ]} + block + /> + + + {t('settings.messages.title')} @@ -485,4 +532,24 @@ export const SettingGroup = styled.div<{ theme?: ThemeMode }>` margin-bottom: 10px; ` +// Define the styled component with hover state styling +const SegmentedContainer = styled.div` + .ant-segmented-item { + font-size: 12px; + } + .ant-segmented-item-selected { + background-color: var(--color-primary) !important; + color: white !important; + } + + .ant-segmented-item:hover:not(.ant-segmented-item-selected) { + background-color: var(--color-primary-bg) !important; + color: var(--color-primary) !important; + } + + .ant-segmented-thumb { + background-color: var(--color-primary) !important; + } +` + export default SettingsTab diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 74a87466..73443f6c 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -154,6 +154,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA setMaxTokens(0) setStreamOutput(true) setTopP(1) + setReasoningEffort(undefined) setCustomParameters([]) updateAssistantSettings({ temperature: DEFAULT_TEMPERATURE, @@ -162,6 +163,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA maxTokens: 0, streamOutput: true, topP: 1, + reasoning_effort: undefined, customParameters: [] }) } diff --git a/src/renderer/src/providers/AnthropicProvider.ts b/src/renderer/src/providers/AnthropicProvider.ts index cac42ac3..66d502c7 100644 --- a/src/renderer/src/providers/AnthropicProvider.ts +++ b/src/renderer/src/providers/AnthropicProvider.ts @@ -1,6 +1,7 @@ import Anthropic from '@anthropic-ai/sdk' import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources' import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' +import { isReasoningModel } from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' @@ -18,7 +19,11 @@ export default class AnthropicProvider extends BaseProvider { constructor(provider: Provider) { super(provider) - this.sdk = new Anthropic({ apiKey: this.apiKey, baseURL: this.getBaseURL() }) + this.sdk = new Anthropic({ + apiKey: this.apiKey, + baseURL: this.getBaseURL(), + dangerouslyAllowBrowser: true + }) } public getBaseURL(): string { @@ -60,6 +65,47 @@ export default class AnthropicProvider extends BaseProvider { } } + private getTemperature(assistant: Assistant, model: Model) { + if (isReasoningModel(model)) return undefined + + return assistant?.settings?.temperature + } + + private getTopP(assistant: Assistant, model: Model) { + if (isReasoningModel(model)) return undefined + + return assistant?.settings?.topP + } + + private getReasoningEffort(assistant: Assistant, model: Model) { + if (isReasoningModel(model)) { + const effort_ratio = + assistant?.settings?.reasoning_effort === 'high' + ? 0.8 + : assistant?.settings?.reasoning_effort === 'medium' + ? 0.5 + : assistant?.settings?.reasoning_effort === 'low' + ? 0.2 + : undefined + if (!effort_ratio) + return { + type: 'disabled' + } + + if (model.id.includes('claude-3.7-sonnet') || model.id.includes('claude-3-7-sonnet')) { + return { + type: 'enabled', + budget_tokens: Math.max( + Math.min((assistant?.settings?.maxTokens || DEFAULT_MAX_TOKENS) * effort_ratio, 32000), + 1024 + ) + } + } + } + + return undefined + } + public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) { const defaultModel = getDefaultModel() const model = assistant.model || defaultModel @@ -84,20 +130,43 @@ export default class AnthropicProvider extends BaseProvider { model: model.id, messages: userMessages, max_tokens: maxTokens || DEFAULT_MAX_TOKENS, - temperature: assistant?.settings?.temperature, - top_p: assistant?.settings?.topP, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), system: assistant.prompt, ...this.getCustomParameters(assistant) } + if (isReasoningModel(model)) { + ;(body as any).thinking = this.getReasoningEffort(assistant, model) + ;(body as any).betas = ['output-128k-2025-02-19'] + } + let time_first_token_millsec = 0 + let time_first_content_millsec = 0 const start_time_millsec = new Date().getTime() if (!streamOutput) { const message = await this.sdk.messages.create({ ...body, stream: false }) const time_completion_millsec = new Date().getTime() - start_time_millsec + + let text = '' + let reasoning_content = '' + + if (message.content && message.content.length > 0) { + const thinkingBlock = message.content.find((block) => block.type === 'thinking') + const textBlock = message.content.find((block) => block.type === 'text') + + if (thinkingBlock && 'thinking' in thinkingBlock) { + reasoning_content = thinkingBlock.thinking + } + + if (textBlock && 'text' in textBlock) { + text = textBlock.text + } + } return onChunk({ - text: message.content[0].type === 'text' ? message.content[0].text : '', + text, + reasoning_content, usage: message.usage, metrics: { completion_tokens: message.usage.output_tokens, @@ -113,6 +182,7 @@ export default class AnthropicProvider extends BaseProvider { const { signal } = abortController return new Promise((resolve, reject) => { + let hasThinkingContent = false const stream = this.sdk.messages .stream({ ...body, stream: true }, { signal }) .on('text', (text) => { @@ -123,9 +193,34 @@ export default class AnthropicProvider extends BaseProvider { if (time_first_token_millsec == 0) { time_first_token_millsec = new Date().getTime() - start_time_millsec } + + if (hasThinkingContent && time_first_content_millsec === 0) { + time_first_content_millsec = new Date().getTime() + } + + const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0 + const time_completion_millsec = new Date().getTime() - start_time_millsec onChunk({ text, + metrics: { + completion_tokens: undefined, + time_completion_millsec, + time_first_token_millsec, + time_thinking_millsec + } + }) + }) + .on('thinking', (thinking) => { + hasThinkingContent = true + if (time_first_token_millsec == 0) { + time_first_token_millsec = new Date().getTime() - start_time_millsec + } + + const time_completion_millsec = new Date().getTime() - start_time_millsec + onChunk({ + reasoning_content: thinking, + text: '', metrics: { completion_tokens: undefined, time_completion_millsec, @@ -134,6 +229,8 @@ export default class AnthropicProvider extends BaseProvider { }) }) .on('finalMessage', (message) => { + const time_completion_millsec = new Date().getTime() - start_time_millsec + const time_thinking_millsec = time_first_content_millsec ? time_first_content_millsec - start_time_millsec : 0 onChunk({ text: '', usage: { @@ -143,8 +240,9 @@ export default class AnthropicProvider extends BaseProvider { }, metrics: { completion_tokens: message.usage.output_tokens, - time_completion_millsec: new Date().getTime() - start_time_millsec, - time_first_token_millsec + time_completion_millsec, + time_first_token_millsec, + time_thinking_millsec } }) resolve() diff --git a/src/renderer/src/providers/OpenAIProvider.ts b/src/renderer/src/providers/OpenAIProvider.ts index f63a74b1..1dc2c799 100644 --- a/src/renderer/src/providers/OpenAIProvider.ts +++ b/src/renderer/src/providers/OpenAIProvider.ts @@ -1,4 +1,11 @@ -import { getOpenAIWebSearchParams, isReasoningModel, isSupportedModel, isVisionModel } from '@renderer/config/models' +import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' +import { + getOpenAIWebSearchParams, + isOpenAIoSeries, + isReasoningModel, + isSupportedModel, + isVisionModel +} from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' @@ -156,9 +163,46 @@ export default class OpenAIProvider extends BaseProvider { } if (isReasoningModel(model)) { - return { - reasoning_effort: assistant?.settings?.reasoning_effort + if (model.provider === 'openrouter') { + return { + reasoning: { + effort: assistant?.settings?.reasoning_effort + } + } } + + if (isOpenAIoSeries(model)) { + return { + reasoning_effort: assistant?.settings?.reasoning_effort + } + } + + const effort_ratio = + assistant?.settings?.reasoning_effort === 'high' + ? 0.8 + : assistant?.settings?.reasoning_effort === 'medium' + ? 0.5 + : assistant?.settings?.reasoning_effort === 'low' + ? 0.2 + : undefined + + if (model.id.includes('claude-3.7-sonnet') || model.id.includes('claude-3-7-sonnet')) { + if (!effort_ratio) { + return { + type: 'disabled' + } + } + return { + thinking: { + budget_tokens: Math.max( + Math.min((assistant?.settings?.maxTokens || DEFAULT_MAX_TOKENS) * effort_ratio, 32000), + 1024 + ) + } + } + } + + return {} } return {} @@ -175,7 +219,7 @@ export default class OpenAIProvider extends BaseProvider { let systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined - if (['o1', 'o1-2024-12-17'].includes(model.id) || model.id.startsWith('o3')) { + if (isOpenAIoSeries(model)) { systemMessage = { role: 'developer', content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` @@ -212,6 +256,7 @@ export default class OpenAIProvider extends BaseProvider { delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta & { reasoning_content?: string reasoning?: string + thinking?: string } ) => { if (!delta?.content) return false @@ -226,7 +271,7 @@ export default class OpenAIProvider extends BaseProvider { } // 如果有reasoning_content或reasoning,说明是在思考中 - if (delta?.reasoning_content || delta?.reasoning) { + if (delta?.reasoning_content || delta?.reasoning || delta?.thinking) { hasReasoningContent = true } diff --git a/yarn.lock b/yarn.lock index d44bf1d8..d3ce4b25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -110,9 +110,9 @@ __metadata: languageName: node linkType: hard -"@anthropic-ai/sdk@npm:^0.24.3": - version: 0.24.3 - resolution: "@anthropic-ai/sdk@npm:0.24.3" +"@anthropic-ai/sdk@npm:^0.38.0": + version: 0.38.0 + resolution: "@anthropic-ai/sdk@npm:0.38.0" dependencies: "@types/node": "npm:^18.11.18" "@types/node-fetch": "npm:^2.6.4" @@ -121,8 +121,7 @@ __metadata: form-data-encoder: "npm:1.7.2" formdata-node: "npm:^4.3.2" node-fetch: "npm:^2.6.7" - web-streams-polyfill: "npm:^3.2.1" - checksum: 10c0/1c73c3df9637522da548d2cddfaf89513dac935c5cdb7c0b3db1c427c069a0de76df935bd189e477822063e9f944360e2d059827d5be4dca33bd388c61e97a30 + checksum: 10c0/accd003cbe314d32d4d36f5fd7fd743c32e2a896c9ea57190966eda20b8c46e00f542bf03ec3603d1274a7ac18e902bed4158ff5980e4e248a6d5c75e3fd891a languageName: node linkType: hard @@ -2996,7 +2995,7 @@ __metadata: version: 0.0.0-use.local resolution: "CherryStudio@workspace:." dependencies: - "@anthropic-ai/sdk": "npm:^0.24.3" + "@anthropic-ai/sdk": "npm:^0.38.0" "@electron-toolkit/eslint-config-prettier": "npm:^2.0.0" "@electron-toolkit/eslint-config-ts": "npm:^1.0.1" "@electron-toolkit/preload": "npm:^3.0.0" @@ -14363,7 +14362,7 @@ __metadata: languageName: node linkType: hard -"web-streams-polyfill@npm:^3.0.3, web-streams-polyfill@npm:^3.2.1": +"web-streams-polyfill@npm:^3.0.3": version: 3.3.3 resolution: "web-streams-polyfill@npm:3.3.3" checksum: 10c0/64e855c47f6c8330b5436147db1c75cb7e7474d924166800e8e2aab5eb6c76aac4981a84261dd2982b3e754490900b99791c80ae1407a9fa0dcff74f82ea3a7f