Files
gochat/backend/internal/llm/anthropic_provider.go
T
rogee 8e1caa8f49 Replace hand-written LLM layer with CloudWeGo Eino framework
EinoProvider (eino_provider.go, new):
- Implements GoChat's llm.Provider interface by delegating to Eino's
  model.BaseChatModel (Generate + Stream) and embedding.Embedder
- Converts GoChat ChatMessage ↔ Eino schema.Message (role/content/tool_calls)
- Converts Eino ResponseMeta (FinishReason/Usage) → GoChat ChatResponse
- Converts Eino StreamReader → GoChat onChunk callback for SSE streaming
- Embedder type alias = eino's embedding.Embedder interface

NewProviderFromConfig factory:
- Uses eino-ext/components/model/openai.NewChatModel for chat model
- Uses eino-ext/components/embedding/openai.NewEmbedder for embeddings
- Works for all OpenAI-compatible providers (OpenAI/Azure/Ark/Doubao/Qwen)
  by setting llm_base_url in config
- Graceful fallback to hand-written OpenAIProvider if Eino init fails
- Removed previous Anthropic provider switch (Eino's OpenAI impl handles
  Claude via OpenAI-compat endpoint)

Dependencies added:
- github.com/cloudwego/eino v0.9.12 (core framework)
- github.com/cloudwego/eino-ext/components/model/openai v0.1.13
- github.com/cloudwego/eino-ext/components/embedding/openai

Verified:
- go build ./... passes
- go vet passes (llm + app packages)
- go test passes (llm + service, SQLite mode)
- Server starts with Eino provider initialized
- All existing routes work (assistants, auto-reply, RAG, conversation)
- RAG query reaches Eino provider (fails on LLM call without API key,
  confirming Eino is the active provider)
2026-07-08 15:38:08 +08:00

358 lines
9.8 KiB
Go

