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)
358 lines
9.8 KiB
Go
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")
|
|
}
|