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
This commit is contained in:
SuYao 2025-03-01 21:22:12 +08:00 committed by GitHub
parent 956c2f683d
commit ae11490f87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 244 additions and 25 deletions

View File

@ -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",

View File

@ -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
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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> = (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> = (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> = (props) => {
maxTokens: DEFAULT_MAX_TOKENS,
streamOutput: true,
hideMessages: false,
reasoning_effort: undefined,
customParameters: []
}
})
@ -120,6 +127,7 @@ const SettingsTab: FC<Props> = (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> = (props) => {
</Col>
</Row>
)}
<SettingDivider />
<Row align="middle">
<Label>{t('assistants.settings.reasoning_effort')}</Label>
<Tooltip title={t('assistants.settings.reasoning_effort.tip')}>
<QuestionIcon />
</Tooltip>
</Row>
<Row align="middle" gutter={10}>
<Col span={24}>
<SegmentedContainer>
<Segmented<'low' | 'medium' | 'high' | undefined>
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
/>
</SegmentedContainer>
</Col>
</Row>
</SettingGroup>
<SettingGroup>
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.messages.title')}</SettingSubtitle>
@ -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

View File

@ -154,6 +154,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
setMaxTokens(0)
setStreamOutput(true)
setTopP(1)
setReasoningEffort(undefined)
setCustomParameters([])
updateAssistantSettings({
temperature: DEFAULT_TEMPERATURE,
@ -162,6 +163,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
maxTokens: 0,
streamOutput: true,
topP: 1,
reasoning_effort: undefined,
customParameters: []
})
}

View File

@ -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<void>((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()

View File

@ -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
}

View File

@ -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