From 42d98261e72d1da7f0df792eae579cb9c4abaaff Mon Sep 17 00:00:00 2001 From: langcaiye Date: Tue, 12 May 2026 15:29:06 +0800 Subject: [PATCH] feat: support Anthropic chat provider --- frontend/src/i18n/locales/en-US.ts | 4 + frontend/src/i18n/locales/zh-CN.ts | 4 + internal/models/chat/anthropic.go | 222 ++++++++++++++++++++++ internal/models/chat/anthropic_test.go | 83 ++++++++ internal/models/chat/chat.go | 3 + internal/models/provider/anthropic.go | 41 ++++ internal/models/provider/provider.go | 5 + internal/models/provider/provider_test.go | 31 +++ 8 files changed, 393 insertions(+) create mode 100644 internal/models/chat/anthropic.go create mode 100644 internal/models/chat/anthropic_test.go create mode 100644 internal/models/provider/anthropic.go diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 5d99b0b9c..7675c9fa3 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 97351a693..c285e561f 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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 服务', diff --git a/internal/models/chat/anthropic.go b/internal/models/chat/anthropic.go new file mode 100644 index 000000000..7fb13e5f3 --- /dev/null +++ b/internal/models/chat/anthropic.go @@ -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, + }, + } +} diff --git a/internal/models/chat/anthropic_test.go b/internal/models/chat/anthropic_test.go new file mode 100644 index 000000000..277ff1787 --- /dev/null +++ b/internal/models/chat/anthropic_test.go @@ -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) +} diff --git a/internal/models/chat/chat.go b/internal/models/chat/chat.go index 27d0deda4..d4a8d50e5 100644 --- a/internal/models/chat/chat.go +++ b/internal/models/chat/chat.go @@ -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 { diff --git a/internal/models/provider/anthropic.go b/internal/models/provider/anthropic.go new file mode 100644 index 000000000..89590de83 --- /dev/null +++ b/internal/models/provider/anthropic.go @@ -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 +} diff --git a/internal/models/provider/provider.go b/internal/models/provider/provider.go index 4c8076608..51fe274ea 100644 --- a/internal/models/provider/provider.go +++ b/internal/models/provider/provider.go @@ -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"): diff --git a/internal/models/provider/provider_test.go b/internal/models/provider/provider_test.go index dcf2568ec..5b35b1cc3 100644 --- a/internal/models/provider/provider_test.go +++ b/internal/models/provider/provider_test.go @@ -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{}