Skip to content

Commit

Permalink
feat: add anthropic plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
rxliuli committed Oct 9, 2024
1 parent c168bd7 commit e824c85
Show file tree
Hide file tree
Showing 12 changed files with 250 additions and 34 deletions.
1 change: 1 addition & 0 deletions packages/plugin-anthropic/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
publish
3 changes: 3 additions & 0 deletions packages/plugin-anthropic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @novachat/plugin-anthropic

NovaChat Anthropic Plugin.
30 changes: 30 additions & 0 deletions packages/plugin-anthropic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@novachat/plugin-anthropic",
"keywords": [
"novachat",
"novachat-plugin"
],
"version": "0.1.0",
"license": "MIT",
"type": "module",
"files": [
"publish"
],
"scripts": {
"setup": "pnpm build",
"build": "novachat",
"dev": "pnpm build --watch"
},
"sideEffects": false,
"devDependencies": {
"@anthropic-ai/sdk": "^0.28.0",
"@novachat/cli": "workspace:*",
"typescript": "^5.3.3",
"vitest": "^1.2.2",
"@novachat/plugin": "workspace:*"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
133 changes: 133 additions & 0 deletions packages/plugin-anthropic/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import * as novachat from '@novachat/plugin'
import type AnthropicTypes from '@anthropic-ai/sdk'
import { Anthropic } from '@anthropic-ai/sdk'
import { getImageAsBase64 } from './utils/base64'

async function convertMessages(
messages: novachat.QueryRequest['messages'],
): Promise<AnthropicTypes.MessageCreateParamsNonStreaming['messages']> {
return Promise.all(
messages.map(async (it) => {
if (!it.content) {
throw new Error('content is required')
}
if (!it.attachments) {
return {
role: it.role,
content: it.content,
} as AnthropicTypes.MessageParam
}
return {
role: it.role,
content: [
{
type: 'text',
text: it.content,
},
...(await Promise.all(
it.attachments.map(async (it) => {
return {
type: 'image',
source: {
type: 'base64',
...(await getImageAsBase64(it.url)),
},
} as AnthropicTypes.ImageBlockParam
}),
)),
],
} as AnthropicTypes.MessageParam
}),
)
}

function getModelMaxTokens(model: string): number {
if (model.startsWith('claude-3-5')) {
return 4096
}
return 8192
}

async function parseReq(
req: novachat.QueryRequest,
stream: boolean,
): Promise<AnthropicTypes.MessageCreateParams> {
return {
messages: await convertMessages(
req.messages.filter((it) =>
(
[
'user',
'assistant',
] as novachat.QueryRequest['messages'][number]['role'][]
).includes(it.role),
),
),
max_tokens: getModelMaxTokens(req.model),
stream,
model: req.model,
system: req.messages.find((it) => it.role === 'system')?.content,
} as AnthropicTypes.MessageCreateParamsNonStreaming
}

function parseResponse(
resp: AnthropicTypes.Messages.Message,
): novachat.QueryResponse {
if (resp.content.length !== 1) {
console.error('Unsupported response', resp.content)
throw new Error('Unsupported response')
}
return {
content: (resp.content[0] as AnthropicTypes.TextBlock).text,
}
}

export async function activate(context: novachat.PluginContext) {
const createClient = async () => {
return new Anthropic({
apiKey: await novachat.setting.get('anthropic.apiKey'),
defaultHeaders: {
'anthropic-dangerous-direct-browser-access': 'true',
},
})
}
await novachat.model.registerProvider({
name: 'Anthropic',
models: [
{ id: 'claude-3-5-sonnet-20240620', name: 'Claude 3.5 Sonnet' },
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
{ id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet' },
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
],
async invoke(query) {
const client = await createClient()
return parseResponse(
await client.messages.create(
(await parseReq(
query,
false,
)) as AnthropicTypes.MessageCreateParamsNonStreaming,
),
)
},
async *stream(query) {
const client = await createClient()
const stream = await client.messages.create(
(await parseReq(
query,
true,
)) as AnthropicTypes.MessageCreateParamsStreaming,
)
for await (const it of stream) {
if (it.type === 'content_block_delta') {
if (it.delta.type !== 'text_delta') {
throw new Error('Unsupported delta type')
}
yield {
content: it.delta.text,
}
}
}
},
})
}
17 changes: 17 additions & 0 deletions packages/plugin-anthropic/src/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "novachat.anthropic",
"name": "Anthropic",
"description": "Anthropic Provider",
"version": "0.1.0",
"author": "NovaChat",
"configuration": {
"title": "Anthropic",
"properties": {
"anthropic.apiKey": {
"type": "string",
"description": "Anthropic API Key",
"default": ""
}
}
}
}
29 changes: 29 additions & 0 deletions packages/plugin-anthropic/src/utils/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = ''
const bytes = new Uint8Array(buffer)
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}

export async function getImageAsBase64(imageUrl: string): Promise<{
media_type: string
data: string
}> {
// 检查是否已经是 data URL
if (imageUrl.startsWith('data:')) {
const [header, data] = imageUrl.split(',')
const media_type = header.split(':')[1].split(';')[0]
return { media_type, data }
}
// 如果不是 data URL,则按原方法处理
const response = await fetch(imageUrl)
const arrayBuffer = await response.arrayBuffer()
// 将 ArrayBuffer 转换为 base64
const base64 = arrayBufferToBase64(arrayBuffer)
return {
media_type: response.headers.get('content-type') || 'image/jpeg',
data: base64,
}
}
16 changes: 16 additions & 0 deletions packages/plugin-anthropic/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext"],
"outDir": "./dist",
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "bundler",
"sourceMap": true,
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}
2 changes: 1 addition & 1 deletion packages/plugin-openai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"novachat",
"novachat-plugin"
],
"version": "0.2.0",
"version": "0.2.1",
"license": "MIT",
"type": "module",
"files": [
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-openai/src/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "novachat.openai",
"name": "OpenAI Provider",
"name": "OpenAI",
"description": "OpenAI Provider",
"version": "0.1.1",
"author": "NovaChat",
Expand Down
31 changes: 0 additions & 31 deletions packages/plugin-openai/tsup.config.ts

This file was deleted.

18 changes: 18 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/lib/plugins/client/addInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import { minimatch } from 'minimatch'

// const EXCLUDE_HOSTNAME = ['127.0.0.1', 'localhost']
const INCLUDE_HOSTNAME = ['*.googleapis.com']
const INCLUDE_HOSTNAME = ['*.googleapis.com', 'api.anthropic.com']

const newLocal: InterceptOptions = {
request(req) {
Expand Down

0 comments on commit e824c85

Please sign in to comment.