From c6a8160c52b7f0e7227dff2534717c7848ece92c Mon Sep 17 00:00:00 2001 From: akumatus Date: Thu, 24 Apr 2025 12:23:05 +0000 Subject: [PATCH] feat(core): add anthropic provider (#11911) Close [AI-40](https://linear.app/affine-design/issue/AI-40). ### What changed? - Add anthropic provider. ## Summary by CodeRabbit - **New Features** - Added support for Anthropic as a new provider option in Copilot, enabling integration with Anthropic's text generation models. - Users can now configure Anthropic provider settings, including API key management, through the admin interface. - **Chores** - Updated dependencies to include the Anthropic SDK for backend operations. --- packages/backend/server/package.json | 1 + .../server/src/plugins/copilot/config.ts | 8 + .../plugins/copilot/providers/anthropic.ts | 170 ++++++++++++++++++ .../src/plugins/copilot/providers/factory.ts | 2 + .../src/plugins/copilot/providers/index.ts | 3 + .../src/plugins/copilot/providers/types.ts | 1 + packages/frontend/admin/src/config.json | 4 + yarn.lock | 13 ++ 8 files changed, 202 insertions(+) create mode 100644 packages/backend/server/src/plugins/copilot/providers/anthropic.ts diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 819b5848a0..c582c7af57 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@affine/server-native": "workspace:*", + "@ai-sdk/anthropic": "^1.2.10", "@ai-sdk/google": "^1.2.10", "@ai-sdk/openai": "^1.3.18", "@ai-sdk/perplexity": "^1.1.6", diff --git a/packages/backend/server/src/plugins/copilot/config.ts b/packages/backend/server/src/plugins/copilot/config.ts index fa3b7dccf1..023a35949f 100644 --- a/packages/backend/server/src/plugins/copilot/config.ts +++ b/packages/backend/server/src/plugins/copilot/config.ts @@ -3,6 +3,7 @@ import { StorageJSONSchema, StorageProviderConfig, } from '../../base'; +import { AnthropicConfig } from './providers/anthropic'; import type { FalConfig } from './providers/fal'; import { GeminiConfig } from './providers/gemini'; import { OpenAIConfig } from './providers/openai'; @@ -21,6 +22,7 @@ declare global { fal: ConfigItem; gemini: ConfigItem; perplexity: ConfigItem; + anthropic: ConfigItem; }; }; } @@ -56,6 +58,12 @@ defineModuleConfig('copilot', { apiKey: '', }, }, + 'providers.anthropic': { + desc: 'The config for the anthropic provider.', + default: { + apiKey: '', + }, + }, unsplash: { desc: 'The config for the unsplash key.', default: { diff --git a/packages/backend/server/src/plugins/copilot/providers/anthropic.ts b/packages/backend/server/src/plugins/copilot/providers/anthropic.ts new file mode 100644 index 0000000000..e49c1c2009 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/providers/anthropic.ts @@ -0,0 +1,170 @@ +import { + AnthropicProvider as AnthropicSDKProvider, + createAnthropic, +} from '@ai-sdk/anthropic'; +import { AISDKError, generateText, streamText } from 'ai'; + +import { + CopilotPromptInvalid, + CopilotProviderSideError, + metrics, + UserFriendlyError, +} from '../../../base'; +import { CopilotProvider } from './provider'; +import { + ChatMessageRole, + CopilotCapability, + CopilotChatOptions, + CopilotProviderType, + CopilotTextToTextProvider, + PromptMessage, +} from './types'; +import { chatToGPTMessage } from './utils'; + +export type AnthropicConfig = { + apiKey: string; + baseUrl?: string; +}; + +export class AnthropicProvider + extends CopilotProvider + implements CopilotTextToTextProvider +{ + override readonly type = CopilotProviderType.Anthropic; + override readonly capabilities = [CopilotCapability.TextToText]; + override readonly models = ['claude-3-7-sonnet-20250219']; + + #instance!: AnthropicSDKProvider; + + override configured(): boolean { + return !!this.config.apiKey; + } + + protected override setup() { + super.setup(); + this.#instance = createAnthropic({ + apiKey: this.config.apiKey, + baseURL: this.config.baseUrl, + }); + } + + protected async checkParams({ + messages, + model, + }: { + messages?: PromptMessage[]; + model: string; + }) { + if (!(await this.isModelAvailable(model))) { + throw new CopilotPromptInvalid(`Invalid model: ${model}`); + } + if (Array.isArray(messages) && messages.length > 0) { + if ( + messages.some( + m => + // check non-object + typeof m !== 'object' || + !m || + // check content + typeof m.content !== 'string' || + // content and attachments must exist at least one + ((!m.content || !m.content.trim()) && + (!Array.isArray(m.attachments) || !m.attachments.length)) + ) + ) { + throw new CopilotPromptInvalid('Empty message content'); + } + if ( + messages.some( + m => + typeof m.role !== 'string' || + !m.role || + !ChatMessageRole.includes(m.role) + ) + ) { + throw new CopilotPromptInvalid('Invalid message role'); + } + } + } + + private handleError(e: any) { + if (e instanceof UserFriendlyError) { + return e; + } else if (e instanceof AISDKError) { + this.logger.error('Throw error from ai sdk:', e); + return new CopilotProviderSideError({ + provider: this.type, + kind: e.name || 'unknown', + message: e.message, + }); + } else { + return new CopilotProviderSideError({ + provider: this.type, + kind: 'unexpected_response', + message: e?.message || 'Unexpected anthropic response', + }); + } + } + + // ====== text to text ====== + async generateText( + messages: PromptMessage[], + model: string = 'claude-3-7-sonnet-20250219', + options: CopilotChatOptions = {} + ): Promise { + await this.checkParams({ messages, model }); + + try { + metrics.ai.counter('chat_text_calls').add(1, { model }); + + const [system, msgs] = await chatToGPTMessage(messages); + + const modelInstance = this.#instance(model); + const { text } = await generateText({ + model: modelInstance, + system, + messages: msgs, + abortSignal: options.signal, + }); + + if (!text) throw new Error('Failed to generate text'); + return text.trim(); + } catch (e: any) { + metrics.ai.counter('chat_text_errors').add(1, { model }); + throw this.handleError(e); + } + } + + async *generateTextStream( + messages: PromptMessage[], + model: string = 'claude-3-7-sonnet-20250219', + options: CopilotChatOptions = {} + ): AsyncIterable { + await this.checkParams({ messages, model }); + + try { + metrics.ai.counter('chat_text_stream_calls').add(1, { model }); + const [system, msgs] = await chatToGPTMessage(messages); + + const { textStream } = streamText({ + model: this.#instance(model), + system, + messages: msgs, + abortSignal: options.signal, + }); + + for await (const message of textStream) { + if (message) { + yield message; + if (options.signal?.aborted) { + await textStream.cancel(); + break; + } + } + } + } catch (e: any) { + metrics.ai.counter('chat_text_stream_errors').add(1, { model }); + throw this.handleError(e); + } + } +} diff --git a/packages/backend/server/src/plugins/copilot/providers/factory.ts b/packages/backend/server/src/plugins/copilot/providers/factory.ts index 7a938fc27a..6b9709a74d 100644 --- a/packages/backend/server/src/plugins/copilot/providers/factory.ts +++ b/packages/backend/server/src/plugins/copilot/providers/factory.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ServerFeature, ServerService } from '../../../core'; +import type { AnthropicProvider } from './anthropic'; import type { FalProvider } from './fal'; import type { GeminiProvider } from './gemini'; import type { OpenAIProvider } from './openai'; @@ -13,6 +14,7 @@ import { } from './types'; type TypedProvider = { + [CopilotProviderType.Anthropic]: AnthropicProvider; [CopilotProviderType.Gemini]: GeminiProvider; [CopilotProviderType.OpenAI]: OpenAIProvider; [CopilotProviderType.Perplexity]: PerplexityProvider; diff --git a/packages/backend/server/src/plugins/copilot/providers/index.ts b/packages/backend/server/src/plugins/copilot/providers/index.ts index 0168e22503..39a1054f63 100644 --- a/packages/backend/server/src/plugins/copilot/providers/index.ts +++ b/packages/backend/server/src/plugins/copilot/providers/index.ts @@ -1,3 +1,4 @@ +import { AnthropicProvider } from './anthropic'; import { FalProvider } from './fal'; import { GeminiProvider } from './gemini'; import { OpenAIProvider } from './openai'; @@ -8,8 +9,10 @@ export const CopilotProviders = [ FalProvider, GeminiProvider, PerplexityProvider, + AnthropicProvider, ]; +export { AnthropicProvider } from './anthropic'; export { CopilotProviderFactory } from './factory'; export { FalProvider } from './fal'; export { GeminiProvider } from './gemini'; diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index 5b21602fc3..37a4d83a83 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { type CopilotProvider } from './provider'; export enum CopilotProviderType { + Anthropic = 'anthropic', FAL = 'fal', Gemini = 'gemini', OpenAI = 'openai', diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index cae75fa051..b2a658c9a4 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -233,6 +233,10 @@ "type": "Object", "desc": "The config for the perplexity provider." }, + "providers.anthropic": { + "type": "Object", + "desc": "The config for the anthropic provider." + }, "unsplash": { "type": "Object", "desc": "The config for the unsplash key." diff --git a/yarn.lock b/yarn.lock index b449b7d2e9..f7c865f5aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -889,6 +889,7 @@ __metadata: "@affine-tools/utils": "workspace:*" "@affine/graphql": "workspace:*" "@affine/server-native": "workspace:*" + "@ai-sdk/anthropic": "npm:^1.2.10" "@ai-sdk/google": "npm:^1.2.10" "@ai-sdk/openai": "npm:^1.3.18" "@ai-sdk/perplexity": "npm:^1.1.6" @@ -1052,6 +1053,18 @@ __metadata: languageName: unknown linkType: soft +"@ai-sdk/anthropic@npm:^1.2.10": + version: 1.2.10 + resolution: "@ai-sdk/anthropic@npm:1.2.10" + dependencies: + "@ai-sdk/provider": "npm:1.1.3" + "@ai-sdk/provider-utils": "npm:2.2.7" + peerDependencies: + zod: ^3.0.0 + checksum: 10/4701f83886592e635fab36d8026e7b764f444f40a2c636340fcf267d8d1c846c7ee710b548a782cec6ecafac1d967be74d4e87d6f6f780b0725a1a580cab77d3 + languageName: node + linkType: hard + "@ai-sdk/google@npm:^1.2.10": version: 1.2.11 resolution: "@ai-sdk/google@npm:1.2.11"