feat(core): add anthropic provider (#11911)

Close [AI-40](https://linear.app/affine-design/issue/AI-40).

### What changed?
- Add anthropic provider.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
akumatus
2025-04-24 12:23:05 +00:00
parent f4ffdb9995
commit c6a8160c52
8 changed files with 202 additions and 0 deletions

View File

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

View File

@@ -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<FalConfig>;
gemini: ConfigItem<GeminiConfig>;
perplexity: ConfigItem<PerplexityConfig>;
anthropic: ConfigItem<AnthropicConfig>;
};
};
}
@@ -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: {

View File

@@ -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<AnthropicConfig>
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<string> {
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<string> {
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);
}
}
}

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { type CopilotProvider } from './provider';
export enum CopilotProviderType {
Anthropic = 'anthropic',
FAL = 'fal',
Gemini = 'gemini',
OpenAI = 'openai',