diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index 5565584254..f00f2d1a88 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2410,7 +2410,7 @@ export function isWebSearchModel(model: Model): boolean { return CLAUDE_SUPPORTED_WEBSEARCH_REGEX.test(model.id) } - if (provider.type === 'openai') { + if (provider.type === 'openai-response') { if ( isOpenAILLMModel(model) && !isTextToImageModel(model) && @@ -2441,7 +2441,7 @@ export function isWebSearchModel(model: Model): boolean { return models.includes(model?.id) } - if (provider?.type === 'openai-compatible') { + if (provider?.type === 'openai') { if (GEMINI_SEARCH_MODELS.includes(model?.id) || isOpenAIWebSearch(model)) { return true } diff --git a/src/renderer/src/databases/upgrades.ts b/src/renderer/src/databases/upgrades.ts index 11db1ed090..fae8a74719 100644 --- a/src/renderer/src/databases/upgrades.ts +++ b/src/renderer/src/databases/upgrades.ts @@ -213,7 +213,7 @@ export async function upgradeToV7(tx: Transaction): Promise { hasCitationData = true citationDataToCreate.response = { results: oldMessage.metadata.annotations, - source: WebSearchSource.OPENAI + source: WebSearchSource.OPENAI_RESPONSE } } if (oldMessage.metadata?.citations?.length) { diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 0d9156a885..f21cc47131 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -49,8 +49,8 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions } switch (block.citationReferences[0].citationBlockSource) { - case WebSearchSource.OPENAI_COMPATIBLE: - case WebSearchSource.OPENAI: { + case WebSearchSource.OPENAI: + case WebSearchSource.OPENAI_RESPONSE: { formattedCitations.forEach((citation) => { const citationNum = citation.number const supData = { diff --git a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx index 309ebbe0a8..e683f8c432 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/AddProviderPopup.tsx @@ -16,7 +16,7 @@ interface Props { const PopupContainer: React.FC = ({ provider, resolve }) => { const [open, setOpen] = useState(true) const [name, setName] = useState(provider?.name || '') - const [type, setType] = useState(provider?.type || 'openai-compatible') + const [type, setType] = useState(provider?.type || 'openai') const [logo, setLogo] = useState(null) const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() @@ -52,7 +52,7 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { const onCancel = () => { setOpen(false) - resolve({ name: '', type: 'openai-compatible' }) + resolve({ name: '', type: 'openai' }) } const onClose = () => { @@ -189,8 +189,8 @@ const PopupContainer: React.FC = ({ provider, resolve }) => { value={type} onChange={setType} options={[ - { label: 'OpenAI-Compatible', value: 'openai-compatible' }, - { label: 'OpenAI-Response', value: 'openai' }, + { label: 'OpenAI', value: 'openai' }, + { label: 'OpenAI-Response', value: 'openai-response' }, { label: 'Gemini', value: 'gemini' }, { label: 'Anthropic', value: 'anthropic' }, { label: 'Azure OpenAI', value: 'azure-openai' } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 793e2bd282..2f58a924dd 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -262,7 +262,7 @@ const ProviderSetting: FC = ({ provider: _provider }) => { if (apiHost.endsWith('#')) { return apiHost.replace('#', '') } - if (provider.type === 'openai-compatible') { + if (provider.type === 'openai') { return formatApiHost(apiHost) + 'chat/completions' } return formatApiHost(apiHost) + 'responses' diff --git a/src/renderer/src/providers/AiProvider/AihubmixProvider.ts b/src/renderer/src/providers/AiProvider/AihubmixProvider.ts index 83b5377b5c..6fe0a1c235 100644 --- a/src/renderer/src/providers/AiProvider/AihubmixProvider.ts +++ b/src/renderer/src/providers/AiProvider/AihubmixProvider.ts @@ -8,8 +8,8 @@ import { CompletionsParams } from '.' import AnthropicProvider from './AnthropicProvider' import BaseProvider from './BaseProvider' import GeminiProvider from './GeminiProvider' -import OpenAICompatibleProvider from './OpenAICompatibleProvider' import OpenAIProvider from './OpenAIProvider' +import OpenAIResponseProvider from './OpenAIResponseProvider' /** * AihubmixProvider - 根据模型类型自动选择合适的提供商 @@ -26,8 +26,8 @@ export default class AihubmixProvider extends BaseProvider { // 初始化各个提供商 this.providers.set('claude', new AnthropicProvider(provider)) this.providers.set('gemini', new GeminiProvider({ ...provider, apiHost: 'https://aihubmix.com/gemini' })) - this.providers.set('openai', new OpenAIProvider(provider)) - this.providers.set('default', new OpenAICompatibleProvider(provider)) + this.providers.set('openai', new OpenAIResponseProvider(provider)) + this.providers.set('default', new OpenAIProvider(provider)) // 设置默认提供商 this.defaultProvider = this.providers.get('default')! diff --git a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts b/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts deleted file mode 100644 index 057a1e3e06..0000000000 --- a/src/renderer/src/providers/AiProvider/OpenAICompatibleProvider.ts +++ /dev/null @@ -1,1220 +0,0 @@ -import { - findTokenLimit, - getOpenAIWebSearchParams, - isHunyuanSearchModel, - isOpenAIReasoningModel, - isOpenAIWebSearch, - isReasoningModel, - isSupportedModel, - isSupportedReasoningEffortGrokModel, - isSupportedReasoningEffortModel, - isSupportedReasoningEffortOpenAIModel, - isSupportedThinkingTokenClaudeModel, - isSupportedThinkingTokenModel, - isSupportedThinkingTokenQwenModel, - isVisionModel, - isZhipuModel -} from '@renderer/config/models' -import { getStoreSetting } from '@renderer/hooks/useSettings' -import i18n from '@renderer/i18n' -import { extractReasoningMiddleware } from '@renderer/middlewares/extractReasoningMiddleware' -import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' -import { EVENT_NAMES } from '@renderer/services/EventService' -import { - filterContextMessages, - filterEmptyMessages, - filterUserRoleStartMessages -} from '@renderer/services/MessagesService' -import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' -import store from '@renderer/store' -import { - Assistant, - EFFORT_RATIO, - FileTypes, - MCPCallToolResponse, - MCPTool, - MCPToolResponse, - Metrics, - Model, - Provider, - Suggestion, - ToolCallResponse, - Usage, - WebSearchSource -} from '@renderer/types' -import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk' -import { Message } from '@renderer/types/newMessage' -import { removeSpecialCharactersForTopicName } from '@renderer/utils' -import { addImageFileToContents } from '@renderer/utils/formats' -import { - convertLinks, - convertLinksToHunyuan, - convertLinksToOpenRouter, - convertLinksToZhipu -} from '@renderer/utils/linkConverter' -import { - mcpToolCallResponseToOpenAICompatibleMessage, - mcpToolsToOpenAIChatTools, - openAIToolsToMcpTool, - parseAndCallTools -} from '@renderer/utils/mcp-tools' -import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' -import { buildSystemPrompt } from '@renderer/utils/prompt' -import { asyncGeneratorToReadableStream, readableStreamAsyncIterable } from '@renderer/utils/stream' -import { isEmpty, takeRight } from 'lodash' -import OpenAI, { AzureOpenAI } from 'openai' -import { - ChatCompletionContentPart, - ChatCompletionCreateParamsNonStreaming, - ChatCompletionMessageParam, - ChatCompletionMessageToolCall, - ChatCompletionTool, - ChatCompletionToolMessageParam -} from 'openai/resources' - -import { CompletionsParams } from '.' -import { BaseOpenAiProvider } from './OpenAIProvider' - -// 1. 定义联合类型 -export type OpenAIStreamChunk = - | { type: 'reasoning' | 'text-delta'; textDelta: string } - | { type: 'tool-calls'; delta: any } - | { type: 'finish'; finishReason: any; usage: any; delta: any; chunk: any } - -export default class OpenAICompatibleProvider extends BaseOpenAiProvider { - constructor(provider: Provider) { - super(provider) - - if (provider.id === 'azure-openai' || provider.type === 'azure-openai') { - this.sdk = new AzureOpenAI({ - dangerouslyAllowBrowser: true, - apiKey: this.apiKey, - apiVersion: provider.apiVersion, - endpoint: provider.apiHost - }) - return - } - - this.sdk = new OpenAI({ - dangerouslyAllowBrowser: true, - apiKey: this.apiKey, - baseURL: this.getBaseURL(), - defaultHeaders: { - ...this.defaultHeaders(), - ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), - ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) - } - }) - } - - /** - * Check if the provider does not support files - * @returns True if the provider does not support files, false otherwise - */ - private get isNotSupportFiles() { - if (this.provider?.isNotSupportArrayContent) { - return true - } - - const providers = ['deepseek', 'baichuan', 'minimax', 'xirang'] - - return providers.includes(this.provider.id) - } - - /** - * Get the message parameter - * @param message - The message - * @param model - The model - * @returns The message parameter - */ - override async getMessageParam( - message: Message, - model: Model - ): Promise { - const isVision = isVisionModel(model) - const content = await this.getMessageContent(message) - const fileBlocks = findFileBlocks(message) - const imageBlocks = findImageBlocks(message) - - if (fileBlocks.length === 0 && imageBlocks.length === 0) { - return { - role: message.role === 'system' ? 'user' : message.role, - content - } - } - - // If the model does not support files, extract the file content - if (this.isNotSupportFiles) { - const fileContent = await this.extractFileContent(message) - - return { - role: message.role === 'system' ? 'user' : message.role, - content: content + '\n\n---\n\n' + fileContent - } - } - - // If the model supports files, add the file content to the message - const parts: ChatCompletionContentPart[] = [] - - if (content) { - parts.push({ type: 'text', text: content }) - } - - for (const imageBlock of imageBlocks) { - if (isVision) { - if (imageBlock.file) { - const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) - parts.push({ type: 'image_url', image_url: { url: image.data } }) - } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { - parts.push({ type: 'image_url', image_url: { url: imageBlock.url } }) - } - } - } - - for (const fileBlock of fileBlocks) { - const file = fileBlock.file - if (!file) { - continue - } - - if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { - const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() - parts.push({ - type: 'text', - text: file.origin_name + '\n' + fileContent - }) - } - } - - return { - role: message.role === 'system' ? 'user' : message.role, - content: parts - } as ChatCompletionMessageParam - } - - /** - * Get the temperature for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The temperature - */ - override getTemperature(assistant: Assistant, model: Model) { - return isReasoningModel(model) || isOpenAIWebSearch(model) ? undefined : assistant?.settings?.temperature - } - - /** - * Get the provider specific parameters for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The provider specific parameters - */ - private getProviderSpecificParameters(assistant: Assistant, model: Model) { - const { maxTokens } = getAssistantSettings(assistant) - - if (this.provider.id === 'openrouter') { - if (model.id.includes('deepseek-r1')) { - return { - include_reasoning: true - } - } - } - - if (isOpenAIReasoningModel(model)) { - return { - max_tokens: undefined, - max_completion_tokens: maxTokens - } - } - - return {} - } - - /** - * Get the top P for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The top P - */ - override getTopP(assistant: Assistant, model: Model) { - if (isReasoningModel(model) || isOpenAIWebSearch(model)) { - return undefined - } - - return assistant?.settings?.topP - } - - /** - * Get the reasoning effort for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The reasoning effort - */ - private getReasoningEffort(assistant: Assistant, model: Model) { - if (this.provider.id === 'groq') { - return {} - } - - if (!isReasoningModel(model)) { - return {} - } - const reasoningEffort = assistant?.settings?.reasoning_effort - if (!reasoningEffort) { - if (isSupportedThinkingTokenQwenModel(model)) { - return { enable_thinking: false } - } - - if (isSupportedThinkingTokenClaudeModel(model)) { - return { thinking: { type: 'disabled' } } - } - - return {} - } - const effortRatio = EFFORT_RATIO[reasoningEffort] - const budgetTokens = Math.floor((findTokenLimit(model.id)?.max || 0) * effortRatio) - // OpenRouter models - if (model.provider === 'openrouter') { - if (isSupportedReasoningEffortModel(model)) { - return { - reasoning: { - effort: assistant?.settings?.reasoning_effort - } - } - } - - if (isSupportedThinkingTokenModel(model)) { - return { - reasoning: { - max_tokens: budgetTokens - } - } - } - } - - // Qwen models - if (isSupportedThinkingTokenQwenModel(model)) { - return { - enable_thinking: true, - thinking_budget: budgetTokens - } - } - - // Grok models - if (isSupportedReasoningEffortGrokModel(model)) { - return { - reasoning_effort: assistant?.settings?.reasoning_effort - } - } - - // OpenAI models - if (isSupportedReasoningEffortOpenAIModel(model)) { - return { - reasoning_effort: assistant?.settings?.reasoning_effort - } - } - - // Claude models - if (isSupportedThinkingTokenClaudeModel(model)) { - return { - thinking: { - type: 'enabled', - budget_tokens: budgetTokens - } - } - } - - // Default case: no special thinking settings - return {} - } - - public convertMcpTools(mcpTools: MCPTool[]): T[] { - return mcpToolsToOpenAIChatTools(mcpTools) as T[] - } - - public mcpToolCallResponseToMessage = (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => { - if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { - return mcpToolCallResponseToOpenAICompatibleMessage(mcpToolResponse, resp, isVisionModel(model)) - } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { - const toolCallOut: ChatCompletionToolMessageParam = { - role: 'tool', - tool_call_id: mcpToolResponse.toolCallId, - content: JSON.stringify(resp.content) - } - return toolCallOut - } - return - } - - /** - * Generate completions for the assistant - * @param messages - The messages - * @param assistant - The assistant - * @param mcpTools - The MCP tools - * @param onChunk - The onChunk callback - * @param onFilterMessages - The onFilterMessages callback - * @returns The completions - */ - async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise { - if (assistant.enableGenerateImage) { - await this.generateImageByChat({ messages, assistant, onChunk } as CompletionsParams) - return - } - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - - const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) - const isEnabledBultinWebSearch = assistant.enableWebSearch - messages = addImageFileToContents(messages) - const enableReasoning = - ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && - assistant.settings?.reasoning_effort !== undefined) || - (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) - let systemMessage = { role: 'system', content: assistant.prompt || '' } - if (isSupportedReasoningEffortOpenAIModel(model)) { - systemMessage = { - role: 'developer', - content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` - } - } - const { tools } = this.setupToolsConfig({ mcpTools, model, enableToolUse }) - - if (this.useSystemPromptForTools) { - systemMessage.content = buildSystemPrompt(systemMessage.content || '', mcpTools) - } - - const userMessages: ChatCompletionMessageParam[] = [] - const _messages = filterUserRoleStartMessages( - filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) - ) - - onFilterMessages(_messages) - - for (const message of _messages) { - userMessages.push(await this.getMessageParam(message, model)) - } - - const isSupportStreamOutput = () => { - return streamOutput - } - - const lastUserMessage = _messages.findLast((m) => m.role === 'user') - const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) - const { signal } = abortController - await this.checkIsCopilot() - - const lastUserMsg = userMessages.findLast((m) => m.role === 'user') - if (lastUserMsg && isSupportedThinkingTokenQwenModel(model)) { - const postsuffix = '/no_think' - // qwenThinkMode === true 表示思考模式啓用,此時不應添加 /no_think,如果存在則移除 - const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true - const currentContent = lastUserMsg.content // content 類型:string | ChatCompletionContentPart[] | null - - lastUserMsg.content = processPostsuffixQwen3Model( - currentContent, - postsuffix, - qwenThinkModeEnabled - ) as ChatCompletionContentPart[] - } - - //当 systemMessage 内容为空时不发送 systemMessage - let reqMessages: ChatCompletionMessageParam[] - if (!systemMessage.content) { - reqMessages = [...userMessages] - } else { - reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] - } - - let finalUsage: Usage = { - completion_tokens: 0, - prompt_tokens: 0, - total_tokens: 0 - } - - const finalMetrics: Metrics = { - completion_tokens: 0, - time_completion_millsec: 0, - time_first_token_millsec: 0 - } - - const toolResponses: MCPToolResponse[] = [] - - const processToolResults = async (toolResults: Awaited>, idx: number) => { - if (toolResults.length === 0) return - - toolResults.forEach((ts) => reqMessages.push(ts as ChatCompletionMessageParam)) - - console.debug('[tool] reqMessages before processing', model.id, reqMessages) - reqMessages = processReqMessages(model, reqMessages) - console.debug('[tool] reqMessages', model.id, reqMessages) - - onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) - const newStream = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_tokens: maxTokens, - keep_alive: this.keepAliveTime, - stream: isSupportStreamOutput(), - tools: !isEmpty(tools) ? tools : undefined, - ...getOpenAIWebSearchParams(assistant, model), - ...this.getReasoningEffort(assistant, model), - ...this.getProviderSpecificParameters(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal - } - ) - await processStream(newStream, idx + 1) - } - - const processToolCalls = async (mcpTools, toolCalls: ChatCompletionMessageToolCall[]) => { - const mcpToolResponses = toolCalls - .map((toolCall) => { - const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as ChatCompletionMessageToolCall) - if (!mcpTool) return undefined - - const parsedArgs = (() => { - try { - return JSON.parse(toolCall.function.arguments) - } catch { - return toolCall.function.arguments - } - })() - - return { - id: toolCall.id, - toolCallId: toolCall.id, - tool: mcpTool, - arguments: parsedArgs, - status: 'pending' - } as ToolCallResponse - }) - .filter((t): t is ToolCallResponse => typeof t !== 'undefined') - return await parseAndCallTools( - mcpToolResponses, - toolResponses, - onChunk, - this.mcpToolCallResponseToMessage, - model, - mcpTools - ) - } - - const processToolUses = async (content: string) => { - return await parseAndCallTools( - content, - toolResponses, - onChunk, - this.mcpToolCallResponseToMessage, - model, - mcpTools - ) - } - - const processStream = async (stream: any, idx: number) => { - const toolCalls: ChatCompletionMessageToolCall[] = [] - let time_first_token_millsec = 0 - - // Handle non-streaming case (already returns early, no change needed here) - if (!isSupportStreamOutput()) { - // Calculate final metrics once - finalMetrics.completion_tokens = stream.usage?.completion_tokens - finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec - - // Create a synthetic usage object if stream.usage is undefined - finalUsage = { ...stream.usage } - // Separate onChunk calls for text and usage/metrics - let content = '' - stream.choices.forEach((choice) => { - // reasoning - if (choice.message.reasoning) { - onChunk({ type: ChunkType.THINKING_DELTA, text: choice.message.reasoning }) - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: choice.message.reasoning, - thinking_millsec: new Date().getTime() - start_time_millsec - }) - } - // text - if (choice.message.content) { - content += choice.message.content - onChunk({ type: ChunkType.TEXT_DELTA, text: choice.message.content }) - } - // tool call - if (choice.message.tool_calls && choice.message.tool_calls.length) { - choice.message.tool_calls.forEach((t) => toolCalls.push(t)) - } - - reqMessages.push({ - role: choice.message.role, - content: choice.message.content, - tool_calls: toolCalls.length - ? toolCalls.map((toolCall) => ({ - id: toolCall.id, - function: { - ...toolCall.function, - arguments: - typeof toolCall.function.arguments === 'string' - ? toolCall.function.arguments - : JSON.stringify(toolCall.function.arguments) - }, - type: 'function' - })) - : undefined - }) - }) - - if (content.length) { - onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) - } - - const toolResults: Awaited> = [] - if (toolCalls.length) { - toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) - } - if (stream.choices[0].message?.content) { - toolResults.push(...(await processToolUses(stream.choices[0].message?.content))) - } - await processToolResults(toolResults, idx) - - // Always send usage and metrics data - onChunk({ type: ChunkType.BLOCK_COMPLETE, response: { usage: finalUsage, metrics: finalMetrics } }) - return - } - - let content = '' - let thinkingContent = '' - let isFirstChunk = true - - // 1. 初始化中间件 - const reasoningTags = [ - { openingTag: '', closingTag: '', separator: '\n' }, - { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' } - ] - const getAppropriateTag = (model: Model) => { - if (model.id.includes('qwen3')) return reasoningTags[0] - return reasoningTags[0] - } - const reasoningTag = getAppropriateTag(model) - async function* openAIChunkToTextDelta(stream: any): AsyncGenerator { - for await (const chunk of stream) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break - } - - const delta = chunk.choices[0]?.delta - if (delta?.reasoning_content || delta?.reasoning) { - yield { type: 'reasoning', textDelta: delta.reasoning_content || delta.reasoning } - } - if (delta?.content) { - yield { type: 'text-delta', textDelta: delta.content } - } - if (delta?.tool_calls) { - yield { type: 'tool-calls', delta: delta } - } - - const finishReason = chunk.choices[0]?.finish_reason - if (!isEmpty(finishReason)) { - yield { type: 'finish', finishReason, usage: chunk.usage, delta, chunk } - break - } - } - } - - // 2. 使用中间件 - const { stream: processedStream } = await extractReasoningMiddleware({ - openingTag: reasoningTag?.openingTag, - closingTag: reasoningTag?.closingTag, - separator: reasoningTag?.separator, - enableReasoning - }).wrapStream({ - doStream: async () => ({ - stream: asyncGeneratorToReadableStream(openAIChunkToTextDelta(stream)) - }) - }) - - // 3. 消费 processedStream,分发 onChunk - for await (const chunk of readableStreamAsyncIterable(processedStream)) { - const delta = chunk.type === 'finish' ? chunk.delta : chunk - const rawChunk = chunk.type === 'finish' ? chunk.chunk : chunk - - switch (chunk.type) { - case 'reasoning': { - if (time_first_token_millsec === 0) { - time_first_token_millsec = new Date().getTime() - } - thinkingContent += chunk.textDelta - onChunk({ - type: ChunkType.THINKING_DELTA, - text: chunk.textDelta, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - break - } - case 'text-delta': { - let textDelta = chunk.textDelta - if (assistant.enableWebSearch && delta) { - const originalDelta = rawChunk?.choices?.[0]?.delta - - if (originalDelta?.annotations) { - textDelta = convertLinks(textDelta, isFirstChunk) - } else if (assistant.model?.provider === 'openrouter') { - textDelta = convertLinksToOpenRouter(textDelta, isFirstChunk) - } else if (isZhipuModel(assistant.model)) { - textDelta = convertLinksToZhipu(textDelta, isFirstChunk) - } else if (isHunyuanSearchModel(assistant.model)) { - const searchResults = rawChunk?.search_info?.search_results || [] - textDelta = convertLinksToHunyuan(textDelta, searchResults, isFirstChunk) - } - } - if (isFirstChunk) { - isFirstChunk = false - if (time_first_token_millsec === 0) { - time_first_token_millsec = new Date().getTime() - } else { - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: thinkingContent, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - } - } - content += textDelta - onChunk({ type: ChunkType.TEXT_DELTA, text: textDelta }) - break - } - case 'tool-calls': { - if (isFirstChunk) { - isFirstChunk = false - if (time_first_token_millsec === 0) { - time_first_token_millsec = new Date().getTime() - } else { - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: thinkingContent, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - } - } - chunk.delta.tool_calls.forEach((toolCall) => { - const { id, index, type, function: fun } = toolCall - if (id && type === 'function' && fun) { - const { name, arguments: args } = fun - toolCalls.push({ - id, - function: { - name: name || '', - arguments: args || '' - }, - type: 'function' - }) - } else if (fun?.arguments) { - toolCalls[index].function.arguments += fun.arguments - } - }) - break - } - case 'finish': { - const finishReason = chunk.finishReason - const usage = chunk.usage - const originalFinishDelta = chunk.delta - const originalFinishRawChunk = chunk.chunk - - if (!isEmpty(finishReason)) { - onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) - if (usage) { - finalUsage.completion_tokens += usage.completion_tokens || 0 - finalUsage.prompt_tokens += usage.prompt_tokens || 0 - finalUsage.total_tokens += usage.total_tokens || 0 - finalMetrics.completion_tokens += usage.completion_tokens || 0 - } - finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec - finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec - if (originalFinishDelta?.annotations) { - if (assistant.model?.provider === 'copilot') return - - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: originalFinishDelta.annotations, - source: WebSearchSource.OPENAI - } - } as LLMWebSearchCompleteChunk) - } - if (assistant.model?.provider === 'perplexity') { - const citations = originalFinishRawChunk.citations - if (citations) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: citations, - source: WebSearchSource.PERPLEXITY - } - } as LLMWebSearchCompleteChunk) - } - } - if ( - isEnabledBultinWebSearch && - isZhipuModel(model) && - finishReason === 'stop' && - originalFinishRawChunk?.web_search - ) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: originalFinishRawChunk.web_search, - source: WebSearchSource.ZHIPU - } - } as LLMWebSearchCompleteChunk) - } - if ( - isEnabledBultinWebSearch && - isHunyuanSearchModel(model) && - originalFinishRawChunk?.search_info?.search_results - ) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: originalFinishRawChunk.search_info.search_results, - source: WebSearchSource.HUNYUAN - } - } as LLMWebSearchCompleteChunk) - } - } - break - } - } - } - - reqMessages.push({ - role: 'assistant', - content: content, - tool_calls: toolCalls.length - ? toolCalls.map((toolCall) => ({ - id: toolCall.id, - function: { - ...toolCall.function, - arguments: - typeof toolCall.function.arguments === 'string' - ? toolCall.function.arguments - : JSON.stringify(toolCall.function.arguments) - }, - type: 'function' - })) - : undefined - }) - let toolResults: Awaited> = [] - if (toolCalls.length) { - toolResults = await processToolCalls(mcpTools, toolCalls) - } - if (content.length) { - toolResults = toolResults.concat(await processToolUses(content)) - } - if (toolResults.length) { - await processToolResults(toolResults, idx) - } - - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: finalUsage, - metrics: finalMetrics - } - }) - } - - reqMessages = processReqMessages(model, reqMessages) - // 等待接口返回流 - onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) - const start_time_millsec = new Date().getTime() - const stream = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_tokens: maxTokens, - keep_alive: this.keepAliveTime, - stream: isSupportStreamOutput(), - tools: !isEmpty(tools) ? tools : undefined, - service_tier: this.getServiceTier(model), - ...getOpenAIWebSearchParams(assistant, model), - ...this.getReasoningEffort(assistant, model), - ...this.getProviderSpecificParameters(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal, - timeout: this.getTimeout(model) - } - ) - - await processStream(stream, 0).finally(cleanup) - - // 捕获signal的错误 - await signalPromise?.promise?.catch((error) => { - throw error - }) - } - - /** - * Translate a message - * @param content - * @param assistant - The assistant - * @param onResponse - The onResponse callback - * @returns The translated message - */ - async translate(content: string, assistant: Assistant, onResponse?: (text: string, isComplete: boolean) => void) { - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - - const messagesForApi = content - ? [ - { role: 'system', content: assistant.prompt }, - { role: 'user', content } - ] - : [{ role: 'user', content: assistant.prompt }] - - const isSupportedStreamOutput = () => { - if (!onResponse) { - return false - } - return true - } - - const stream = isSupportedStreamOutput() - - await this.checkIsCopilot() - - // console.debug('[translate] reqMessages', model.id, message) - // @ts-ignore key is not typed - const response = await this.sdk.chat.completions.create({ - model: model.id, - messages: messagesForApi as ChatCompletionMessageParam[], - stream, - keep_alive: this.keepAliveTime, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - ...this.getReasoningEffort(assistant, model) - }) - - if (!stream) { - return response.choices[0].message?.content || '' - } - - let text = '' - let isThinking = false - const isReasoning = isReasoningModel(model) - - for await (const chunk of response) { - const deltaContent = chunk.choices[0]?.delta?.content || '' - - if (isReasoning) { - if (deltaContent.includes('')) { - isThinking = true - } - - if (!isThinking) { - text += deltaContent - onResponse?.(text, false) - } - - if (deltaContent.includes('')) { - isThinking = false - } - } else { - text += deltaContent - onResponse?.(text, false) - } - } - - onResponse?.(text, true) - - return text - } - - /** - * Summarize a message - * @param messages - The messages - * @param assistant - The assistant - * @returns The summary - */ - public async summaries(messages: Message[], assistant: Assistant): Promise { - const model = getTopNamingModel() || assistant.model || getDefaultModel() - - const userMessages = takeRight(messages, 5) - .filter((message) => !message.isPreset) - .map((message) => ({ - role: message.role, - content: getMainTextContent(message) - })) - - const userMessageContent = userMessages.reduce((prev, curr) => { - const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}` - return prev + (prev ? '\n' : '') + content - }, '') - - const systemMessage = { - role: 'system', - content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title') - } - - const userMessage = { - role: 'user', - content: userMessageContent - } - - await this.checkIsCopilot() - - // @ts-ignore key is not typed - const response = await this.sdk.chat.completions.create({ - model: model.id, - messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], - stream: false, - keep_alive: this.keepAliveTime, - max_tokens: 1000 - }) - - // 针对思考类模型的返回,总结仅截取之后的内容 - let content = response.choices[0].message?.content || '' - content = content.replace(/^(.*?)<\/think>/s, '') - - return removeSpecialCharactersForTopicName(content.substring(0, 50)) - } - - /** - * Summarize a message for search - * @param messages - The messages - * @param assistant - The assistant - * @returns The summary - */ - public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { - const model = assistant.model || getDefaultModel() - - const systemMessage = { - role: 'system', - content: assistant.prompt - } - - const messageContents = messages.map((m) => getMainTextContent(m)) - const userMessageContent = messageContents.join('\n') - - const userMessage = { - role: 'user', - content: userMessageContent - } - - const lastUserMessage = messages[messages.length - 1] - const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) - const { signal } = abortController - - const response = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], - stream: false, - keep_alive: this.keepAliveTime, - max_tokens: 1000 - }, - { - timeout: 20 * 1000, - signal: signal - } - ) - .finally(cleanup) - - // 针对思考类模型的返回,总结仅截取之后的内容 - let content = response.choices[0].message?.content || '' - content = content.replace(/^(.*?)<\/think>/s, '') - - return content - } - - /** - * Generate text - * @param prompt - The prompt - * @param content - The content - * @returns The generated text - */ - public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { - const model = getDefaultModel() - - await this.checkIsCopilot() - - const response = await this.sdk.chat.completions.create({ - model: model.id, - stream: false, - messages: [ - { role: 'system', content: prompt }, - { role: 'user', content } - ] - }) - - return response.choices[0].message?.content || '' - } - - /** - * Generate suggestions - * @param messages - The messages - * @param assistant - The assistant - * @returns The suggestions - */ - async suggestions(messages: Message[], assistant: Assistant): Promise { - const { model } = assistant - - if (!model) { - return [] - } - - await this.checkIsCopilot() - - const userMessagesForApi = messages - .filter((m) => m.role === 'user') - .map((m) => ({ - role: m.role, - content: getMainTextContent(m) - })) - - const response: any = await this.sdk.request({ - method: 'post', - path: '/advice_questions', - body: { - messages: userMessagesForApi, - model: model.id, - max_tokens: 0, - temperature: 0, - n: 0 - } - }) - - return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || [] - } - - /** - * Check if the model is valid - * @param model - The model - * @param stream - Whether to use streaming interface - * @returns The validity of the model - */ - public async check(model: Model, stream: boolean = false): Promise<{ valid: boolean; error: Error | null }> { - if (!model) { - return { valid: false, error: new Error('No model found') } - } - - const body = { - model: model.id, - messages: [{ role: 'user', content: 'hi' }], - max_completion_tokens: 1, // openAI - max_tokens: 1, // openAI deprecated 但大部分OpenAI兼容的提供商继续用这个头 - enable_thinking: false, // qwen3 - stream - } - - try { - await this.checkIsCopilot() - if (!stream) { - const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming) - if (!response?.choices[0].message) { - throw new Error('Empty response') - } - return { valid: true, error: null } - } else { - const response: any = await this.sdk.chat.completions.create(body as any) - // 等待整个流式响应结束 - let hasContent = false - for await (const chunk of response) { - if (chunk.choices?.[0]?.delta?.content) { - hasContent = true - } - } - if (hasContent) { - return { valid: true, error: null } - } - throw new Error('Empty streaming response') - } - } catch (error: any) { - return { - valid: false, - error - } - } - } - - /** - * Get the models - * @returns The models - */ - public async models(): Promise { - try { - await this.checkIsCopilot() - - const response = await this.sdk.models.list() - - if (this.provider.id === 'github') { - // @ts-ignore key is not typed - return response.body - .map((model) => ({ - id: model.name, - description: model.summary, - object: 'model', - owned_by: model.publisher - })) - .filter(isSupportedModel) - } - - if (this.provider.id === 'together') { - // @ts-ignore key is not typed - return response?.body - .map((model: any) => ({ - id: model.id, - description: model.display_name, - object: 'model', - owned_by: model.organization - })) - .filter(isSupportedModel) - } - - const models = response.data || [] - models.forEach((model) => { - model.id = model.id.trim() - }) - - return models.filter(isSupportedModel) - } catch (error) { - return [] - } - } - - /** - * Get the embedding dimensions - * @param model - The model - * @returns The embedding dimensions - */ - public async getEmbeddingDimensions(model: Model): Promise { - await this.checkIsCopilot() - - const data = await this.sdk.embeddings.create({ - model: model.id, - input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi' - }) - return data.data[0].embedding.length - } - - public async checkIsCopilot() { - if (this.provider.id !== 'copilot') { - return - } - const defaultHeaders = store.getState().copilot.defaultHeaders - // copilot每次请求前需要重新获取token,因为token中附带时间戳 - const { token } = await window.api.copilot.getToken(defaultHeaders) - this.sdk.apiKey = token - } -} diff --git a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts index 51f61136b3..650960fc65 100644 --- a/src/renderer/src/providers/AiProvider/OpenAIProvider.ts +++ b/src/renderer/src/providers/AiProvider/OpenAIProvider.ts @@ -1,26 +1,36 @@ import { + findTokenLimit, getOpenAIWebSearchParams, - isOpenAILLMModel, + isHunyuanSearchModel, isOpenAIReasoningModel, isOpenAIWebSearch, + isReasoningModel, isSupportedModel, + isSupportedReasoningEffortGrokModel, + isSupportedReasoningEffortModel, isSupportedReasoningEffortOpenAIModel, - isVisionModel + isSupportedThinkingTokenClaudeModel, + isSupportedThinkingTokenModel, + isSupportedThinkingTokenQwenModel, + isVisionModel, + isZhipuModel } from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' +import { extractReasoningMiddleware } from '@renderer/middlewares/extractReasoningMiddleware' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' import { EVENT_NAMES } from '@renderer/services/EventService' -import FileManager from '@renderer/services/FileManager' import { filterContextMessages, filterEmptyMessages, filterUserRoleStartMessages } from '@renderer/services/MessagesService' +import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' +import store from '@renderer/store' import { Assistant, + EFFORT_RATIO, FileTypes, - GenerateImageParams, MCPCallToolResponse, MCPTool, MCPToolResponse, @@ -32,205 +42,83 @@ import { Usage, WebSearchSource } from '@renderer/types' -import { ChunkType } from '@renderer/types/chunk' +import { ChunkType, LLMWebSearchCompleteChunk } from '@renderer/types/chunk' import { Message } from '@renderer/types/newMessage' import { removeSpecialCharactersForTopicName } from '@renderer/utils' import { addImageFileToContents } from '@renderer/utils/formats' -import { convertLinks } from '@renderer/utils/linkConverter' import { - mcpToolCallResponseToOpenAIMessage, - mcpToolsToOpenAIResponseTools, + convertLinks, + convertLinksToHunyuan, + convertLinksToOpenRouter, + convertLinksToZhipu +} from '@renderer/utils/linkConverter' +import { + mcpToolCallResponseToOpenAICompatibleMessage, + mcpToolsToOpenAIChatTools, openAIToolsToMcpTool, parseAndCallTools } from '@renderer/utils/mcp-tools' import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { buildSystemPrompt } from '@renderer/utils/prompt' +import { asyncGeneratorToReadableStream, readableStreamAsyncIterable } from '@renderer/utils/stream' import { isEmpty, takeRight } from 'lodash' -import OpenAI from 'openai' -import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources/chat/completions' -import { Stream } from 'openai/streaming' -import { FileLike, toFile } from 'openai/uploads' +import OpenAI, { AzureOpenAI } from 'openai' +import { + ChatCompletionContentPart, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, + ChatCompletionToolMessageParam +} from 'openai/resources' import { CompletionsParams } from '.' -import BaseProvider from './BaseProvider' +import { BaseOpenAiProvider } from './OpenAIResponseProvider' -export abstract class BaseOpenAiProvider extends BaseProvider { - protected sdk: OpenAI +// 1. 定义联合类型 +export type OpenAIStreamChunk = + | { type: 'reasoning' | 'text-delta'; textDelta: string } + | { type: 'tool-calls'; delta: any } + | { type: 'finish'; finishReason: any; usage: any; delta: any; chunk: any } +export default class OpenAIProvider extends BaseOpenAiProvider { constructor(provider: Provider) { super(provider) + if (provider.id === 'azure-openai' || provider.type === 'azure-openai') { + this.sdk = new AzureOpenAI({ + dangerouslyAllowBrowser: true, + apiKey: this.apiKey, + apiVersion: provider.apiVersion, + endpoint: provider.apiHost + }) + return + } + this.sdk = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.apiKey, baseURL: this.getBaseURL(), defaultHeaders: { - ...this.defaultHeaders() + ...this.defaultHeaders(), + ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), + ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) } }) } - abstract convertMcpTools(mcpTools: MCPTool[]): T[] - - abstract mcpToolCallResponseToMessage: ( - mcpToolResponse: MCPToolResponse, - resp: MCPCallToolResponse, - model: Model - ) => OpenAI.Responses.ResponseInputItem | ChatCompletionMessageParam | undefined - /** - * Extract the file content from the message - * @param message - The message - * @returns The file content + * Check if the provider does not support files + * @returns True if the provider does not support files, false otherwise */ - protected async extractFileContent(message: Message) { - const fileBlocks = findFileBlocks(message) - if (fileBlocks.length > 0) { - const textFileBlocks = fileBlocks.filter( - (fb) => fb.file && [FileTypes.TEXT, FileTypes.DOCUMENT].includes(fb.file.type) - ) - - if (textFileBlocks.length > 0) { - let text = '' - const divider = '\n\n---\n\n' - - for (const fileBlock of textFileBlocks) { - const file = fileBlock.file - const fileContent = (await window.api.file.read(file.id + file.ext)).trim() - const fileNameRow = 'file: ' + file.origin_name + '\n\n' - text = text + fileNameRow + fileContent + divider - } - - return text - } + private get isNotSupportFiles() { + if (this.provider?.isNotSupportArrayContent) { + return true } - return '' - } + const providers = ['deepseek', 'baichuan', 'minimax', 'xirang'] - private async getReponseMessageParam(message: Message, model: Model): Promise { - const isVision = isVisionModel(model) - const content = await this.getMessageContent(message) - const fileBlocks = findFileBlocks(message) - const imageBlocks = findImageBlocks(message) - - if (fileBlocks.length === 0 && imageBlocks.length === 0) { - if (message.role === 'assistant') { - return { - role: 'assistant', - content: content - } - } else { - return { - role: message.role === 'system' ? 'user' : message.role, - content: content ? [{ type: 'input_text', text: content }] : [] - } as OpenAI.Responses.EasyInputMessage - } - } - - const parts: OpenAI.Responses.ResponseInputContent[] = [] - if (content) { - parts.push({ - type: 'input_text', - text: content - }) - } - - for (const imageBlock of imageBlocks) { - if (isVision) { - if (imageBlock.file) { - const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) - parts.push({ - detail: 'auto', - type: 'input_image', - image_url: image.data as string - }) - } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { - parts.push({ - detail: 'auto', - type: 'input_image', - image_url: imageBlock.url - }) - } - } - } - - for (const fileBlock of fileBlocks) { - const file = fileBlock.file - if (!file) continue - - if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { - const fileContent = (await window.api.file.read(file.id + file.ext)).trim() - parts.push({ - type: 'input_text', - text: file.origin_name + '\n' + fileContent - }) - } - } - - return { - role: message.role === 'system' ? 'user' : message.role, - content: parts - } - } - - protected getServiceTier(model: Model) { - if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { - return 'flex' - } - if (isOpenAILLMModel(model)) { - return 'auto' - } - return undefined - } - - protected getTimeout(model: Model) { - if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { - return 15 * 1000 * 60 - } - return 5 * 1000 * 60 - } - - /** - * Get the temperature for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The temperature - */ - protected getTemperature(assistant: Assistant, model: Model) { - return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.temperature - } - - /** - * Get the top P for the assistant - * @param assistant - The assistant - * @param model - The model - * @returns The top P - */ - protected getTopP(assistant: Assistant, model: Model) { - return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.topP - } - - private getResponseReasoningEffort(assistant: Assistant, model: Model) { - if (!isSupportedReasoningEffortOpenAIModel(model)) { - return {} - } - - const reasoningEffort = assistant?.settings?.reasoning_effort - if (!reasoningEffort) { - return {} - } - - if (isSupportedReasoningEffortOpenAIModel(model)) { - return { - reasoning: { - effort: reasoningEffort as OpenAI.ReasoningEffort, - summary: 'detailed' - } as OpenAI.Reasoning - } - } - - return {} + return providers.includes(this.provider.id) } /** @@ -239,7 +127,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { * @param model - The model * @returns The message parameter */ - protected async getMessageParam( + override async getMessageParam( message: Message, model: Model ): Promise { @@ -255,6 +143,17 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } } + // If the model does not support files, extract the file content + if (this.isNotSupportFiles) { + const fileContent = await this.extractFileContent(message) + + return { + role: message.role === 'system' ? 'user' : message.role, + content: content + '\n\n---\n\n' + fileContent + } + } + + // If the model supports files, add the file content to the message const parts: ChatCompletionContentPart[] = [] if (content) { @@ -273,7 +172,7 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } for (const fileBlock of fileBlocks) { - const { file } = fileBlock + const file = fileBlock.file if (!file) { continue } @@ -294,10 +193,162 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } /** - * Generate completions for the assistant use Response API + * Get the temperature for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The temperature + */ + override getTemperature(assistant: Assistant, model: Model) { + return isReasoningModel(model) || isOpenAIWebSearch(model) ? undefined : assistant?.settings?.temperature + } + + /** + * Get the provider specific parameters for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The provider specific parameters + */ + private getProviderSpecificParameters(assistant: Assistant, model: Model) { + const { maxTokens } = getAssistantSettings(assistant) + + if (this.provider.id === 'openrouter') { + if (model.id.includes('deepseek-r1')) { + return { + include_reasoning: true + } + } + } + + if (isOpenAIReasoningModel(model)) { + return { + max_tokens: undefined, + max_completion_tokens: maxTokens + } + } + + return {} + } + + /** + * Get the top P for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The top P + */ + override getTopP(assistant: Assistant, model: Model) { + if (isReasoningModel(model) || isOpenAIWebSearch(model)) { + return undefined + } + + return assistant?.settings?.topP + } + + /** + * Get the reasoning effort for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The reasoning effort + */ + private getReasoningEffort(assistant: Assistant, model: Model) { + if (this.provider.id === 'groq') { + return {} + } + + if (!isReasoningModel(model)) { + return {} + } + const reasoningEffort = assistant?.settings?.reasoning_effort + if (!reasoningEffort) { + if (isSupportedThinkingTokenQwenModel(model)) { + return { enable_thinking: false } + } + + if (isSupportedThinkingTokenClaudeModel(model)) { + return { thinking: { type: 'disabled' } } + } + + return {} + } + const effortRatio = EFFORT_RATIO[reasoningEffort] + const budgetTokens = Math.floor((findTokenLimit(model.id)?.max || 0) * effortRatio) + // OpenRouter models + if (model.provider === 'openrouter') { + if (isSupportedReasoningEffortModel(model)) { + return { + reasoning: { + effort: assistant?.settings?.reasoning_effort + } + } + } + + if (isSupportedThinkingTokenModel(model)) { + return { + reasoning: { + max_tokens: budgetTokens + } + } + } + } + + // Qwen models + if (isSupportedThinkingTokenQwenModel(model)) { + return { + enable_thinking: true, + thinking_budget: budgetTokens + } + } + + // Grok models + if (isSupportedReasoningEffortGrokModel(model)) { + return { + reasoning_effort: assistant?.settings?.reasoning_effort + } + } + + // OpenAI models + if (isSupportedReasoningEffortOpenAIModel(model)) { + return { + reasoning_effort: assistant?.settings?.reasoning_effort + } + } + + // Claude models + if (isSupportedThinkingTokenClaudeModel(model)) { + return { + thinking: { + type: 'enabled', + budget_tokens: budgetTokens + } + } + } + + // Default case: no special thinking settings + return {} + } + + public convertMcpTools(mcpTools: MCPTool[]): T[] { + return mcpToolsToOpenAIChatTools(mcpTools) as T[] + } + + public mcpToolCallResponseToMessage = (mcpToolResponse: MCPToolResponse, resp: MCPCallToolResponse, model: Model) => { + if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { + return mcpToolCallResponseToOpenAICompatibleMessage(mcpToolResponse, resp, isVisionModel(model)) + } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { + const toolCallOut: ChatCompletionToolMessageParam = { + role: 'tool', + tool_call_id: mcpToolResponse.toolCallId, + content: JSON.stringify(resp.content) + } + return toolCallOut + } + return + } + + /** + * Generate completions for the assistant * @param messages - The messages * @param assistant - The assistant - * @param mcpTools + * @param mcpTools - The MCP tools * @param onChunk - The onChunk callback * @param onFilterMessages - The onFilterMessages callback * @returns The completions @@ -309,173 +360,70 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } const defaultModel = getDefaultModel() const model = assistant.model || defaultModel + const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) - const isEnabledBuiltinWebSearch = assistant.enableWebSearch - // 退回到 OpenAI 兼容模式 - if (isOpenAIWebSearch(model)) { - const systemMessage = { role: 'system', content: assistant.prompt || '' } - const userMessages: ChatCompletionMessageParam[] = [] - const _messages = filterUserRoleStartMessages( - filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) - ) - onFilterMessages(_messages) - - for (const message of _messages) { - userMessages.push(await this.getMessageParam(message, model)) - } - //当 systemMessage 内容为空时不发送 systemMessage - let reqMessages: ChatCompletionMessageParam[] - if (!systemMessage.content) { - reqMessages = [...userMessages] - } else { - reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] - } - const lastUserMessage = _messages.findLast((m) => m.role === 'user') - const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) - const { signal } = abortController - const start_time_millsec = new Date().getTime() - const response = await this.sdk.chat.completions - // @ts-ignore key is not typed - .create( - { - model: model.id, - messages: reqMessages, - stream: true, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_tokens: maxTokens, - ...getOpenAIWebSearchParams(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal - } - ) - const processStream = async (stream: any) => { - let content = '' - let isFirstChunk = true - const finalUsage: Usage = { - completion_tokens: 0, - prompt_tokens: 0, - total_tokens: 0 - } - - const finalMetrics: Metrics = { - completion_tokens: 0, - time_completion_millsec: 0, - time_first_token_millsec: 0 - } - for await (const chunk of stream as any) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break - } - const delta = chunk.choices[0]?.delta - const finishReason = chunk.choices[0]?.finish_reason - if (delta?.content) { - if (isOpenAIWebSearch(model)) { - delta.content = convertLinks(delta.content || '', isFirstChunk) - } - if (isFirstChunk) { - isFirstChunk = false - finalMetrics.time_first_token_millsec = new Date().getTime() - start_time_millsec - } - content += delta.content - onChunk({ type: ChunkType.TEXT_DELTA, text: delta.content }) - } - if (!isEmpty(finishReason) || chunk?.annotations) { - onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) - finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec - if (chunk.usage) { - const usage = chunk.usage as OpenAI.Completions.CompletionUsage - finalUsage.completion_tokens = usage.completion_tokens - finalUsage.prompt_tokens = usage.prompt_tokens - finalUsage.total_tokens = usage.total_tokens - } - finalMetrics.completion_tokens = finalUsage.completion_tokens - } - if (delta?.annotations) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - results: delta.annotations, - source: WebSearchSource.OPENAI_COMPATIBLE - } - }) - } - } - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: finalUsage, - metrics: finalMetrics - } - }) - } - await processStream(response).finally(cleanup) - await signalPromise?.promise?.catch((error) => { - throw error - }) - return - } - let tools: OpenAI.Responses.Tool[] = [] - const toolChoices: OpenAI.Responses.ToolChoiceTypes = { - type: 'web_search_preview' - } - if (isEnabledBuiltinWebSearch) { - tools.push({ - type: 'web_search_preview' - }) - } + const isEnabledBultinWebSearch = assistant.enableWebSearch messages = addImageFileToContents(messages) - const systemMessage: OpenAI.Responses.EasyInputMessage = { - role: 'system', - content: [] - } - const systemMessageContent: OpenAI.Responses.ResponseInputMessageContentList = [] - const systemMessageInput: OpenAI.Responses.ResponseInputText = { - text: assistant.prompt || '', - type: 'input_text' - } + const enableReasoning = + ((isSupportedThinkingTokenModel(model) || isSupportedReasoningEffortModel(model)) && + assistant.settings?.reasoning_effort !== undefined) || + (isReasoningModel(model) && (!isSupportedThinkingTokenModel(model) || !isSupportedReasoningEffortModel(model))) + let systemMessage = { role: 'system', content: assistant.prompt || '' } if (isSupportedReasoningEffortOpenAIModel(model)) { - systemMessage.role = 'developer' + systemMessage = { + role: 'developer', + content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` + } } - - const { tools: extraTools } = this.setupToolsConfig({ - mcpTools, - model, - enableToolUse - }) - - tools = tools.concat(extraTools) + const { tools } = this.setupToolsConfig({ mcpTools, model, enableToolUse }) if (this.useSystemPromptForTools) { - systemMessageInput.text = buildSystemPrompt(systemMessageInput.text || '', mcpTools) + systemMessage.content = buildSystemPrompt(systemMessage.content || '', mcpTools) } - systemMessageContent.push(systemMessageInput) - systemMessage.content = systemMessageContent + + const userMessages: ChatCompletionMessageParam[] = [] const _messages = filterUserRoleStartMessages( filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) ) onFilterMessages(_messages) - const userMessage: OpenAI.Responses.ResponseInputItem[] = [] + for (const message of _messages) { - userMessage.push(await this.getReponseMessageParam(message, model)) + userMessages.push(await this.getMessageParam(message, model)) + } + + const isSupportStreamOutput = () => { + return streamOutput } const lastUserMessage = _messages.findLast((m) => m.role === 'user') const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) const { signal } = abortController + await this.checkIsCopilot() - // 当 systemMessage 内容为空时不发送 systemMessage - let reqMessages: OpenAI.Responses.ResponseInput - if (!systemMessage.content) { - reqMessages = [...userMessage] - } else { - reqMessages = [systemMessage, ...userMessage].filter(Boolean) as OpenAI.Responses.EasyInputMessage[] + const lastUserMsg = userMessages.findLast((m) => m.role === 'user') + if (lastUserMsg && isSupportedThinkingTokenQwenModel(model)) { + const postsuffix = '/no_think' + // qwenThinkMode === true 表示思考模式啓用,此時不應添加 /no_think,如果存在則移除 + const qwenThinkModeEnabled = assistant.settings?.qwenThinkMode === true + const currentContent = lastUserMsg.content // content 類型:string | ChatCompletionContentPart[] | null + + lastUserMsg.content = processPostsuffixQwen3Model( + currentContent, + postsuffix, + qwenThinkModeEnabled + ) as ChatCompletionContentPart[] } - const finalUsage: Usage = { + //当 systemMessage 内容为空时不发送 systemMessage + let reqMessages: ChatCompletionMessageParam[] + if (!systemMessage.content) { + reqMessages = [...userMessages] + } else { + reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] + } + + let finalUsage: Usage = { completion_tokens: 0, prompt_tokens: 0, total_tokens: 0 @@ -492,55 +440,61 @@ export abstract class BaseOpenAiProvider extends BaseProvider { const processToolResults = async (toolResults: Awaited>, idx: number) => { if (toolResults.length === 0) return - toolResults.forEach((ts) => reqMessages.push(ts as OpenAI.Responses.EasyInputMessage)) + toolResults.forEach((ts) => reqMessages.push(ts as ChatCompletionMessageParam)) + + console.debug('[tool] reqMessages before processing', model.id, reqMessages) + reqMessages = processReqMessages(model, reqMessages) + console.debug('[tool] reqMessages', model.id, reqMessages) onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) - const stream = await this.sdk.responses.create( - { - model: model.id, - input: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_output_tokens: maxTokens, - stream: streamOutput, - tools: !isEmpty(tools) ? tools : undefined, - service_tier: this.getServiceTier(model), - ...this.getResponseReasoningEffort(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal, - timeout: this.getTimeout(model) - } - ) - await processStream(stream, idx + 1) + const newStream = await this.sdk.chat.completions + // @ts-ignore key is not typed + .create( + { + model: model.id, + messages: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_tokens: maxTokens, + keep_alive: this.keepAliveTime, + stream: isSupportStreamOutput(), + tools: !isEmpty(tools) ? tools : undefined, + ...getOpenAIWebSearchParams(assistant, model), + ...this.getReasoningEffort(assistant, model), + ...this.getProviderSpecificParameters(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal + } + ) + await processStream(newStream, idx + 1) } - const processToolCalls = async (mcpTools, toolCalls: OpenAI.Responses.ResponseFunctionToolCall[]) => { + const processToolCalls = async (mcpTools, toolCalls: ChatCompletionMessageToolCall[]) => { const mcpToolResponses = toolCalls .map((toolCall) => { - const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as OpenAI.Responses.ResponseFunctionToolCall) + const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as ChatCompletionMessageToolCall) if (!mcpTool) return undefined const parsedArgs = (() => { try { - return JSON.parse(toolCall.arguments) + return JSON.parse(toolCall.function.arguments) } catch { - return toolCall.arguments + return toolCall.function.arguments } })() return { - id: toolCall.call_id, - toolCallId: toolCall.call_id, + id: toolCall.id, + toolCallId: toolCall.id, tool: mcpTool, arguments: parsedArgs, status: 'pending' } as ToolCallResponse }) .filter((t): t is ToolCallResponse => typeof t !== 'undefined') - - return await parseAndCallTools( + return await parseAndCallTools( mcpToolResponses, toolResponses, onChunk, @@ -561,218 +515,308 @@ export abstract class BaseOpenAiProvider extends BaseProvider { ) } - const processStream = async ( - stream: Stream | OpenAI.Responses.Response, - idx: number - ) => { - const toolCalls: OpenAI.Responses.ResponseFunctionToolCall[] = [] + const processStream = async (stream: any, idx: number) => { + const toolCalls: ChatCompletionMessageToolCall[] = [] let time_first_token_millsec = 0 - if (!streamOutput) { - const nonStream = stream as OpenAI.Responses.Response - const time_completion_millsec = new Date().getTime() - start_time_millsec - const completion_tokens = - (nonStream.usage?.output_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) - const total_tokens = - (nonStream.usage?.total_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) - const finalMetrics = { - completion_tokens, - time_completion_millsec, - time_first_token_millsec: 0 - } - const finalUsage = { - completion_tokens, - prompt_tokens: nonStream.usage?.input_tokens || 0, - total_tokens - } + // Handle non-streaming case (already returns early, no change needed here) + if (!isSupportStreamOutput()) { + // Calculate final metrics once + finalMetrics.completion_tokens = stream.usage?.completion_tokens + finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec + + // Create a synthetic usage object if stream.usage is undefined + finalUsage = { ...stream.usage } + // Separate onChunk calls for text and usage/metrics let content = '' - - for (const output of nonStream.output) { - switch (output.type) { - case 'message': - if (output.content[0].type === 'output_text') { - onChunk({ type: ChunkType.TEXT_DELTA, text: output.content[0].text }) - onChunk({ type: ChunkType.TEXT_COMPLETE, text: output.content[0].text }) - content += output.content[0].text - if (output.content[0].annotations && output.content[0].annotations.length > 0) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - source: WebSearchSource.OPENAI, - results: output.content[0].annotations - } - }) - } - } - break - case 'reasoning': - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: output.summary.map((s) => s.text).join('\n'), - thinking_millsec: new Date().getTime() - start_time_millsec - }) - break - case 'function_call': - toolCalls.push(output) + stream.choices.forEach((choice) => { + // reasoning + if (choice.message.reasoning) { + onChunk({ type: ChunkType.THINKING_DELTA, text: choice.message.reasoning }) + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: choice.message.reasoning, + thinking_millsec: new Date().getTime() - start_time_millsec + }) + } + // text + if (choice.message.content) { + content += choice.message.content + onChunk({ type: ChunkType.TEXT_DELTA, text: choice.message.content }) + } + // tool call + if (choice.message.tool_calls && choice.message.tool_calls.length) { + choice.message.tool_calls.forEach((t) => toolCalls.push(t)) } - } - if (content) { reqMessages.push({ - role: 'assistant', - content: content - }) - } - if (toolCalls.length) { - toolCalls.forEach((toolCall) => { - reqMessages.push(toolCall) + role: choice.message.role, + content: choice.message.content, + tool_calls: toolCalls.length + ? toolCalls.map((toolCall) => ({ + id: toolCall.id, + function: { + ...toolCall.function, + arguments: + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments) + }, + type: 'function' + })) + : undefined }) + }) + + if (content.length) { + onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) } const toolResults: Awaited> = [] if (toolCalls.length) { toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) } - if (content.length) { - toolResults.push(...(await processToolUses(content))) + if (stream.choices[0].message?.content) { + toolResults.push(...(await processToolUses(stream.choices[0].message?.content))) } await processToolResults(toolResults, idx) - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: finalUsage, - metrics: finalMetrics - } - }) + // Always send usage and metrics data + onChunk({ type: ChunkType.BLOCK_COMPLETE, response: { usage: finalUsage, metrics: finalMetrics } }) return } + let content = '' + let thinkingContent = '' + let isFirstChunk = true - const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] + // 1. 初始化中间件 + const reasoningTags = [ + { openingTag: '', closingTag: '', separator: '\n' }, + { openingTag: '###Thinking', closingTag: '###Response', separator: '\n' } + ] + const getAppropriateTag = (model: Model) => { + if (model.id.includes('qwen3')) return reasoningTags[0] + return reasoningTags[0] + } + const reasoningTag = getAppropriateTag(model) + async function* openAIChunkToTextDelta(stream: any): AsyncGenerator { + for await (const chunk of stream) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } - for await (const chunk of stream as Stream) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break + const delta = chunk.choices[0]?.delta + if (delta?.reasoning_content || delta?.reasoning) { + yield { type: 'reasoning', textDelta: delta.reasoning_content || delta.reasoning } + } + if (delta?.content) { + yield { type: 'text-delta', textDelta: delta.content } + } + if (delta?.tool_calls) { + yield { type: 'tool-calls', delta: delta } + } + + const finishReason = chunk.choices[0]?.finish_reason + if (!isEmpty(finishReason)) { + yield { type: 'finish', finishReason, usage: chunk.usage, delta, chunk } + break + } } + } + + // 2. 使用中间件 + const { stream: processedStream } = await extractReasoningMiddleware({ + openingTag: reasoningTag?.openingTag, + closingTag: reasoningTag?.closingTag, + separator: reasoningTag?.separator, + enableReasoning + }).wrapStream({ + doStream: async () => ({ + stream: asyncGeneratorToReadableStream(openAIChunkToTextDelta(stream)) + }) + }) + + // 3. 消费 processedStream,分发 onChunk + for await (const chunk of readableStreamAsyncIterable(processedStream)) { + const delta = chunk.type === 'finish' ? chunk.delta : chunk + const rawChunk = chunk.type === 'finish' ? chunk.chunk : chunk + switch (chunk.type) { - case 'response.output_item.added': + case 'reasoning': { if (time_first_token_millsec === 0) { time_first_token_millsec = new Date().getTime() } - if (chunk.item.type === 'function_call') { - outputItems.push(chunk.item) - } - break - - case 'response.reasoning_summary_text.delta': + thinkingContent += chunk.textDelta onChunk({ type: ChunkType.THINKING_DELTA, - text: chunk.delta, + text: chunk.textDelta, thinking_millsec: new Date().getTime() - time_first_token_millsec }) break - case 'response.reasoning_summary_text.done': - onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: chunk.text, - thinking_millsec: new Date().getTime() - time_first_token_millsec - }) - break - case 'response.output_text.delta': { - let delta = chunk.delta - if (isEnabledBuiltinWebSearch) { - delta = convertLinks(delta) - } - onChunk({ - type: ChunkType.TEXT_DELTA, - text: delta - }) - content += delta - break } - case 'response.output_text.done': - onChunk({ - type: ChunkType.TEXT_COMPLETE, - text: content - }) - break - case 'response.function_call_arguments.done': { - const outputItem: OpenAI.Responses.ResponseOutputItem | undefined = outputItems.find( - (item) => item.id === chunk.item_id - ) - if (outputItem) { - if (outputItem.type === 'function_call') { - toolCalls.push({ - ...outputItem, - arguments: chunk.arguments + case 'text-delta': { + let textDelta = chunk.textDelta + if (assistant.enableWebSearch && delta) { + const originalDelta = rawChunk?.choices?.[0]?.delta + + if (originalDelta?.annotations) { + textDelta = convertLinks(textDelta, isFirstChunk) + } else if (assistant.model?.provider === 'openrouter') { + textDelta = convertLinksToOpenRouter(textDelta, isFirstChunk) + } else if (isZhipuModel(assistant.model)) { + textDelta = convertLinksToZhipu(textDelta, isFirstChunk) + } else if (isHunyuanSearchModel(assistant.model)) { + const searchResults = rawChunk?.search_info?.search_results || [] + textDelta = convertLinksToHunyuan(textDelta, searchResults, isFirstChunk) + } + } + if (isFirstChunk) { + isFirstChunk = false + if (time_first_token_millsec === 0) { + time_first_token_millsec = new Date().getTime() + } else { + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: thinkingContent, + thinking_millsec: new Date().getTime() - time_first_token_millsec }) } } - + content += textDelta + onChunk({ type: ChunkType.TEXT_DELTA, text: textDelta }) break } - case 'response.content_part.done': - if (chunk.part.type === 'output_text' && chunk.part.annotations && chunk.part.annotations.length > 0) { - onChunk({ - type: ChunkType.LLM_WEB_SEARCH_COMPLETE, - llm_web_search: { - source: WebSearchSource.OPENAI, - results: chunk.part.annotations - } - }) + case 'tool-calls': { + if (isFirstChunk) { + isFirstChunk = false + if (time_first_token_millsec === 0) { + time_first_token_millsec = new Date().getTime() + } else { + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: thinkingContent, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + } } - break - case 'response.completed': { - const completion_tokens = - (chunk.response.usage?.output_tokens || 0) + - (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) - const total_tokens = - (chunk.response.usage?.total_tokens || 0) + - (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) - finalUsage.completion_tokens += completion_tokens - finalUsage.prompt_tokens += chunk.response.usage?.input_tokens || 0 - finalUsage.total_tokens += total_tokens - finalMetrics.completion_tokens += completion_tokens - finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec - finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec - break - } - case 'error': - onChunk({ - type: ChunkType.ERROR, - error: { - message: chunk.message, - code: chunk.code + chunk.delta.tool_calls.forEach((toolCall) => { + const { id, index, type, function: fun } = toolCall + if (id && type === 'function' && fun) { + const { name, arguments: args } = fun + toolCalls.push({ + id, + function: { + name: name || '', + arguments: args || '' + }, + type: 'function' + }) + } else if (fun?.arguments) { + toolCalls[index].function.arguments += fun.arguments } }) break + } + case 'finish': { + const finishReason = chunk.finishReason + const usage = chunk.usage + const originalFinishDelta = chunk.delta + const originalFinishRawChunk = chunk.chunk + + if (!isEmpty(finishReason)) { + onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) + if (usage) { + finalUsage.completion_tokens += usage.completion_tokens || 0 + finalUsage.prompt_tokens += usage.prompt_tokens || 0 + finalUsage.total_tokens += usage.total_tokens || 0 + finalMetrics.completion_tokens += usage.completion_tokens || 0 + } + finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec + finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec + if (originalFinishDelta?.annotations) { + if (assistant.model?.provider === 'copilot') return + + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: originalFinishDelta.annotations, + source: WebSearchSource.OPENAI_RESPONSE + } + } as LLMWebSearchCompleteChunk) + } + if (assistant.model?.provider === 'perplexity') { + const citations = originalFinishRawChunk.citations + if (citations) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: citations, + source: WebSearchSource.PERPLEXITY + } + } as LLMWebSearchCompleteChunk) + } + } + if ( + isEnabledBultinWebSearch && + isZhipuModel(model) && + finishReason === 'stop' && + originalFinishRawChunk?.web_search + ) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: originalFinishRawChunk.web_search, + source: WebSearchSource.ZHIPU + } + } as LLMWebSearchCompleteChunk) + } + if ( + isEnabledBultinWebSearch && + isHunyuanSearchModel(model) && + originalFinishRawChunk?.search_info?.search_results + ) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: originalFinishRawChunk.search_info.search_results, + source: WebSearchSource.HUNYUAN + } + } as LLMWebSearchCompleteChunk) + } + } + break + } } - - // --- End of Incremental onChunk calls --- - } // End of for await loop - if (content) { - reqMessages.push({ - role: 'assistant', - content: content - }) - } - if (toolCalls.length) { - toolCalls.forEach((toolCall) => { - reqMessages.push(toolCall) - }) } - // Call processToolUses AFTER the loop finishes processing the main stream content - // Note: parseAndCallTools inside processToolUses should handle its own onChunk for tool responses - const toolResults: Awaited> = [] + reqMessages.push({ + role: 'assistant', + content: content, + tool_calls: toolCalls.length + ? toolCalls.map((toolCall) => ({ + id: toolCall.id, + function: { + ...toolCall.function, + arguments: + typeof toolCall.function.arguments === 'string' + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments) + }, + type: 'function' + })) + : undefined + }) + let toolResults: Awaited> = [] if (toolCalls.length) { - toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) + toolResults = await processToolCalls(mcpTools, toolCalls) } - if (content) { - toolResults.push(...(await processToolUses(content))) + if (content.length) { + toolResults = toolResults.concat(await processToolUses(content)) + } + if (toolResults.length) { + await processToolResults(toolResults, idx) } - await processToolResults(toolResults, idx) onChunk({ type: ChunkType.BLOCK_COMPLETE, @@ -783,27 +827,33 @@ export abstract class BaseOpenAiProvider extends BaseProvider { }) } + reqMessages = processReqMessages(model, reqMessages) + // 等待接口返回流 onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) const start_time_millsec = new Date().getTime() - const stream = await this.sdk.responses.create( - { - model: model.id, - input: reqMessages, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - max_output_tokens: maxTokens, - stream: streamOutput, - tools: tools.length > 0 ? tools : undefined, - tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined, - service_tier: this.getServiceTier(model), - ...this.getResponseReasoningEffort(assistant, model), - ...this.getCustomParameters(assistant) - }, - { - signal, - timeout: this.getTimeout(model) - } - ) + const stream = await this.sdk.chat.completions + // @ts-ignore key is not typed + .create( + { + model: model.id, + messages: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_tokens: maxTokens, + keep_alive: this.keepAliveTime, + stream: isSupportStreamOutput(), + tools: !isEmpty(tools) ? tools : undefined, + service_tier: this.getServiceTier(model), + ...getOpenAIWebSearchParams(assistant, model), + ...this.getReasoningEffort(assistant, model), + ...this.getProviderSpecificParameters(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal, + timeout: this.getTimeout(model) + } + ) await processStream(stream, 0).finally(cleanup) @@ -814,163 +864,218 @@ export abstract class BaseOpenAiProvider extends BaseProvider { } /** - * Translate the content - * @param content - The content + * Translate a message + * @param content * @param assistant - The assistant * @param onResponse - The onResponse callback - * @returns The translated content + * @returns The translated message */ - async translate( - content: string, - assistant: Assistant, - onResponse?: (text: string, isComplete: boolean) => void - ): Promise { + async translate(content: string, assistant: Assistant, onResponse?: (text: string, isComplete: boolean) => void) { const defaultModel = getDefaultModel() const model = assistant.model || defaultModel - const messageForApi: OpenAI.Responses.EasyInputMessage[] = content + + const messagesForApi = content ? [ - { - role: 'system', - content: assistant.prompt - }, - { - role: 'user', - content - } + { role: 'system', content: assistant.prompt }, + { role: 'user', content } ] : [{ role: 'user', content: assistant.prompt }] - const isOpenAIReasoning = isOpenAIReasoningModel(model) const isSupportedStreamOutput = () => { if (!onResponse) { return false } - return !isOpenAIReasoning + return true } const stream = isSupportedStreamOutput() - let text = '' - if (stream) { - const response = await this.sdk.responses.create({ - model: model.id, - input: messageForApi, - stream: true, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - ...this.getResponseReasoningEffort(assistant, model) - }) - for await (const chunk of response) { - switch (chunk.type) { - case 'response.output_text.delta': - text += chunk.delta - onResponse?.(text, false) - break - case 'response.output_text.done': - onResponse?.(chunk.text, true) - break - } - } - } else { - const response = await this.sdk.responses.create({ - model: model.id, - input: messageForApi, - stream: false, - temperature: this.getTemperature(assistant, model), - top_p: this.getTopP(assistant, model), - ...this.getResponseReasoningEffort(assistant, model) - }) - return response.output_text + await this.checkIsCopilot() + + // console.debug('[translate] reqMessages', model.id, message) + // @ts-ignore key is not typed + const response = await this.sdk.chat.completions.create({ + model: model.id, + messages: messagesForApi as ChatCompletionMessageParam[], + stream, + keep_alive: this.keepAliveTime, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + ...this.getReasoningEffort(assistant, model) + }) + + if (!stream) { + return response.choices[0].message?.content || '' } + let text = '' + let isThinking = false + const isReasoning = isReasoningModel(model) + + for await (const chunk of response) { + const deltaContent = chunk.choices[0]?.delta?.content || '' + + if (isReasoning) { + if (deltaContent.includes('')) { + isThinking = true + } + + if (!isThinking) { + text += deltaContent + onResponse?.(text, false) + } + + if (deltaContent.includes('')) { + isThinking = false + } + } else { + text += deltaContent + onResponse?.(text, false) + } + } + + onResponse?.(text, true) + return text } /** - * Summarize the messages + * Summarize a message * @param messages - The messages * @param assistant - The assistant * @returns The summary */ public async summaries(messages: Message[], assistant: Assistant): Promise { const model = getTopNamingModel() || assistant.model || getDefaultModel() + const userMessages = takeRight(messages, 5) .filter((message) => !message.isPreset) .map((message) => ({ role: message.role, content: getMainTextContent(message) })) + const userMessageContent = userMessages.reduce((prev, curr) => { const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}` return prev + (prev ? '\n' : '') + content }, '') - const systemMessage: OpenAI.Responses.EasyInputMessage = { + const systemMessage = { role: 'system', - content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title') + content: getStoreSetting('topicNamingPrompt') || i18n.t('prompts.title') } - const userMessage: OpenAI.Responses.EasyInputMessage = { + const userMessage = { role: 'user', content: userMessageContent } - const response = await this.sdk.responses.create({ + await this.checkIsCopilot() + + // @ts-ignore key is not typed + const response = await this.sdk.chat.completions.create({ model: model.id, - input: [systemMessage, userMessage], + messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], stream: false, - max_output_tokens: 1000 + keep_alive: this.keepAliveTime, + max_tokens: 1000 }) - return removeSpecialCharactersForTopicName(response.output_text.substring(0, 50)) + + // 针对思考类模型的返回,总结仅截取之后的内容 + let content = response.choices[0].message?.content || '' + content = content.replace(/^(.*?)<\/think>/s, '') + + return removeSpecialCharactersForTopicName(content.substring(0, 50)) } + /** + * Summarize a message for search + * @param messages - The messages + * @param assistant - The assistant + * @returns The summary + */ public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { - const model = getTopNamingModel() || assistant.model || getDefaultModel() - const systemMessage: OpenAI.Responses.EasyInputMessage = { + const model = assistant.model || getDefaultModel() + + const systemMessage = { role: 'system', content: assistant.prompt } + const messageContents = messages.map((m) => getMainTextContent(m)) const userMessageContent = messageContents.join('\n') - const userMessage: OpenAI.Responses.EasyInputMessage = { + + const userMessage = { role: 'user', content: userMessageContent } + const lastUserMessage = messages[messages.length - 1] const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) const { signal } = abortController - const response = await this.sdk.responses + const response = await this.sdk.chat.completions + // @ts-ignore key is not typed .create( { model: model.id, - input: [systemMessage, userMessage], + messages: [systemMessage, userMessage] as ChatCompletionMessageParam[], stream: false, - max_output_tokens: 1000 + keep_alive: this.keepAliveTime, + max_tokens: 1000 }, { - signal, - timeout: 20 * 1000 + timeout: 20 * 1000, + signal: signal } ) .finally(cleanup) - return response.output_text + // 针对思考类模型的返回,总结仅截取之后的内容 + let content = response.choices[0].message?.content || '' + content = content.replace(/^(.*?)<\/think>/s, '') + + return content } /** - * Generate suggestions + * Generate text + * @param prompt - The prompt + * @param content - The content + * @returns The generated text + */ + public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { + const model = getDefaultModel() + + await this.checkIsCopilot() + + const response = await this.sdk.chat.completions.create({ + model: model.id, + stream: false, + messages: [ + { role: 'system', content: prompt }, + { role: 'user', content } + ] + }) + + return response.choices[0].message?.content || '' + } + + /** + * Generate suggestions * @param messages - The messages * @param assistant - The assistant * @returns The suggestions */ async suggestions(messages: Message[], assistant: Assistant): Promise { - const model = assistant.model + const { model } = assistant if (!model) { return [] } + await this.checkIsCopilot() + const userMessagesForApi = messages .filter((m) => m.role === 'user') .map((m) => ({ @@ -993,63 +1098,53 @@ export abstract class BaseOpenAiProvider extends BaseProvider { return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || [] } - /** - * Generate text - * @param prompt - The prompt - * @param content - The content - * @returns The generated text - */ - public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { - const model = getDefaultModel() - const response = await this.sdk.responses.create({ - model: model.id, - stream: false, - input: [ - { role: 'system', content: prompt }, - { role: 'user', content } - ] - }) - return response.output_text - } - /** * Check if the model is valid * @param model - The model * @param stream - Whether to use streaming interface * @returns The validity of the model */ - public async check(model: Model, stream: boolean): Promise<{ valid: boolean; error: Error | null }> { + public async check(model: Model, stream: boolean = false): Promise<{ valid: boolean; error: Error | null }> { if (!model) { return { valid: false, error: new Error('No model found') } } - if (stream) { - const response = await this.sdk.responses.create({ - model: model.id, - input: [{ role: 'user', content: 'hi' }], - max_output_tokens: 1, - stream: true - }) - let hasContent = false - for await (const chunk of response) { - if (chunk.type === 'response.output_text.delta') { - hasContent = true + + const body = { + model: model.id, + messages: [{ role: 'user', content: 'hi' }], + max_completion_tokens: 1, // openAI + max_tokens: 1, // openAI deprecated 但大部分OpenAI兼容的提供商继续用这个头 + enable_thinking: false, // qwen3 + stream + } + + try { + await this.checkIsCopilot() + if (!stream) { + const response = await this.sdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming) + if (!response?.choices[0].message) { + throw new Error('Empty response') } - } - if (hasContent) { return { valid: true, error: null } + } else { + const response: any = await this.sdk.chat.completions.create(body as any) + // 等待整个流式响应结束 + let hasContent = false + for await (const chunk of response) { + if (chunk.choices?.[0]?.delta?.content) { + hasContent = true + } + } + if (hasContent) { + return { valid: true, error: null } + } + throw new Error('Empty streaming response') } - throw new Error('Empty streaming response') - } else { - const response = await this.sdk.responses.create({ - model: model.id, - input: [{ role: 'user', content: 'hi' }], - stream: false, - max_output_tokens: 1 - }) - if (!response.output_text) { - throw new Error('Empty response') + } catch (error: any) { + return { + valid: false, + error } - return { valid: true, error: null } } } @@ -1059,207 +1154,67 @@ export abstract class BaseOpenAiProvider extends BaseProvider { */ public async models(): Promise { try { + await this.checkIsCopilot() + const response = await this.sdk.models.list() + + if (this.provider.id === 'github') { + // @ts-ignore key is not typed + return response.body + .map((model) => ({ + id: model.name, + description: model.summary, + object: 'model', + owned_by: model.publisher + })) + .filter(isSupportedModel) + } + + if (this.provider.id === 'together') { + // @ts-ignore key is not typed + return response?.body + .map((model: any) => ({ + id: model.id, + description: model.display_name, + object: 'model', + owned_by: model.organization + })) + .filter(isSupportedModel) + } + const models = response.data || [] models.forEach((model) => { model.id = model.id.trim() }) + return models.filter(isSupportedModel) } catch (error) { return [] } } - /** - * Generate an image - * @param params - The parameters - * @returns The generated image - */ - public async generateImage({ - model, - prompt, - negativePrompt, - imageSize, - batchSize, - seed, - numInferenceSteps, - guidanceScale, - signal, - promptEnhancement - }: GenerateImageParams): Promise { - const response = (await this.sdk.request({ - method: 'post', - path: '/images/generations', - signal, - body: { - model, - prompt, - negative_prompt: negativePrompt, - image_size: imageSize, - batch_size: batchSize, - seed: seed ? parseInt(seed) : undefined, - num_inference_steps: numInferenceSteps, - guidance_scale: guidanceScale, - prompt_enhancement: promptEnhancement - } - })) as { data: Array<{ url: string }> } - - return response.data.map((item) => item.url) - } - - public async generateImageByChat({ messages, assistant, onChunk }: CompletionsParams): Promise { - const defaultModel = getDefaultModel() - const model = assistant.model || defaultModel - // save image data from the last assistant message - messages = addImageFileToContents(messages) - const lastUserMessage = messages.findLast((m) => m.role === 'user') - const lastAssistantMessage = messages.findLast((m) => m.role === 'assistant') - if (!lastUserMessage) { - return - } - - const { abortController } = this.createAbortController(lastUserMessage?.id, true) - const { signal } = abortController - const content = getMainTextContent(lastUserMessage!) - let response: OpenAI.Images.ImagesResponse | null = null - let images: FileLike[] = [] - - try { - if (lastUserMessage) { - const UserFiles = findImageBlocks(lastUserMessage) - const validUserFiles = UserFiles.filter((f) => f.file) // Filter out files that are undefined first - const userImages = await Promise.all( - validUserFiles.map(async (f) => { - // f.file is guaranteed to exist here due to the filter above - const fileInfo = f.file! - const binaryData = await FileManager.readBinaryImage(fileInfo) - return await toFile(binaryData, fileInfo.origin_name || 'image.png', { - type: 'image/png' - }) - }) - ) - images = images.concat(userImages) - } - - if (lastAssistantMessage) { - const assistantFiles = findImageBlocks(lastAssistantMessage) - const assistantImages = await Promise.all( - assistantFiles.filter(Boolean).map(async (f) => { - const base64Data = f?.url?.replace(/^data:image\/\w+;base64,/, '') - if (!base64Data) return null - const binary = atob(base64Data) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return await toFile(bytes, 'assistant_image.png', { - type: 'image/png' - }) - }) - ) - images = images.concat(assistantImages.filter(Boolean) as FileLike[]) - } - onChunk({ - type: ChunkType.IMAGE_CREATED - }) - - const start_time_millsec = new Date().getTime() - - if (images.length > 0) { - response = await this.sdk.images.edit( - { - model: model.id, - image: images, - prompt: content || '' - }, - { - signal, - timeout: 300_000 - } - ) - } else { - response = await this.sdk.images.generate( - { - model: model.id, - prompt: content || '', - response_format: model.id.includes('gpt-image-1') ? undefined : 'b64_json' - }, - { - signal, - timeout: 300_000 - } - ) - } - - onChunk({ - type: ChunkType.IMAGE_COMPLETE, - image: { - type: 'base64', - images: response?.data?.map((item) => `data:image/png;base64,${item.b64_json}`) || [] - } - }) - - onChunk({ - type: ChunkType.BLOCK_COMPLETE, - response: { - usage: { - completion_tokens: response.usage?.output_tokens || 0, - prompt_tokens: response.usage?.input_tokens || 0, - total_tokens: response.usage?.total_tokens || 0 - }, - metrics: { - completion_tokens: response.usage?.output_tokens || 0, - time_first_token_millsec: 0, // Non-streaming, first token time is not relevant - time_completion_millsec: new Date().getTime() - start_time_millsec - } - } - }) - } catch (error: any) { - console.error('[generateImageByChat] error', error) - onChunk({ - type: ChunkType.ERROR, - error - }) - } - } - /** * Get the embedding dimensions * @param model - The model * @returns The embedding dimensions */ public async getEmbeddingDimensions(model: Model): Promise { + await this.checkIsCopilot() + const data = await this.sdk.embeddings.create({ model: model.id, - input: 'hi' + input: model?.provider === 'baidu-cloud' ? ['hi'] : 'hi' }) return data.data[0].embedding.length } -} -export default class OpenAIProvider extends BaseOpenAiProvider { - constructor(provider: Provider) { - super(provider) - } - - public convertMcpTools(mcpTools: MCPTool[]) { - return mcpToolsToOpenAIResponseTools(mcpTools) as T[] - } - - public mcpToolCallResponseToMessage = ( - mcpToolResponse: MCPToolResponse, - resp: MCPCallToolResponse, - model: Model - ): OpenAI.Responses.ResponseInputItem | undefined => { - if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { - return mcpToolCallResponseToOpenAIMessage(mcpToolResponse, resp, isVisionModel(model)) - } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { - return { - type: 'function_call_output', - call_id: mcpToolResponse.toolCallId, - output: JSON.stringify(resp.content) - } + public async checkIsCopilot() { + if (this.provider.id !== 'copilot') { + return } - return + const defaultHeaders = store.getState().copilot.defaultHeaders + // copilot每次请求前需要重新获取token,因为token中附带时间戳 + const { token } = await window.api.copilot.getToken(defaultHeaders) + this.sdk.apiKey = token } } diff --git a/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts new file mode 100644 index 0000000000..8ebcc475a1 --- /dev/null +++ b/src/renderer/src/providers/AiProvider/OpenAIResponseProvider.ts @@ -0,0 +1,1265 @@ +import { + getOpenAIWebSearchParams, + isOpenAILLMModel, + isOpenAIReasoningModel, + isOpenAIWebSearch, + isSupportedModel, + isSupportedReasoningEffortOpenAIModel, + isVisionModel +} from '@renderer/config/models' +import { getStoreSetting } from '@renderer/hooks/useSettings' +import i18n from '@renderer/i18n' +import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/AssistantService' +import { EVENT_NAMES } from '@renderer/services/EventService' +import FileManager from '@renderer/services/FileManager' +import { + filterContextMessages, + filterEmptyMessages, + filterUserRoleStartMessages +} from '@renderer/services/MessagesService' +import { + Assistant, + FileTypes, + GenerateImageParams, + MCPCallToolResponse, + MCPTool, + MCPToolResponse, + Metrics, + Model, + Provider, + Suggestion, + ToolCallResponse, + Usage, + WebSearchSource +} from '@renderer/types' +import { ChunkType } from '@renderer/types/chunk' +import { Message } from '@renderer/types/newMessage' +import { removeSpecialCharactersForTopicName } from '@renderer/utils' +import { addImageFileToContents } from '@renderer/utils/formats' +import { convertLinks } from '@renderer/utils/linkConverter' +import { + mcpToolCallResponseToOpenAIMessage, + mcpToolsToOpenAIResponseTools, + openAIToolsToMcpTool, + parseAndCallTools +} from '@renderer/utils/mcp-tools' +import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { buildSystemPrompt } from '@renderer/utils/prompt' +import { isEmpty, takeRight } from 'lodash' +import OpenAI from 'openai' +import { ChatCompletionContentPart, ChatCompletionMessageParam } from 'openai/resources/chat/completions' +import { Stream } from 'openai/streaming' +import { FileLike, toFile } from 'openai/uploads' + +import { CompletionsParams } from '.' +import BaseProvider from './BaseProvider' + +export abstract class BaseOpenAiProvider extends BaseProvider { + protected sdk: OpenAI + + constructor(provider: Provider) { + super(provider) + + this.sdk = new OpenAI({ + dangerouslyAllowBrowser: true, + apiKey: this.apiKey, + baseURL: this.getBaseURL(), + defaultHeaders: { + ...this.defaultHeaders() + } + }) + } + + abstract convertMcpTools(mcpTools: MCPTool[]): T[] + + abstract mcpToolCallResponseToMessage: ( + mcpToolResponse: MCPToolResponse, + resp: MCPCallToolResponse, + model: Model + ) => OpenAI.Responses.ResponseInputItem | ChatCompletionMessageParam | undefined + + /** + * Extract the file content from the message + * @param message - The message + * @returns The file content + */ + protected async extractFileContent(message: Message) { + const fileBlocks = findFileBlocks(message) + if (fileBlocks.length > 0) { + const textFileBlocks = fileBlocks.filter( + (fb) => fb.file && [FileTypes.TEXT, FileTypes.DOCUMENT].includes(fb.file.type) + ) + + if (textFileBlocks.length > 0) { + let text = '' + const divider = '\n\n---\n\n' + + for (const fileBlock of textFileBlocks) { + const file = fileBlock.file + const fileContent = (await window.api.file.read(file.id + file.ext)).trim() + const fileNameRow = 'file: ' + file.origin_name + '\n\n' + text = text + fileNameRow + fileContent + divider + } + + return text + } + } + + return '' + } + + private async getReponseMessageParam(message: Message, model: Model): Promise { + const isVision = isVisionModel(model) + const content = await this.getMessageContent(message) + const fileBlocks = findFileBlocks(message) + const imageBlocks = findImageBlocks(message) + + if (fileBlocks.length === 0 && imageBlocks.length === 0) { + if (message.role === 'assistant') { + return { + role: 'assistant', + content: content + } + } else { + return { + role: message.role === 'system' ? 'user' : message.role, + content: content ? [{ type: 'input_text', text: content }] : [] + } as OpenAI.Responses.EasyInputMessage + } + } + + const parts: OpenAI.Responses.ResponseInputContent[] = [] + if (content) { + parts.push({ + type: 'input_text', + text: content + }) + } + + for (const imageBlock of imageBlocks) { + if (isVision) { + if (imageBlock.file) { + const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) + parts.push({ + detail: 'auto', + type: 'input_image', + image_url: image.data as string + }) + } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { + parts.push({ + detail: 'auto', + type: 'input_image', + image_url: imageBlock.url + }) + } + } + } + + for (const fileBlock of fileBlocks) { + const file = fileBlock.file + if (!file) continue + + if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { + const fileContent = (await window.api.file.read(file.id + file.ext)).trim() + parts.push({ + type: 'input_text', + text: file.origin_name + '\n' + fileContent + }) + } + } + + return { + role: message.role === 'system' ? 'user' : message.role, + content: parts + } + } + + protected getServiceTier(model: Model) { + if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { + return 'flex' + } + if (isOpenAILLMModel(model)) { + return 'auto' + } + return undefined + } + + protected getTimeout(model: Model) { + if ((model.id.includes('o3') && !model.id.includes('o3-mini')) || model.id.includes('o4-mini')) { + return 15 * 1000 * 60 + } + return 5 * 1000 * 60 + } + + /** + * Get the temperature for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The temperature + */ + protected getTemperature(assistant: Assistant, model: Model) { + return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.temperature + } + + /** + * Get the top P for the assistant + * @param assistant - The assistant + * @param model - The model + * @returns The top P + */ + protected getTopP(assistant: Assistant, model: Model) { + return isOpenAIReasoningModel(model) || isOpenAILLMModel(model) ? undefined : assistant?.settings?.topP + } + + private getResponseReasoningEffort(assistant: Assistant, model: Model) { + if (!isSupportedReasoningEffortOpenAIModel(model)) { + return {} + } + + const reasoningEffort = assistant?.settings?.reasoning_effort + if (!reasoningEffort) { + return {} + } + + if (isSupportedReasoningEffortOpenAIModel(model)) { + return { + reasoning: { + effort: reasoningEffort as OpenAI.ReasoningEffort, + summary: 'detailed' + } as OpenAI.Reasoning + } + } + + return {} + } + + /** + * Get the message parameter + * @param message - The message + * @param model - The model + * @returns The message parameter + */ + protected async getMessageParam( + message: Message, + model: Model + ): Promise { + const isVision = isVisionModel(model) + const content = await this.getMessageContent(message) + const fileBlocks = findFileBlocks(message) + const imageBlocks = findImageBlocks(message) + + if (fileBlocks.length === 0 && imageBlocks.length === 0) { + return { + role: message.role === 'system' ? 'user' : message.role, + content + } + } + + const parts: ChatCompletionContentPart[] = [] + + if (content) { + parts.push({ type: 'text', text: content }) + } + + for (const imageBlock of imageBlocks) { + if (isVision) { + if (imageBlock.file) { + const image = await window.api.file.base64Image(imageBlock.file.id + imageBlock.file.ext) + parts.push({ type: 'image_url', image_url: { url: image.data } }) + } else if (imageBlock.url && imageBlock.url.startsWith('data:')) { + parts.push({ type: 'image_url', image_url: { url: imageBlock.url } }) + } + } + } + + for (const fileBlock of fileBlocks) { + const { file } = fileBlock + if (!file) { + continue + } + + if ([FileTypes.TEXT, FileTypes.DOCUMENT].includes(file.type)) { + const fileContent = await (await window.api.file.read(file.id + file.ext)).trim() + parts.push({ + type: 'text', + text: file.origin_name + '\n' + fileContent + }) + } + } + + return { + role: message.role === 'system' ? 'user' : message.role, + content: parts + } as ChatCompletionMessageParam + } + + /** + * Generate completions for the assistant use Response API + * @param messages - The messages + * @param assistant - The assistant + * @param mcpTools + * @param onChunk - The onChunk callback + * @param onFilterMessages - The onFilterMessages callback + * @returns The completions + */ + async completions({ messages, assistant, mcpTools, onChunk, onFilterMessages }: CompletionsParams): Promise { + if (assistant.enableGenerateImage) { + await this.generateImageByChat({ messages, assistant, onChunk } as CompletionsParams) + return + } + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const { contextCount, maxTokens, streamOutput, enableToolUse } = getAssistantSettings(assistant) + const isEnabledBuiltinWebSearch = assistant.enableWebSearch + // 退回到 OpenAI 兼容模式 + if (isOpenAIWebSearch(model)) { + const systemMessage = { role: 'system', content: assistant.prompt || '' } + const userMessages: ChatCompletionMessageParam[] = [] + const _messages = filterUserRoleStartMessages( + filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) + ) + onFilterMessages(_messages) + + for (const message of _messages) { + userMessages.push(await this.getMessageParam(message, model)) + } + //当 systemMessage 内容为空时不发送 systemMessage + let reqMessages: ChatCompletionMessageParam[] + if (!systemMessage.content) { + reqMessages = [...userMessages] + } else { + reqMessages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[] + } + const lastUserMessage = _messages.findLast((m) => m.role === 'user') + const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) + const { signal } = abortController + const start_time_millsec = new Date().getTime() + const response = await this.sdk.chat.completions + // @ts-ignore key is not typed + .create( + { + model: model.id, + messages: reqMessages, + stream: true, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_tokens: maxTokens, + ...getOpenAIWebSearchParams(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal + } + ) + const processStream = async (stream: any) => { + let content = '' + let isFirstChunk = true + const finalUsage: Usage = { + completion_tokens: 0, + prompt_tokens: 0, + total_tokens: 0 + } + + const finalMetrics: Metrics = { + completion_tokens: 0, + time_completion_millsec: 0, + time_first_token_millsec: 0 + } + for await (const chunk of stream as any) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } + const delta = chunk.choices[0]?.delta + const finishReason = chunk.choices[0]?.finish_reason + if (delta?.content) { + if (isOpenAIWebSearch(model)) { + delta.content = convertLinks(delta.content || '', isFirstChunk) + } + if (isFirstChunk) { + isFirstChunk = false + finalMetrics.time_first_token_millsec = new Date().getTime() - start_time_millsec + } + content += delta.content + onChunk({ type: ChunkType.TEXT_DELTA, text: delta.content }) + } + if (!isEmpty(finishReason) || chunk?.annotations) { + onChunk({ type: ChunkType.TEXT_COMPLETE, text: content }) + finalMetrics.time_completion_millsec = new Date().getTime() - start_time_millsec + if (chunk.usage) { + const usage = chunk.usage as OpenAI.Completions.CompletionUsage + finalUsage.completion_tokens = usage.completion_tokens + finalUsage.prompt_tokens = usage.prompt_tokens + finalUsage.total_tokens = usage.total_tokens + } + finalMetrics.completion_tokens = finalUsage.completion_tokens + } + if (delta?.annotations) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + results: delta.annotations, + source: WebSearchSource.OPENAI + } + }) + } + } + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: finalUsage, + metrics: finalMetrics + } + }) + } + await processStream(response).finally(cleanup) + await signalPromise?.promise?.catch((error) => { + throw error + }) + return + } + let tools: OpenAI.Responses.Tool[] = [] + const toolChoices: OpenAI.Responses.ToolChoiceTypes = { + type: 'web_search_preview' + } + if (isEnabledBuiltinWebSearch) { + tools.push({ + type: 'web_search_preview' + }) + } + messages = addImageFileToContents(messages) + const systemMessage: OpenAI.Responses.EasyInputMessage = { + role: 'system', + content: [] + } + const systemMessageContent: OpenAI.Responses.ResponseInputMessageContentList = [] + const systemMessageInput: OpenAI.Responses.ResponseInputText = { + text: assistant.prompt || '', + type: 'input_text' + } + if (isSupportedReasoningEffortOpenAIModel(model)) { + systemMessage.role = 'developer' + } + + const { tools: extraTools } = this.setupToolsConfig({ + mcpTools, + model, + enableToolUse + }) + + tools = tools.concat(extraTools) + + if (this.useSystemPromptForTools) { + systemMessageInput.text = buildSystemPrompt(systemMessageInput.text || '', mcpTools) + } + systemMessageContent.push(systemMessageInput) + systemMessage.content = systemMessageContent + const _messages = filterUserRoleStartMessages( + filterEmptyMessages(filterContextMessages(takeRight(messages, contextCount + 1))) + ) + + onFilterMessages(_messages) + const userMessage: OpenAI.Responses.ResponseInputItem[] = [] + for (const message of _messages) { + userMessage.push(await this.getReponseMessageParam(message, model)) + } + + const lastUserMessage = _messages.findLast((m) => m.role === 'user') + const { abortController, cleanup, signalPromise } = this.createAbortController(lastUserMessage?.id, true) + const { signal } = abortController + + // 当 systemMessage 内容为空时不发送 systemMessage + let reqMessages: OpenAI.Responses.ResponseInput + if (!systemMessage.content) { + reqMessages = [...userMessage] + } else { + reqMessages = [systemMessage, ...userMessage].filter(Boolean) as OpenAI.Responses.EasyInputMessage[] + } + + const finalUsage: Usage = { + completion_tokens: 0, + prompt_tokens: 0, + total_tokens: 0 + } + + const finalMetrics: Metrics = { + completion_tokens: 0, + time_completion_millsec: 0, + time_first_token_millsec: 0 + } + + const toolResponses: MCPToolResponse[] = [] + + const processToolResults = async (toolResults: Awaited>, idx: number) => { + if (toolResults.length === 0) return + + toolResults.forEach((ts) => reqMessages.push(ts as OpenAI.Responses.EasyInputMessage)) + + onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const stream = await this.sdk.responses.create( + { + model: model.id, + input: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_output_tokens: maxTokens, + stream: streamOutput, + tools: !isEmpty(tools) ? tools : undefined, + service_tier: this.getServiceTier(model), + ...this.getResponseReasoningEffort(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal, + timeout: this.getTimeout(model) + } + ) + await processStream(stream, idx + 1) + } + + const processToolCalls = async (mcpTools, toolCalls: OpenAI.Responses.ResponseFunctionToolCall[]) => { + const mcpToolResponses = toolCalls + .map((toolCall) => { + const mcpTool = openAIToolsToMcpTool(mcpTools, toolCall as OpenAI.Responses.ResponseFunctionToolCall) + if (!mcpTool) return undefined + + const parsedArgs = (() => { + try { + return JSON.parse(toolCall.arguments) + } catch { + return toolCall.arguments + } + })() + + return { + id: toolCall.call_id, + toolCallId: toolCall.call_id, + tool: mcpTool, + arguments: parsedArgs, + status: 'pending' + } as ToolCallResponse + }) + .filter((t): t is ToolCallResponse => typeof t !== 'undefined') + + return await parseAndCallTools( + mcpToolResponses, + toolResponses, + onChunk, + this.mcpToolCallResponseToMessage, + model, + mcpTools + ) + } + + const processToolUses = async (content: string) => { + return await parseAndCallTools( + content, + toolResponses, + onChunk, + this.mcpToolCallResponseToMessage, + model, + mcpTools + ) + } + + const processStream = async ( + stream: Stream | OpenAI.Responses.Response, + idx: number + ) => { + const toolCalls: OpenAI.Responses.ResponseFunctionToolCall[] = [] + let time_first_token_millsec = 0 + + if (!streamOutput) { + const nonStream = stream as OpenAI.Responses.Response + const time_completion_millsec = new Date().getTime() - start_time_millsec + const completion_tokens = + (nonStream.usage?.output_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) + const total_tokens = + (nonStream.usage?.total_tokens || 0) + (nonStream.usage?.output_tokens_details.reasoning_tokens ?? 0) + const finalMetrics = { + completion_tokens, + time_completion_millsec, + time_first_token_millsec: 0 + } + const finalUsage = { + completion_tokens, + prompt_tokens: nonStream.usage?.input_tokens || 0, + total_tokens + } + let content = '' + + for (const output of nonStream.output) { + switch (output.type) { + case 'message': + if (output.content[0].type === 'output_text') { + onChunk({ type: ChunkType.TEXT_DELTA, text: output.content[0].text }) + onChunk({ type: ChunkType.TEXT_COMPLETE, text: output.content[0].text }) + content += output.content[0].text + if (output.content[0].annotations && output.content[0].annotations.length > 0) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + source: WebSearchSource.OPENAI_RESPONSE, + results: output.content[0].annotations + } + }) + } + } + break + case 'reasoning': + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: output.summary.map((s) => s.text).join('\n'), + thinking_millsec: new Date().getTime() - start_time_millsec + }) + break + case 'function_call': + toolCalls.push(output) + } + } + + if (content) { + reqMessages.push({ + role: 'assistant', + content: content + }) + } + if (toolCalls.length) { + toolCalls.forEach((toolCall) => { + reqMessages.push(toolCall) + }) + } + + const toolResults: Awaited> = [] + if (toolCalls.length) { + toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) + } + if (content.length) { + toolResults.push(...(await processToolUses(content))) + } + await processToolResults(toolResults, idx) + + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: finalUsage, + metrics: finalMetrics + } + }) + return + } + let content = '' + + const outputItems: OpenAI.Responses.ResponseOutputItem[] = [] + + for await (const chunk of stream as Stream) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } + switch (chunk.type) { + case 'response.output_item.added': + if (time_first_token_millsec === 0) { + time_first_token_millsec = new Date().getTime() + } + if (chunk.item.type === 'function_call') { + outputItems.push(chunk.item) + } + break + + case 'response.reasoning_summary_text.delta': + onChunk({ + type: ChunkType.THINKING_DELTA, + text: chunk.delta, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + break + case 'response.reasoning_summary_text.done': + onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: chunk.text, + thinking_millsec: new Date().getTime() - time_first_token_millsec + }) + break + case 'response.output_text.delta': { + let delta = chunk.delta + if (isEnabledBuiltinWebSearch) { + delta = convertLinks(delta) + } + onChunk({ + type: ChunkType.TEXT_DELTA, + text: delta + }) + content += delta + break + } + case 'response.output_text.done': + onChunk({ + type: ChunkType.TEXT_COMPLETE, + text: content + }) + break + case 'response.function_call_arguments.done': { + const outputItem: OpenAI.Responses.ResponseOutputItem | undefined = outputItems.find( + (item) => item.id === chunk.item_id + ) + if (outputItem) { + if (outputItem.type === 'function_call') { + toolCalls.push({ + ...outputItem, + arguments: chunk.arguments + }) + } + } + + break + } + case 'response.content_part.done': + if (chunk.part.type === 'output_text' && chunk.part.annotations && chunk.part.annotations.length > 0) { + onChunk({ + type: ChunkType.LLM_WEB_SEARCH_COMPLETE, + llm_web_search: { + source: WebSearchSource.OPENAI, + results: chunk.part.annotations + } + }) + } + break + case 'response.completed': { + const completion_tokens = + (chunk.response.usage?.output_tokens || 0) + + (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) + const total_tokens = + (chunk.response.usage?.total_tokens || 0) + + (chunk.response.usage?.output_tokens_details.reasoning_tokens ?? 0) + finalUsage.completion_tokens += completion_tokens + finalUsage.prompt_tokens += chunk.response.usage?.input_tokens || 0 + finalUsage.total_tokens += total_tokens + finalMetrics.completion_tokens += completion_tokens + finalMetrics.time_completion_millsec += new Date().getTime() - start_time_millsec + finalMetrics.time_first_token_millsec = time_first_token_millsec - start_time_millsec + break + } + case 'error': + onChunk({ + type: ChunkType.ERROR, + error: { + message: chunk.message, + code: chunk.code + } + }) + break + } + + // --- End of Incremental onChunk calls --- + } // End of for await loop + if (content) { + reqMessages.push({ + role: 'assistant', + content: content + }) + } + if (toolCalls.length) { + toolCalls.forEach((toolCall) => { + reqMessages.push(toolCall) + }) + } + + // Call processToolUses AFTER the loop finishes processing the main stream content + // Note: parseAndCallTools inside processToolUses should handle its own onChunk for tool responses + const toolResults: Awaited> = [] + if (toolCalls.length) { + toolResults.push(...(await processToolCalls(mcpTools, toolCalls))) + } + if (content) { + toolResults.push(...(await processToolUses(content))) + } + await processToolResults(toolResults, idx) + + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: finalUsage, + metrics: finalMetrics + } + }) + } + + onChunk({ type: ChunkType.LLM_RESPONSE_CREATED }) + const start_time_millsec = new Date().getTime() + const stream = await this.sdk.responses.create( + { + model: model.id, + input: reqMessages, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + max_output_tokens: maxTokens, + stream: streamOutput, + tools: tools.length > 0 ? tools : undefined, + tool_choice: isEnabledBuiltinWebSearch ? toolChoices : undefined, + service_tier: this.getServiceTier(model), + ...this.getResponseReasoningEffort(assistant, model), + ...this.getCustomParameters(assistant) + }, + { + signal, + timeout: this.getTimeout(model) + } + ) + + await processStream(stream, 0).finally(cleanup) + + // 捕获signal的错误 + await signalPromise?.promise?.catch((error) => { + throw error + }) + } + + /** + * Translate the content + * @param content - The content + * @param assistant - The assistant + * @param onResponse - The onResponse callback + * @returns The translated content + */ + async translate( + content: string, + assistant: Assistant, + onResponse?: (text: string, isComplete: boolean) => void + ): Promise { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + const messageForApi: OpenAI.Responses.EasyInputMessage[] = content + ? [ + { + role: 'system', + content: assistant.prompt + }, + { + role: 'user', + content + } + ] + : [{ role: 'user', content: assistant.prompt }] + + const isOpenAIReasoning = isOpenAIReasoningModel(model) + const isSupportedStreamOutput = () => { + if (!onResponse) { + return false + } + return !isOpenAIReasoning + } + + const stream = isSupportedStreamOutput() + let text = '' + if (stream) { + const response = await this.sdk.responses.create({ + model: model.id, + input: messageForApi, + stream: true, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + ...this.getResponseReasoningEffort(assistant, model) + }) + + for await (const chunk of response) { + switch (chunk.type) { + case 'response.output_text.delta': + text += chunk.delta + onResponse?.(text, false) + break + case 'response.output_text.done': + onResponse?.(chunk.text, true) + break + } + } + } else { + const response = await this.sdk.responses.create({ + model: model.id, + input: messageForApi, + stream: false, + temperature: this.getTemperature(assistant, model), + top_p: this.getTopP(assistant, model), + ...this.getResponseReasoningEffort(assistant, model) + }) + return response.output_text + } + + return text + } + + /** + * Summarize the messages + * @param messages - The messages + * @param assistant - The assistant + * @returns The summary + */ + public async summaries(messages: Message[], assistant: Assistant): Promise { + const model = getTopNamingModel() || assistant.model || getDefaultModel() + const userMessages = takeRight(messages, 5) + .filter((message) => !message.isPreset) + .map((message) => ({ + role: message.role, + content: getMainTextContent(message) + })) + const userMessageContent = userMessages.reduce((prev, curr) => { + const content = curr.role === 'user' ? `User: ${curr.content}` : `Assistant: ${curr.content}` + return prev + (prev ? '\n' : '') + content + }, '') + + const systemMessage: OpenAI.Responses.EasyInputMessage = { + role: 'system', + content: (getStoreSetting('topicNamingPrompt') as string) || i18n.t('prompts.title') + } + + const userMessage: OpenAI.Responses.EasyInputMessage = { + role: 'user', + content: userMessageContent + } + + const response = await this.sdk.responses.create({ + model: model.id, + input: [systemMessage, userMessage], + stream: false, + max_output_tokens: 1000 + }) + return removeSpecialCharactersForTopicName(response.output_text.substring(0, 50)) + } + + public async summaryForSearch(messages: Message[], assistant: Assistant): Promise { + const model = getTopNamingModel() || assistant.model || getDefaultModel() + const systemMessage: OpenAI.Responses.EasyInputMessage = { + role: 'system', + content: assistant.prompt + } + const messageContents = messages.map((m) => getMainTextContent(m)) + const userMessageContent = messageContents.join('\n') + const userMessage: OpenAI.Responses.EasyInputMessage = { + role: 'user', + content: userMessageContent + } + const lastUserMessage = messages[messages.length - 1] + const { abortController, cleanup } = this.createAbortController(lastUserMessage?.id) + const { signal } = abortController + + const response = await this.sdk.responses + .create( + { + model: model.id, + input: [systemMessage, userMessage], + stream: false, + max_output_tokens: 1000 + }, + { + signal, + timeout: 20 * 1000 + } + ) + .finally(cleanup) + + return response.output_text + } + + /** + * Generate suggestions + * @param messages - The messages + * @param assistant - The assistant + * @returns The suggestions + */ + async suggestions(messages: Message[], assistant: Assistant): Promise { + const model = assistant.model + + if (!model) { + return [] + } + + const userMessagesForApi = messages + .filter((m) => m.role === 'user') + .map((m) => ({ + role: m.role, + content: getMainTextContent(m) + })) + + const response: any = await this.sdk.request({ + method: 'post', + path: '/advice_questions', + body: { + messages: userMessagesForApi, + model: model.id, + max_tokens: 0, + temperature: 0, + n: 0 + } + }) + + return response?.questions?.filter(Boolean)?.map((q: any) => ({ content: q })) || [] + } + + /** + * Generate text + * @param prompt - The prompt + * @param content - The content + * @returns The generated text + */ + public async generateText({ prompt, content }: { prompt: string; content: string }): Promise { + const model = getDefaultModel() + const response = await this.sdk.responses.create({ + model: model.id, + stream: false, + input: [ + { role: 'system', content: prompt }, + { role: 'user', content } + ] + }) + return response.output_text + } + + /** + * Check if the model is valid + * @param model - The model + * @param stream - Whether to use streaming interface + * @returns The validity of the model + */ + public async check(model: Model, stream: boolean): Promise<{ valid: boolean; error: Error | null }> { + if (!model) { + return { valid: false, error: new Error('No model found') } + } + if (stream) { + const response = await this.sdk.responses.create({ + model: model.id, + input: [{ role: 'user', content: 'hi' }], + max_output_tokens: 1, + stream: true + }) + let hasContent = false + for await (const chunk of response) { + if (chunk.type === 'response.output_text.delta') { + hasContent = true + } + } + if (hasContent) { + return { valid: true, error: null } + } + throw new Error('Empty streaming response') + } else { + const response = await this.sdk.responses.create({ + model: model.id, + input: [{ role: 'user', content: 'hi' }], + max_output_tokens: 1, + stream: false + }) + if (!response.output_text) { + throw new Error('Empty response') + } + return { valid: true, error: null } + } + } + + /** + * Get the models + * @returns The models + */ + public async models(): Promise { + try { + const response = await this.sdk.models.list() + const models = response.data || [] + models.forEach((model) => { + model.id = model.id.trim() + }) + return models.filter(isSupportedModel) + } catch (error) { + return [] + } + } + + /** + * Generate an image + * @param params - The parameters + * @returns The generated image + */ + public async generateImage({ + model, + prompt, + negativePrompt, + imageSize, + batchSize, + seed, + numInferenceSteps, + guidanceScale, + signal, + promptEnhancement + }: GenerateImageParams): Promise { + const response = (await this.sdk.request({ + method: 'post', + path: '/images/generations', + signal, + body: { + model, + prompt, + negative_prompt: negativePrompt, + image_size: imageSize, + batch_size: batchSize, + seed: seed ? parseInt(seed) : undefined, + num_inference_steps: numInferenceSteps, + guidance_scale: guidanceScale, + prompt_enhancement: promptEnhancement + } + })) as { data: Array<{ url: string }> } + + return response.data.map((item) => item.url) + } + + public async generateImageByChat({ messages, assistant, onChunk }: CompletionsParams): Promise { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + // save image data from the last assistant message + messages = addImageFileToContents(messages) + const lastUserMessage = messages.findLast((m) => m.role === 'user') + const lastAssistantMessage = messages.findLast((m) => m.role === 'assistant') + if (!lastUserMessage) { + return + } + + const { abortController } = this.createAbortController(lastUserMessage?.id, true) + const { signal } = abortController + const content = getMainTextContent(lastUserMessage!) + let response: OpenAI.Images.ImagesResponse | null = null + let images: FileLike[] = [] + + try { + if (lastUserMessage) { + const UserFiles = findImageBlocks(lastUserMessage) + const validUserFiles = UserFiles.filter((f) => f.file) // Filter out files that are undefined first + const userImages = await Promise.all( + validUserFiles.map(async (f) => { + // f.file is guaranteed to exist here due to the filter above + const fileInfo = f.file! + const binaryData = await FileManager.readBinaryImage(fileInfo) + return await toFile(binaryData, fileInfo.origin_name || 'image.png', { + type: 'image/png' + }) + }) + ) + images = images.concat(userImages) + } + + if (lastAssistantMessage) { + const assistantFiles = findImageBlocks(lastAssistantMessage) + const assistantImages = await Promise.all( + assistantFiles.filter(Boolean).map(async (f) => { + const base64Data = f?.url?.replace(/^data:image\/\w+;base64,/, '') + if (!base64Data) return null + const binary = atob(base64Data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return await toFile(bytes, 'assistant_image.png', { + type: 'image/png' + }) + }) + ) + images = images.concat(assistantImages.filter(Boolean) as FileLike[]) + } + onChunk({ + type: ChunkType.IMAGE_CREATED + }) + + const start_time_millsec = new Date().getTime() + + if (images.length > 0) { + response = await this.sdk.images.edit( + { + model: model.id, + image: images, + prompt: content || '' + }, + { + signal, + timeout: 300_000 + } + ) + } else { + response = await this.sdk.images.generate( + { + model: model.id, + prompt: content || '', + response_format: model.id.includes('gpt-image-1') ? undefined : 'b64_json' + }, + { + signal, + timeout: 300_000 + } + ) + } + + onChunk({ + type: ChunkType.IMAGE_COMPLETE, + image: { + type: 'base64', + images: response?.data?.map((item) => `data:image/png;base64,${item.b64_json}`) || [] + } + }) + + onChunk({ + type: ChunkType.BLOCK_COMPLETE, + response: { + usage: { + completion_tokens: response.usage?.output_tokens || 0, + prompt_tokens: response.usage?.input_tokens || 0, + total_tokens: response.usage?.total_tokens || 0 + }, + metrics: { + completion_tokens: response.usage?.output_tokens || 0, + time_first_token_millsec: 0, // Non-streaming, first token time is not relevant + time_completion_millsec: new Date().getTime() - start_time_millsec + } + } + }) + } catch (error: any) { + console.error('[generateImageByChat] error', error) + onChunk({ + type: ChunkType.ERROR, + error + }) + } + } + + /** + * Get the embedding dimensions + * @param model - The model + * @returns The embedding dimensions + */ + public async getEmbeddingDimensions(model: Model): Promise { + const data = await this.sdk.embeddings.create({ + model: model.id, + input: 'hi' + }) + return data.data[0].embedding.length + } +} + +export default class OpenAIResponseProvider extends BaseOpenAiProvider { + constructor(provider: Provider) { + super(provider) + } + + public convertMcpTools(mcpTools: MCPTool[]) { + return mcpToolsToOpenAIResponseTools(mcpTools) as T[] + } + + public mcpToolCallResponseToMessage = ( + mcpToolResponse: MCPToolResponse, + resp: MCPCallToolResponse, + model: Model + ): OpenAI.Responses.ResponseInputItem | undefined => { + if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) { + return mcpToolCallResponseToOpenAIMessage(mcpToolResponse, resp, isVisionModel(model)) + } else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) { + return { + type: 'function_call_output', + call_id: mcpToolResponse.toolCallId, + output: JSON.stringify(resp.content) + } + } + return + } +} diff --git a/src/renderer/src/providers/AiProvider/ProviderFactory.ts b/src/renderer/src/providers/AiProvider/ProviderFactory.ts index 6d3c10468e..d8c1f40e6f 100644 --- a/src/renderer/src/providers/AiProvider/ProviderFactory.ts +++ b/src/renderer/src/providers/AiProvider/ProviderFactory.ts @@ -4,25 +4,26 @@ import AihubmixProvider from './AihubmixProvider' import AnthropicProvider from './AnthropicProvider' import BaseProvider from './BaseProvider' import GeminiProvider from './GeminiProvider' -import OpenAICompatibleProvider from './OpenAICompatibleProvider' import OpenAIProvider from './OpenAIProvider' +import OpenAIResponseProvider from './OpenAIResponseProvider' export default class ProviderFactory { static create(provider: Provider): BaseProvider { + if (provider.id === 'aihubmix') { + return new AihubmixProvider(provider) + } + switch (provider.type) { case 'openai': return new OpenAIProvider(provider) - case 'openai-compatible': - if (provider.id === 'aihubmix') { - return new AihubmixProvider(provider) - } - return new OpenAICompatibleProvider(provider) + case 'openai-response': + return new OpenAIResponseProvider(provider) case 'anthropic': return new AnthropicProvider(provider) case 'gemini': return new GeminiProvider(provider) default: - return new OpenAICompatibleProvider(provider) + return new OpenAIProvider(provider) } } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index df7af60f69..abfc334986 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -46,7 +46,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 99, + version: 100, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index 27a68b342b..8bb70599b0 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -28,7 +28,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'silicon', name: 'Silicon', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.siliconflow.cn', models: SYSTEM_MODELS.silicon, @@ -38,7 +38,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'aihubmix', name: 'AiHubMix', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://aihubmix.com', models: SYSTEM_MODELS.aihubmix, @@ -48,7 +48,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'ocoolai', name: 'ocoolAI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.ocoolai.com', models: SYSTEM_MODELS.ocoolai, @@ -58,7 +58,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'deepseek', name: 'deepseek', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.deepseek.com', models: SYSTEM_MODELS.deepseek, @@ -68,7 +68,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'openrouter', name: 'OpenRouter', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://openrouter.ai/api/v1/', models: SYSTEM_MODELS.openrouter, @@ -78,7 +78,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'ppio', name: 'PPIO', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.ppinfra.com/v3/openai', models: SYSTEM_MODELS.ppio, @@ -88,7 +88,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'alayanew', name: 'AlayaNew', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://deepseek.alayanew.com', models: SYSTEM_MODELS.alayanew, @@ -98,7 +98,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'infini', name: 'Infini', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://cloud.infini-ai.com/maas', models: SYSTEM_MODELS.infini, @@ -108,7 +108,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'qiniu', name: 'Qiniu', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.qnaigc.com', models: SYSTEM_MODELS.qiniu, @@ -118,7 +118,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'dmxapi', name: 'DMXAPI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://www.dmxapi.cn', models: SYSTEM_MODELS.dmxapi, @@ -128,7 +128,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'o3', name: 'O3', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.o3.fan', models: SYSTEM_MODELS.o3, @@ -138,7 +138,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'ollama', name: 'Ollama', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'http://localhost:11434', models: SYSTEM_MODELS.ollama, @@ -148,7 +148,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'lmstudio', name: 'LM Studio', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'http://localhost:1234', models: SYSTEM_MODELS.lmstudio, @@ -168,7 +168,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'openai', name: 'OpenAI', - type: 'openai', + type: 'openai-response', apiKey: '', apiHost: 'https://api.openai.com', models: SYSTEM_MODELS.openai, @@ -178,7 +178,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'azure-openai', name: 'Azure OpenAI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: '', apiVersion: '', @@ -199,7 +199,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'zhipu', name: 'ZhiPu', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://open.bigmodel.cn/api/paas/v4/', models: SYSTEM_MODELS.zhipu, @@ -209,7 +209,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'github', name: 'Github Models', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://models.inference.ai.azure.com/', models: SYSTEM_MODELS.github, @@ -219,7 +219,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'copilot', name: 'Github Copilot', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.githubcopilot.com/', models: SYSTEM_MODELS.copilot, @@ -230,7 +230,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'yi', name: 'Yi', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.lingyiwanwu.com', models: SYSTEM_MODELS.yi, @@ -240,7 +240,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'moonshot', name: 'Moonshot AI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.moonshot.cn', models: SYSTEM_MODELS.moonshot, @@ -250,7 +250,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'baichuan', name: 'BAICHUAN AI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.baichuan-ai.com', models: SYSTEM_MODELS.baichuan, @@ -260,7 +260,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'dashscope', name: 'Bailian', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://dashscope.aliyuncs.com/compatible-mode/v1/', models: SYSTEM_MODELS.bailian, @@ -270,7 +270,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'stepfun', name: 'StepFun', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.stepfun.com', models: SYSTEM_MODELS.stepfun, @@ -280,7 +280,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'doubao', name: 'doubao', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://ark.cn-beijing.volces.com/api/v3/', models: SYSTEM_MODELS.doubao, @@ -290,7 +290,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'minimax', name: 'MiniMax', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.minimax.chat/v1/', models: SYSTEM_MODELS.minimax, @@ -300,7 +300,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'groq', name: 'Groq', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.groq.com/openai', models: SYSTEM_MODELS.groq, @@ -310,7 +310,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'together', name: 'Together', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.together.xyz', models: SYSTEM_MODELS.together, @@ -320,7 +320,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'fireworks', name: 'Fireworks', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.fireworks.ai/inference', models: SYSTEM_MODELS.fireworks, @@ -330,7 +330,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'zhinao', name: 'zhinao', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.360.cn', models: SYSTEM_MODELS.zhinao, @@ -340,7 +340,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'hunyuan', name: 'hunyuan', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.hunyuan.cloud.tencent.com', models: SYSTEM_MODELS.hunyuan, @@ -350,7 +350,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'nvidia', name: 'nvidia', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://integrate.api.nvidia.com', models: SYSTEM_MODELS.nvidia, @@ -360,7 +360,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'grok', name: 'Grok', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.x.ai', models: SYSTEM_MODELS.grok, @@ -370,7 +370,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'hyperbolic', name: 'Hyperbolic', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.hyperbolic.xyz', models: SYSTEM_MODELS.hyperbolic, @@ -380,7 +380,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'mistral', name: 'Mistral', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.mistral.ai', models: SYSTEM_MODELS.mistral, @@ -390,7 +390,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'jina', name: 'Jina', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.jina.ai', models: SYSTEM_MODELS.jina, @@ -400,7 +400,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'gitee-ai', name: 'gitee ai', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://ai.gitee.com', models: SYSTEM_MODELS['gitee-ai'], @@ -410,7 +410,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'perplexity', name: 'Perplexity', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.perplexity.ai/', models: SYSTEM_MODELS.perplexity, @@ -420,7 +420,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'modelscope', name: 'ModelScope', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api-inference.modelscope.cn/v1/', models: SYSTEM_MODELS.modelscope, @@ -430,7 +430,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'xirang', name: 'Xirang', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://wishub-x1.ctyun.cn', models: SYSTEM_MODELS.xirang, @@ -440,7 +440,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'tencent-cloud-ti', name: 'Tencent Cloud TI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.lkeap.cloud.tencent.com', models: SYSTEM_MODELS['tencent-cloud-ti'], @@ -450,7 +450,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'baidu-cloud', name: 'Baidu Cloud', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://qianfan.baidubce.com/v2/', models: SYSTEM_MODELS['baidu-cloud'], @@ -460,7 +460,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'gpustack', name: 'GPUStack', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: '', models: SYSTEM_MODELS.gpustack, @@ -470,7 +470,7 @@ export const INITIAL_PROVIDERS: Provider[] = [ { id: 'voyageai', name: 'VoyageAI', - type: 'openai-compatible', + type: 'openai', apiKey: '', apiHost: 'https://api.voyageai.com', models: SYSTEM_MODELS.voyageai, diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index 33c00200da..2a4ac9845c 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -101,7 +101,7 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita })) || [] break } - case WebSearchSource.OPENAI: + case WebSearchSource.OPENAI_RESPONSE: formattedCitations = (block.response.results as OpenAI.Responses.ResponseOutputText.URLCitation[])?.map((result, index) => { let hostname: string | undefined @@ -120,7 +120,7 @@ const formatCitationsFromBlock = (block: CitationMessageBlock | undefined): Cita } }) || [] break - case WebSearchSource.OPENAI_COMPATIBLE: + case WebSearchSource.OPENAI: formattedCitations = (block.response.results as OpenAI.Chat.Completions.ChatCompletionMessage.Annotation[])?.map((url, index) => { const urlCitation = url.url_citation diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index ab852b18f6..e88cac33ef 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1257,6 +1257,7 @@ const migrateConfig = { try { state.llm.providers.forEach((provider) => { if (provider.type === 'openai' && provider.id !== 'openai') { + // @ts-ignore eslint-disable-next-line provider.type = 'openai-compatible' } }) @@ -1296,6 +1297,22 @@ const migrateConfig = { } catch (error) { return state } + }, + '100': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + // @ts-ignore eslint-disable-next-line + if (['openai-compatible', 'openai'].includes(provider.type)) { + provider.type = 'openai' + } + if (provider.id === 'openai') { + provider.type = 'openai-response' + } + }) + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 0873fc0a99..e66e629043 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -162,7 +162,7 @@ export type Provider = { notes?: string } -export type ProviderType = 'openai' | 'openai-compatible' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai' +export type ProviderType = 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'qwenlm' | 'azure-openai' export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search' @@ -462,7 +462,7 @@ export type WebSearchResults = export enum WebSearchSource { WEBSEARCH = 'websearch', OPENAI = 'openai', - OPENAI_COMPATIBLE = 'openai-compatible', + OPENAI_RESPONSE = 'openai-response', OPENROUTER = 'openrouter', ANTHROPIC = 'anthropic', GEMINI = 'gemini',