feat: support Anthropic chat provider

This commit is contained in:
langcaiye
2026-05-12 17:33:16 +08:00
committed by lyingbug
parent 5949be739f
commit 42d98261e7
8 changed files with 393 additions and 0 deletions
+4
View File
@@ -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',
+4
View File
@@ -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 服务',
+222
View File
@@ -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,
},
}
}
+83
View File
@@ -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)
}
+3
View File
@@ -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 {
+41
View File
@@ -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
}
+5
View File
@@ -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"):
+31
View File
@@ -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{}