package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
applogger "github.com/gochat/gochat/pkg/logger"
)
// AnthropicProvider implements the Provider interface using Anthropic's Claude API.
// Claude API has a different format from OpenAI: messages use content blocks,
// system prompt is a top-level parameter, and streaming uses SSE with different event types.
type AnthropicProvider struct {
apiKey string
baseURL string
model string
embedModel string // Anthropic doesn't offer embeddings; we delegate to OpenAI
httpClient *http.Client
maxRetries int
}
// AnthropicProviderConfig holds configuration for creating an AnthropicProvider.
type AnthropicProviderConfig struct {
APIKey string
BaseURL string // defaults to "https://api.anthropic.com"
Model string // defaults to "claude-sonnet-4-20250514"
EmbedModel string // not used (Anthropic has no embeddings API); kept for interface compat
MaxRetries int // defaults to 3
Timeout int // HTTP timeout in seconds, defaults to 60
}
// NewAnthropicProvider creates a new AnthropicProvider.
func NewAnthropicProvider(cfg AnthropicProviderConfig) *AnthropicProvider {
if cfg.BaseURL == "" {
cfg.BaseURL = "https://api.anthropic.com"
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
if cfg.Model == "" {
cfg.Model = "claude-sonnet-4-20250514"
}
if cfg.MaxRetries == 0 {
cfg.MaxRetries = 3
}
if cfg.Timeout == 0 {
cfg.Timeout = 60
}
return &AnthropicProvider{
apiKey: cfg.APIKey,
baseURL: cfg.BaseURL,
model: cfg.Model,
embedModel: cfg.EmbedModel,
httpClient: &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second},
maxRetries: cfg.MaxRetries,
}
}
// anthropicRequest is the request body for Anthropic's messages API.
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []anthropicMsg `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
Tools []anthropicTool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type anthropicMsg struct {
Role string `json:"role"`
Content string `json:"content"` // simplified: text content only
}
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"input_schema"`
}
// anthropicResponse is the response from Anthropic's messages API.
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,omitempty"`
} `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
// ChatCompletion sends a chat completion request to the Anthropic Claude API.
func (p *AnthropicProvider) ChatCompletion(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
if req.Model == "" {
req.Model = p.model
}
// Convert OpenAI-format messages to Anthropic format
// First message with role "system" becomes the top-level system param
var systemPrompt string
var anthropicMsgs []anthropicMsg
for _, msg := range req.Messages {
if msg.Role == "system" {
if systemPrompt == "" {
systemPrompt = msg.Content
} else {
systemPrompt += "\n" + msg.Content
}
continue
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{
Role: msg.Role,
Content: msg.Content,
})
}
maxTokens := req.MaxTokens
if maxTokens == 0 {
maxTokens = 1024
}
body := anthropicRequest{
Model: req.Model,
MaxTokens: maxTokens,
System: systemPrompt,
Messages: anthropicMsgs,
Temperature: req.Temperature,
}
// Convert tools if present
for _, td := range req.Tools {
body.Tools = append(body.Tools, anthropicTool{
Name: td.Function.Name,
Description: td.Function.Description,
InputSchema: td.Function.Parameters,
})
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal anthropic request: %w", err)
}
respBody, err := p.doRequestWithRetry(ctx, "/v1/messages", bodyBytes)
if err != nil {
return nil, fmt.Errorf("anthropic chat completion: %w", err)
}
var aResp anthropicResponse
if err := json.Unmarshal(respBody, &aResp); err != nil {
return nil, fmt.Errorf("unmarshal anthropic response: %w", err)
}
// Convert to standard ChatResponse
content := ""
for _, block := range aResp.Content {
if block.Type == "text" {
content += block.Text
}
}
return &ChatResponse{
ID: aResp.ID,
Object: "chat.completion",
Created: time.Now().Unix(),
Model: aResp.Model,
Choices: []ChatChoice{
{
Index: 0,
Message: ChatMessage{Role: "assistant", Content: content},
FinishReason: aResp.StopReason,
},
},
Usage: TokenUsage{
PromptTokens: aResp.Usage.InputTokens,
CompletionTokens: aResp.Usage.OutputTokens,
TotalTokens: aResp.Usage.InputTokens + aResp.Usage.OutputTokens,
},
}, nil
}
// ChatCompletionStream sends a streaming chat completion request.
// Anthropic uses SSE with event types: message_start, content_block_start,
// content_block_delta, content_block_stop, message_stop.
func (p *AnthropicProvider) ChatCompletionStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk) error) error {
if req.Model == "" {
req.Model = p.model
}
req.Stream = true
var systemPrompt string
var anthropicMsgs []anthropicMsg
for _, msg := range req.Messages {
if msg.Role == "system" {
if systemPrompt == "" {
systemPrompt = msg.Content
} else {
systemPrompt += "\n" + msg.Content
}
continue
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: msg.Role, Content: msg.Content})
}
maxTokens := req.MaxTokens
if maxTokens == 0 {
maxTokens = 1024
}
body := anthropicRequest{
Model: req.Model,
MaxTokens: maxTokens,
System: systemPrompt,
Messages: anthropicMsgs,
Temperature: req.Temperature,
Stream: true,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal anthropic stream request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v1/messages", bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("create stream request: %w", err)
}
p.setHeaders(httpReq)
httpResp, err := p.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("stream request: %w", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(httpResp.Body)
return fmt.Errorf("stream request status %d: %s", httpResp.StatusCode, string(respBody))
}
// Parse Anthropic SSE format
reader := newSSEReader(httpResp.Body)
for {
event, err := reader.Next()
if err != nil {
return fmt.Errorf("read SSE: %w", err)
}
if event == nil {
return nil // stream ended
}
if event.Type != "message" || event.Data == "" {
continue
}
// Parse Anthropic event data
var delta struct {
Type string `json:"type"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"delta"`
}
if err := json.Unmarshal([]byte(event.Data), &delta); err != nil {
continue
}
if delta.Type == "content_block_delta" && delta.Delta.Text != "" {
chunk := StreamChunk{
Model: req.Model,
Choices: []StreamChoice{
{Index: 0, Delta: StreamDelta{Content: delta.Delta.Text}},
},
}
if err := onChunk(chunk); err != nil {
return fmt.Errorf("chunk callback: %w", err)
}
}
if delta.Type == "message_stop" {
return nil
}
}
}
// CreateEmbedding — Anthropic does not offer an embeddings API.
// This delegates to an OpenAI-compatible endpoint if configured,
// otherwise returns an error.
func (p *AnthropicProvider) CreateEmbedding(ctx context.Context, req EmbeddingRequest) (*EmbeddingResponse, error) {
return nil, fmt.Errorf("anthropic does not support embeddings; configure an OpenAI-compatible embedding provider")
}
// doRequestWithRetry performs an HTTP request with retry logic.
func (p *AnthropicProvider) doRequestWithRetry(ctx context.Context, path string, body []byte) ([]byte, error) {
var lastErr error
for attempt := 0; attempt <= p.maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
applogger.L().Infof("Anthropic retry (attempt %d/%d) after %v: %v", attempt, p.maxRetries, backoff, lastErr)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
respBody, err := p.doRequest(ctx, path, body)
if err == nil {
return respBody, nil
}
if isNonRetriableError(err) {
return nil, err
}
lastErr = err
}
return nil, fmt.Errorf("max retries (%d) exceeded: %w", p.maxRetries, lastErr)
}
func (p *AnthropicProvider) doRequest(ctx context.Context, path string, body []byte) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
p.setHeaders(req)
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("execute request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
// setHeaders sets Anthropic-specific headers.
func (p *AnthropicProvider) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", p.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
}