From 31284a6e23176ee3b8b15db728b0383e9b5fd639 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 19 Jul 2024 15:34:34 +0800 Subject: [PATCH] feat: add anthropic provider --- package.json | 1 + .../src/assets/images/models/claude.png | Bin 0 -> 10403 bytes .../assets/images/providers/anthropic.jpeg | Bin 0 -> 6978 bytes src/renderer/src/config/models.ts | 30 ++++ src/renderer/src/config/provider.ts | 15 +- src/renderer/src/i18n/index.ts | 6 +- src/renderer/src/services/ProviderSDK.ts | 142 ++++++++++++++++++ src/renderer/src/services/api.ts | 132 +++++----------- src/renderer/src/store/llm.ts | 9 ++ src/renderer/src/store/migrate.ts | 9 ++ src/renderer/src/utils/index.ts | 24 +++ yarn.lock | 17 +++ 12 files changed, 284 insertions(+), 101 deletions(-) create mode 100644 src/renderer/src/assets/images/models/claude.png create mode 100644 src/renderer/src/assets/images/providers/anthropic.jpeg create mode 100644 src/renderer/src/services/ProviderSDK.ts diff --git a/package.json b/package.json index 8d7baa10..09b10084 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "electron-window-state": "^5.0.3" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.24.3", "@electron-toolkit/eslint-config-prettier": "^2.0.0", "@electron-toolkit/eslint-config-ts": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1", diff --git a/src/renderer/src/assets/images/models/claude.png b/src/renderer/src/assets/images/models/claude.png new file mode 100644 index 0000000000000000000000000000000000000000..e62132559b037852ff83695671b558f6934e7d54 GIT binary patch literal 10403 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 literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/images/providers/anthropic.jpeg b/src/renderer/src/assets/images/providers/anthropic.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..6cb2e6ab4076e6ed4f831ba3dbde6210d7584255 GIT binary patch literal 6978 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 = { group: 'Gemma', enabled: false } + ], + anthropic: [ + { + id: 'claude-3-5-sonnet-20240620', + provider: 'anthropic', + name: 'Claude 3.5 Sonnet', + group: 'Claude 3.5', + enabled: true + }, + { + id: 'claude-3-opus-20240229', + provider: 'anthropic', + name: 'Claude 3 Opus', + group: 'Claude 3', + enabled: true + }, + { + id: 'claude-3-sonnet-20240229', + provider: 'anthropic', + name: 'Claude 3 Sonnet', + group: 'Claude 3', + enabled: true + }, + { + id: 'claude-3-haiku-20240307', + provider: 'anthropic', + name: 'Claude 3 Haiku', + group: 'Claude 3', + enabled: true + } ] } diff --git a/src/renderer/src/config/provider.ts b/src/renderer/src/config/provider.ts index c271ddcd..8d2bdf28 100644 --- a/src/renderer/src/config/provider.ts +++ b/src/renderer/src/config/provider.ts @@ -9,6 +9,7 @@ import MoonshotProviderLogo from '@renderer/assets/images/providers/moonshot.jpe import OpenRouterProviderLogo from '@renderer/assets/images/providers/openrouter.png' import BaichuanProviderLogo from '@renderer/assets/images/providers/baichuan.png' import DashScopeProviderLogo from '@renderer/assets/images/providers/dashscope.png' +import AnthropicProviderLogo from '@renderer/assets/images/providers/anthropic.jpeg' import ChatGPTModelLogo from '@renderer/assets/images/models/chatgpt.jpeg' import ChatGLMModelLogo from '@renderer/assets/images/models/chatglm.jpeg' import DeepSeekModelLogo from '@renderer/assets/images/models/deepseek.png' @@ -20,6 +21,7 @@ import MixtralModelLogo from '@renderer/assets/images/models/mixtral.jpeg' import MoonshotModelLogo from '@renderer/assets/images/providers/moonshot.jpeg' import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png' import BaichuanModelLogo from '@renderer/assets/images/models/baichuan.png' +import ClaudeModelLogo from '@renderer/assets/images/models/claude.png' export function getProviderLogo(providerId: string) { switch (providerId) { @@ -45,6 +47,8 @@ export function getProviderLogo(providerId: string) { return BaichuanProviderLogo case 'dashscope': return DashScopeProviderLogo + case 'anthropic': + return AnthropicProviderLogo default: return undefined } @@ -63,7 +67,8 @@ export function getModelLogo(modelId: string) { mistral: MixtralModelLogo, moonshot: MoonshotModelLogo, phi: MicrosoftModelLogo, - baichuan: BaichuanModelLogo + baichuan: BaichuanModelLogo, + claude: ClaudeModelLogo } for (const key in logoMap) { @@ -162,5 +167,13 @@ export const PROVIDER_CONFIG = { docs: 'https://github.com/ollama/ollama/tree/main/docs', models: 'https://ollama.com/library' } + }, + anthropic: { + websites: { + official: 'https://anthropic.com/', + apiKey: 'https://console.anthropic.com/settings/keys', + docs: 'https://docs.anthropic.com/en/docs', + models: 'https://docs.anthropic.com/en/docs/about-claude/models' + } } } diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index a51118d0..ba54df38 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -80,7 +80,8 @@ const resources = { groq: 'Groq', ollama: 'Ollama', baichuan: 'Baichuan', - dashscope: 'DashScope' + dashscope: 'DashScope', + anthropic: 'Anthropic' }, settings: { title: 'Settings', @@ -197,7 +198,8 @@ const resources = { groq: 'Groq', ollama: 'Ollama', baichuan: '百川', - dashscope: '阿里云灵积' + dashscope: '阿里云灵积', + anthropic: 'Anthropic' }, settings: { title: '设置', diff --git a/src/renderer/src/services/ProviderSDK.ts b/src/renderer/src/services/ProviderSDK.ts new file mode 100644 index 00000000..2f15cdc2 --- /dev/null +++ b/src/renderer/src/services/ProviderSDK.ts @@ -0,0 +1,142 @@ +import { Assistant, Message, Provider } from '@renderer/types' +import OpenAI from 'openai' +import Anthropic from '@anthropic-ai/sdk' +import { getDefaultModel, getTopNamingModel } from './assistant' +import { + ChatCompletionCreateParamsNonStreaming, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam +} from 'openai/resources' +import { sum, takeRight } from 'lodash' +import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources' +import { EVENT_NAMES } from './event' +import { removeQuotes } from '@renderer/utils' + +export default class ProviderSDK { + provider: Provider + openaiSdk: OpenAI + anthropicSdk: Anthropic + + constructor(provider: Provider) { + this.provider = provider + const host = provider.apiHost + const baseURL = host.endsWith('/') ? host : `${provider.apiHost}/v1/` + this.anthropicSdk = new Anthropic({ apiKey: provider.apiKey, baseURL }) + this.openaiSdk = new OpenAI({ dangerouslyAllowBrowser: true, apiKey: provider.apiKey, baseURL }) + } + + private get isAnthropic() { + return this.provider.id === 'anthropic' + } + + public async completions( + messages: Message[], + assistant: Assistant, + onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void + ) { + const defaultModel = getDefaultModel() + const model = assistant.model || defaultModel + + const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined + + const userMessages = takeRight(messages, 5).map((message) => ({ + role: message.role, + content: message.content + })) + + if (this.isAnthropic) { + await this.anthropicSdk.messages + .stream({ + max_tokens: 1024, + messages: [systemMessage, ...userMessages].filter(Boolean) as MessageParam[], + model: model.id + }) + .on('text', (text) => onChunk({ text: text || '' })) + .on('finalMessage', (message) => + onChunk({ + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: sum(Object.values(message.usage)) } + }) + ) + } else { + const stream = await this.openaiSdk.chat.completions.create({ + model: model.id, + messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[], + stream: true + }) + for await (const chunk of stream) { + if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { + break + } + onChunk({ text: chunk.choices[0]?.delta?.content || '', usage: chunk.usage }) + } + } + } + + public async summaries(messages: Message[], assistant: Assistant): Promise { + const model = getTopNamingModel() || assistant.model || getDefaultModel() + + const userMessages: ChatCompletionMessageParam[] = takeRight(messages, 5).map((message) => ({ + role: 'user', + content: message.content + })) + + const systemMessage: ChatCompletionSystemMessageParam = { + role: 'system', + content: '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要加标点符号' + } + + if (this.isAnthropic) { + const message = await this.anthropicSdk.messages.create({ + messages: [systemMessage, ...userMessages] as Anthropic.Messages.MessageParam[], + model: model.id, + stream: false, + max_tokens: 50 + }) + + return message.content[0].type === 'text' ? message.content[0].text : null + } else { + const response = await this.openaiSdk.chat.completions.create({ + model: model.id, + messages: [systemMessage, ...userMessages], + stream: false + }) + + return removeQuotes(response.choices[0].message?.content || '') + } + } + + public async check(): Promise<{ valid: boolean; error: Error | null }> { + const model = this.provider.models[0] + const body = { + model: model.id, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 100, + stream: false + } + + try { + if (this.isAnthropic) { + const message = await this.anthropicSdk.messages.create(body as MessageCreateParamsNonStreaming) + return { valid: message.content.length > 0, error: null } + } else { + const response = await this.openaiSdk.chat.completions.create(body as ChatCompletionCreateParamsNonStreaming) + return { valid: Boolean(response?.choices[0].message), error: null } + } + } catch (error: any) { + return { valid: false, error } + } + } + + public async models(): Promise { + try { + if (this.isAnthropic) { + return [] + } + + const response = await this.openaiSdk.models.list() + return response.data + } catch (error) { + return [] + } + } +} diff --git a/src/renderer/src/services/api.ts b/src/renderer/src/services/api.ts index e91f68f2..5539b5a5 100644 --- a/src/renderer/src/services/api.ts +++ b/src/renderer/src/services/api.ts @@ -1,38 +1,30 @@ -import { Assistant, Message, Provider, Topic } from '@renderer/types' -import { uuid } from '@renderer/utils' -import { EVENT_NAMES, EventEmitter } from './event' -import { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources' -import OpenAI from 'openai' -import { getAssistantProvider, getDefaultModel, getProviderByModel, getTopNamingModel } from './assistant' -import { takeRight } from 'lodash' -import dayjs from 'dayjs' +import i18n from '@renderer/i18n' import store from '@renderer/store' import { setGenerating } from '@renderer/store/runtime' -import i18n from '@renderer/i18n' +import { Assistant, Message, Provider, Topic } from '@renderer/types' +import { getErrorMessage, uuid } from '@renderer/utils' +import dayjs from 'dayjs' +import { getAssistantProvider, getDefaultModel, getProviderByModel, getTopNamingModel } from './assistant' +import { EVENT_NAMES, EventEmitter } from './event' +import ProviderSDK from './ProviderSDK' -interface FetchChatCompletionParams { +export async function fetchChatCompletion({ + messages, + topic, + assistant, + onResponse +}: { messages: Message[] topic: Topic assistant: Assistant onResponse: (message: Message) => void -} - -const getOpenAiProvider = (provider: Provider) => { - const host = provider.apiHost - return new OpenAI({ - dangerouslyAllowBrowser: true, - apiKey: provider.apiKey, - baseURL: host.endsWith('/') ? host : `${provider.apiHost}/v1/` - }) -} - -export async function fetchChatCompletion({ messages, topic, assistant, onResponse }: FetchChatCompletionParams) { +}) { window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, false) const provider = getAssistantProvider(assistant) - const openaiProvider = getOpenAiProvider(provider) const defaultModel = getDefaultModel() const model = assistant.model || defaultModel + const providerSdk = new ProviderSDK(provider) store.dispatch(setGenerating(true)) @@ -49,79 +41,36 @@ export async function fetchChatCompletion({ messages, topic, assistant, onRespon onResponse({ ...message }) - const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined - - const userMessages = takeRight(messages, 5).map((message) => ({ - role: message.role, - content: message.content - })) - try { - const stream = await openaiProvider.chat.completions.create({ - model: model.id, - messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[], - stream: true + await providerSdk.completions(messages, assistant, ({ text, usage }) => { + message.content = message.content + text || '' + message.usage = usage + onResponse({ ...message, status: 'pending' }) }) - - let content = '' - let usage: OpenAI.Completions.CompletionUsage | undefined = undefined - - for await (const chunk of stream) { - if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) { - break - } - - content = content + (chunk.choices[0]?.delta?.content || '') - chunk.usage && (usage = chunk.usage) - onResponse({ ...message, content, status: 'pending' }) - } - - message.content = content - message.usage = usage } catch (error: any) { message.content = `Error: ${error.message}` } - const paused = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) - message.status = paused ? 'paused' : 'success' + // Update message status + message.status = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) ? 'paused' : 'success' + + // Emit chat completion event EventEmitter.emit(EVENT_NAMES.AI_CHAT_COMPLETION, message) + + // Reset generating state store.dispatch(setGenerating(false)) return message } -interface FetchMessagesSummaryParams { - messages: Message[] - assistant: Assistant -} - -export async function fetchMessagesSummary({ messages, assistant }: FetchMessagesSummaryParams) { +export async function fetchMessagesSummary({ messages, assistant }: { messages: Message[]; assistant: Assistant }) { const model = getTopNamingModel() || assistant.model || getDefaultModel() const provider = getProviderByModel(model) - const openaiProvider = getOpenAiProvider(provider) - - const userMessages: ChatCompletionMessageParam[] = takeRight(messages, 5).map((message) => ({ - role: 'user', - content: message.content - })) - - const systemMessage: ChatCompletionSystemMessageParam = { - role: 'system', - content: - '你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,回复内容不需要用引号引起来,不需要在结尾加上句号。' - } - - const response = await openaiProvider.chat.completions.create({ - model: model.id, - messages: [systemMessage, ...userMessages], - stream: false - }) - - return response.choices[0].message?.content + const providerSdk = new ProviderSDK(provider) + return providerSdk.summaries(messages, assistant) } export async function checkApi(provider: Provider) { - const openaiProvider = getOpenAiProvider(provider) const model = provider.models[0] const key = 'api-check' const style = { marginTop: '3vh' } @@ -141,22 +90,9 @@ export async function checkApi(provider: Provider) { return false } - let valid = false - let errorMessage = '' + const providerSdk = new ProviderSDK(provider) - try { - const response = await openaiProvider.chat.completions.create({ - model: model.id, - messages: [{ role: 'user', content: 'hi' }], - max_tokens: 100, - stream: false - }) - - valid = Boolean(response?.choices[0].message) - } catch (error) { - errorMessage = (error as Error).message - valid = false - } + const { valid, error } = await providerSdk.check() window.message[valid ? 'success' : 'error']({ key: 'api-check', @@ -164,17 +100,17 @@ export async function checkApi(provider: Provider) { duration: valid ? 2 : 8, content: valid ? i18n.t('message.api.connection.success') - : i18n.t('message.api.connection.failed') + ' ' + errorMessage + : i18n.t('message.api.connection.failed') + ' : ' + getErrorMessage(error) }) return valid } export async function fetchModels(provider: Provider) { + const providerSdk = new ProviderSDK(provider) + try { - const openaiProvider = getOpenAiProvider(provider) - const response = await openaiProvider.models.list() - return response.data + return await providerSdk.models() } catch (error) { return [] } diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index fbe5fe7f..e248b5f9 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -85,6 +85,15 @@ const initialState: LlmState = { isSystem: true, enabled: false }, + { + id: 'anthropic', + name: 'Anthropic', + apiKey: '', + apiHost: 'https://api.anthropic.com/', + models: SYSTEM_MODELS.anthropic.filter((m) => m.enabled), + isSystem: true, + enabled: false + }, { id: 'openrouter', name: 'OpenRouter', diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 1a177601..f2aed1e3 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -194,6 +194,15 @@ const migrate = createMigrate({ models: SYSTEM_MODELS.dashscope.filter((m) => m.enabled), isSystem: true, enabled: false + }, + { + id: 'anthropic', + name: 'Anthropic', + apiKey: '', + apiHost: 'https://api.anthropic.com/', + models: SYSTEM_MODELS.anthropic.filter((m) => m.enabled), + isSystem: true, + enabled: false } ] } diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index f3da50db..02c9d90d 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -108,3 +108,27 @@ export async function isDev() { const isProd = await isProduction() return !isProd } + +export function getErrorMessage(error: any) { + if (!error) { + return '' + } + + if (typeof error === 'string') { + return error + } + + if (error?.error) { + return getErrorMessage(error.error) + } + + if (error?.message) { + return error.message + } + + return '' +} + +export function removeQuotes(str) { + return str.replace(/['"]+/g, '') +} diff --git a/yarn.lock b/yarn.lock index cb08ff89..45c6df60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -87,6 +87,22 @@ __metadata: languageName: node linkType: hard +"@anthropic-ai/sdk@npm:^0.24.3": + version: 0.24.3 + resolution: "@anthropic-ai/sdk@npm:0.24.3" + dependencies: + "@types/node": "npm:^18.11.18" + "@types/node-fetch": "npm:^2.6.4" + abort-controller: "npm:^3.0.0" + agentkeepalive: "npm:^4.2.1" + form-data-encoder: "npm:1.7.2" + formdata-node: "npm:^4.3.2" + node-fetch: "npm:^2.6.7" + web-streams-polyfill: "npm:^3.2.1" + checksum: 10c0/1c73c3df9637522da548d2cddfaf89513dac935c5cdb7c0b3db1c427c069a0de76df935bd189e477822063e9f944360e2d059827d5be4dca33bd388c61e97a30 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.23.5, @babel/code-frame@npm:^7.24.2": version: 7.24.2 resolution: "@babel/code-frame@npm:7.24.2" @@ -3391,6 +3407,7 @@ __metadata: version: 0.0.0-use.local resolution: "cherry-studio@workspace:." dependencies: + "@anthropic-ai/sdk": "npm:^0.24.3" "@electron-toolkit/eslint-config-prettier": "npm:^2.0.0" "@electron-toolkit/eslint-config-ts": "npm:^1.0.1" "@electron-toolkit/preload": "npm:^3.0.0"