mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-19 02:18:25 +08:00
feat: support Anthropic chat provider
This commit is contained in:
@@ -2567,6 +2567,10 @@ export default {
|
||||
label: 'OpenAI',
|
||||
description: 'gpt-5.2, gpt-5-mini, etc.',
|
||||
},
|
||||
anthropic: {
|
||||
label: 'Anthropic',
|
||||
description: 'Claude models via native Anthropic Messages API',
|
||||
},
|
||||
azure_openai: {
|
||||
label: 'Azure OpenAI',
|
||||
description: 'OpenAI service hosted on Microsoft Azure',
|
||||
|
||||
@@ -1805,6 +1805,10 @@ export default {
|
||||
label: "OpenAI",
|
||||
description: "gpt-5.2, gpt-5-mini, etc.",
|
||||
},
|
||||
anthropic: {
|
||||
label: "Anthropic",
|
||||
description: "Claude models via native Anthropic Messages API",
|
||||
},
|
||||
azure_openai: {
|
||||
label: 'Azure OpenAI',
|
||||
description: 'Microsoft Azure 上的 OpenAI 服务',
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
const anthropicVersion = "2023-06-01"
|
||||
|
||||
type AnthropicChat struct {
|
||||
modelName string
|
||||
modelID string
|
||||
baseURL string
|
||||
apiKey string
|
||||
customHeaders map[string]string
|
||||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []anthropicMessage `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
} `json:"usage"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func NewAnthropicChat(config *ChatConfig) (*AnthropicChat, error) {
|
||||
if config.BaseURL != "" {
|
||||
if err := secutils.ValidateURLForSSRF(config.BaseURL); err != nil {
|
||||
return nil, fmt.Errorf("baseURL SSRF check failed: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return nil, fmt.Errorf("Anthropic provider: API key is required")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = provider.AnthropicBaseURL
|
||||
}
|
||||
|
||||
return &AnthropicChat{
|
||||
modelName: config.ModelName,
|
||||
modelID: config.ModelID,
|
||||
baseURL: baseURL,
|
||||
apiKey: config.APIKey,
|
||||
customHeaders: config.CustomHeaders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AnthropicChat) Chat(ctx context.Context, messages []Message, opts *ChatOptions) (*types.ChatResponse, error) {
|
||||
reqBody := c.buildRequest(messages, opts)
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := withLLMTimeout(ctx, defaultChatTimeout)
|
||||
defer cancel()
|
||||
|
||||
endpoint := c.baseURL + "/messages"
|
||||
if err := secutils.ValidateURLForSSRF(endpoint); err != nil {
|
||||
return nil, fmt.Errorf("endpoint SSRF check failed: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("x-api-key", c.apiKey)
|
||||
httpReq.Header.Set("anthropic-version", anthropicVersion)
|
||||
secutils.ApplyCustomHeaders(httpReq, c.customHeaders)
|
||||
|
||||
resp, err := rawHTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var chatResp anthropicResponse
|
||||
if err := json.Unmarshal(body, &chatResp); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
if chatResp.Error != nil && chatResp.Error.Message != "" {
|
||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, chatResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return c.parseResponse(&chatResp), nil
|
||||
}
|
||||
|
||||
func (c *AnthropicChat) ChatStream(ctx context.Context, messages []Message, opts *ChatOptions) (<-chan types.StreamResponse, error) {
|
||||
return nil, fmt.Errorf("Anthropic streaming chat is not supported yet")
|
||||
}
|
||||
|
||||
func (c *AnthropicChat) GetModelName() string {
|
||||
return c.modelName
|
||||
}
|
||||
|
||||
func (c *AnthropicChat) GetModelID() string {
|
||||
return c.modelID
|
||||
}
|
||||
|
||||
func (c *AnthropicChat) buildRequest(messages []Message, opts *ChatOptions) anthropicRequest {
|
||||
req := anthropicRequest{
|
||||
Model: c.modelName,
|
||||
MaxTokens: 1024,
|
||||
Messages: make([]anthropicMessage, 0, len(messages)),
|
||||
}
|
||||
if opts != nil {
|
||||
if opts.MaxTokens > 0 {
|
||||
req.MaxTokens = opts.MaxTokens
|
||||
} else if opts.MaxCompletionTokens > 0 {
|
||||
req.MaxTokens = opts.MaxCompletionTokens
|
||||
}
|
||||
if opts.Temperature > 0 {
|
||||
temperature := opts.Temperature
|
||||
req.Temperature = &temperature
|
||||
}
|
||||
if opts.TopP > 0 {
|
||||
topP := opts.TopP
|
||||
req.TopP = &topP
|
||||
}
|
||||
}
|
||||
|
||||
var systemParts []string
|
||||
for _, msg := range messages {
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" {
|
||||
content = textFromMultiContent(msg.MultiContent)
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
systemParts = append(systemParts, content)
|
||||
case "assistant":
|
||||
req.Messages = append(req.Messages, anthropicMessage{Role: "assistant", Content: content})
|
||||
case "user":
|
||||
req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content})
|
||||
default:
|
||||
req.Messages = append(req.Messages, anthropicMessage{Role: "user", Content: content})
|
||||
}
|
||||
}
|
||||
req.System = strings.Join(systemParts, "\n\n")
|
||||
return req
|
||||
}
|
||||
|
||||
func textFromMultiContent(parts []MessageContentPart) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
textParts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part.Type == "text" && strings.TrimSpace(part.Text) != "" {
|
||||
textParts = append(textParts, strings.TrimSpace(part.Text))
|
||||
}
|
||||
}
|
||||
return strings.Join(textParts, "\n")
|
||||
}
|
||||
|
||||
func (c *AnthropicChat) parseResponse(resp *anthropicResponse) *types.ChatResponse {
|
||||
parts := make([]string, 0, len(resp.Content))
|
||||
for _, part := range resp.Content {
|
||||
if part.Type == "text" && part.Text != "" {
|
||||
parts = append(parts, part.Text)
|
||||
}
|
||||
}
|
||||
inputTokens := resp.Usage.InputTokens
|
||||
outputTokens := resp.Usage.OutputTokens
|
||||
return &types.ChatResponse{
|
||||
Content: strings.Join(parts, ""),
|
||||
FinishReason: resp.StopReason,
|
||||
Usage: types.TokenUsage{
|
||||
PromptTokens: inputTokens,
|
||||
CompletionTokens: outputTokens,
|
||||
TotalTokens: inputTokens + outputTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnthropicChat(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
|
||||
var capturedHeaders http.Header
|
||||
var capturedRequest anthropicRequest
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/messages", r.URL.Path)
|
||||
capturedHeaders = r.Header.Clone()
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedRequest))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"id":"msg_123",
|
||||
"type":"message",
|
||||
"role":"assistant",
|
||||
"content":[{"type":"text","text":"hello"}],
|
||||
"stop_reason":"end_turn",
|
||||
"usage":{"input_tokens":3,"output_tokens":2}
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
chat, err := NewAnthropicChat(&ChatConfig{
|
||||
Source: types.ModelSourceRemote,
|
||||
BaseURL: server.URL,
|
||||
ModelName: "claude-sonnet-4-5",
|
||||
APIKey: "test-key",
|
||||
Provider: string(provider.ProviderAnthropic),
|
||||
CustomHeaders: map[string]string{
|
||||
"anthropic-beta": "test-beta",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := chat.Chat(context.Background(), []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, &ChatOptions{MaxTokens: 7, Temperature: 0.2})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test-key", capturedHeaders.Get("x-api-key"))
|
||||
assert.Equal(t, anthropicVersion, capturedHeaders.Get("anthropic-version"))
|
||||
assert.Equal(t, "test-beta", capturedHeaders.Get("anthropic-beta"))
|
||||
assert.Equal(t, "claude-sonnet-4-5", capturedRequest.Model)
|
||||
assert.Equal(t, 7, capturedRequest.MaxTokens)
|
||||
assert.Equal(t, "You are helpful.", capturedRequest.System)
|
||||
require.Len(t, capturedRequest.Messages, 1)
|
||||
assert.Equal(t, "user", capturedRequest.Messages[0].Role)
|
||||
assert.Equal(t, "Hi", capturedRequest.Messages[0].Content)
|
||||
assert.Equal(t, "hello", resp.Content)
|
||||
assert.Equal(t, "end_turn", resp.FinishReason)
|
||||
assert.Equal(t, 3, resp.Usage.PromptTokens)
|
||||
assert.Equal(t, 2, resp.Usage.CompletionTokens)
|
||||
assert.Equal(t, 5, resp.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestNewRemoteChat_AnthropicProvider(t *testing.T) {
|
||||
chat, err := NewRemoteChat(&ChatConfig{
|
||||
Source: types.ModelSourceRemote,
|
||||
ModelName: "claude-sonnet-4-5",
|
||||
APIKey: "test-key",
|
||||
Provider: string(provider.ProviderAnthropic),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, ok := chat.(*AnthropicChat)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
@@ -151,6 +151,9 @@ func NewRemoteChat(config *ChatConfig) (Chat, error) {
|
||||
if providerName == "" {
|
||||
providerName = provider.DetectProvider(config.BaseURL)
|
||||
}
|
||||
if providerName == provider.ProviderAnthropic {
|
||||
return NewAnthropicChat(config)
|
||||
}
|
||||
|
||||
remoteChat, err := NewRemoteAPIChat(config)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
const AnthropicBaseURL = "https://api.anthropic.com/v1"
|
||||
|
||||
// AnthropicProvider implements native Anthropic Messages API metadata.
|
||||
type AnthropicProvider struct{}
|
||||
|
||||
func init() {
|
||||
Register(&AnthropicProvider{})
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Info() ProviderInfo {
|
||||
return ProviderInfo{
|
||||
Name: ProviderAnthropic,
|
||||
DisplayName: "Anthropic",
|
||||
Description: "Claude models via native Anthropic Messages API",
|
||||
DefaultURLs: map[types.ModelType]string{
|
||||
types.ModelTypeKnowledgeQA: AnthropicBaseURL,
|
||||
},
|
||||
ModelTypes: []types.ModelType{
|
||||
types.ModelTypeKnowledgeQA,
|
||||
},
|
||||
RequiresAuth: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) ValidateConfig(config *Config) error {
|
||||
if config.APIKey == "" {
|
||||
return fmt.Errorf("API key is required for Anthropic provider")
|
||||
}
|
||||
if config.ModelName == "" {
|
||||
return fmt.Errorf("model name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -15,6 +15,8 @@ type ProviderName string
|
||||
const (
|
||||
// OpenAI
|
||||
ProviderOpenAI ProviderName = "openai"
|
||||
// Anthropic Claude
|
||||
ProviderAnthropic ProviderName = "anthropic"
|
||||
// 阿里云 DashScope
|
||||
ProviderAliyun ProviderName = "aliyun"
|
||||
// 智谱AI (GLM 系列)
|
||||
@@ -78,6 +80,7 @@ func AllProviders() []ProviderName {
|
||||
ProviderQianfan,
|
||||
ProviderQiniu,
|
||||
ProviderOpenAI,
|
||||
ProviderAnthropic,
|
||||
ProviderGemini,
|
||||
ProviderOpenRouter,
|
||||
ProviderJina,
|
||||
@@ -229,6 +232,8 @@ func DetectProvider(baseURL string) ProviderName {
|
||||
return ProviderAzureOpenAI
|
||||
case containsAny(baseURL, "api.openai.com"):
|
||||
return ProviderOpenAI
|
||||
case containsAny(baseURL, "api.anthropic.com"):
|
||||
return ProviderAnthropic
|
||||
case containsAny(baseURL, "api.deepseek.com"):
|
||||
return ProviderDeepSeek
|
||||
case containsAny(baseURL, "generativelanguage.googleapis.com"):
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestDetectProvider(t *testing.T) {
|
||||
expected ProviderName
|
||||
}{
|
||||
{"https://api.openai.com/v1", ProviderOpenAI},
|
||||
{"https://api.anthropic.com/v1", ProviderAnthropic},
|
||||
{"https://openrouter.ai/api/v1", ProviderOpenRouter},
|
||||
{"https://dashscope.aliyuncs.com/compatible-mode/v1", ProviderAliyun},
|
||||
{"https://open.bigmodel.cn/api/paas/v4", ProviderZhipu},
|
||||
@@ -60,6 +61,36 @@ func TestDetectProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProviderValidation(t *testing.T) {
|
||||
p := &AnthropicProvider{}
|
||||
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
config := &Config{
|
||||
APIKey: "sk-ant-test",
|
||||
ModelName: "claude-sonnet-4-5",
|
||||
}
|
||||
err := p.ValidateConfig(config)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing API key", func(t *testing.T) {
|
||||
config := &Config{
|
||||
ModelName: "claude-sonnet-4-5",
|
||||
}
|
||||
err := p.ValidateConfig(config)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "API key")
|
||||
})
|
||||
|
||||
t.Run("info", func(t *testing.T) {
|
||||
info := p.Info()
|
||||
assert.Equal(t, ProviderAnthropic, info.Name)
|
||||
assert.Equal(t, AnthropicBaseURL, info.GetDefaultURL(types.ModelTypeKnowledgeQA))
|
||||
assert.Contains(t, info.ModelTypes, types.ModelTypeKnowledgeQA)
|
||||
assert.True(t, info.RequiresAuth)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIProviderValidation(t *testing.T) {
|
||||
p := &OpenAIProvider{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user