Add go-llm v2: redesigned API for simpler LLM abstraction
v2 is a new Go module (v2/) with a dramatically simpler API: - Unified Message type (no more Input marker interface) - Define[T] for ergonomic tool creation with standard context.Context - Chat session with automatic tool-call loop (agent loop) - Streaming via pull-based StreamReader - MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer) - Middleware support (logging, retry, timeout, usage tracking) - Decoupled JSON Schema (map[string]any, no provider coupling) - Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP - Providers: OpenAI, Anthropic, Google (all with streaming) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
273
v2/anthropic/anthropic.go
Normal file
273
v2/anthropic/anthropic.go
Normal file
@@ -0,0 +1,273 @@
|
||||
// Package anthropic implements the go-llm v2 provider interface for Anthropic.
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/internal/imageutil"
|
||||
"gitea.stevedudenhoeffer.com/steve/go-llm/v2/provider"
|
||||
|
||||
anth "github.com/liushuangls/go-anthropic/v2"
|
||||
)
|
||||
|
||||
// Provider implements the provider.Provider interface for Anthropic.
|
||||
type Provider struct {
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// New creates a new Anthropic provider.
|
||||
func New(apiKey string) *Provider {
|
||||
return &Provider{apiKey: apiKey}
|
||||
}
|
||||
|
||||
// Complete performs a non-streaming completion.
|
||||
func (p *Provider) Complete(ctx context.Context, req provider.Request) (provider.Response, error) {
|
||||
cl := anth.NewClient(p.apiKey)
|
||||
|
||||
anthReq := p.buildRequest(req)
|
||||
|
||||
resp, err := cl.CreateMessages(ctx, anthReq)
|
||||
if err != nil {
|
||||
return provider.Response{}, fmt.Errorf("anthropic completion error: %w", err)
|
||||
}
|
||||
|
||||
return p.convertResponse(resp), nil
|
||||
}
|
||||
|
||||
// Stream performs a streaming completion.
|
||||
func (p *Provider) Stream(ctx context.Context, req provider.Request, events chan<- provider.StreamEvent) error {
|
||||
cl := anth.NewClient(p.apiKey)
|
||||
|
||||
anthReq := p.buildRequest(req)
|
||||
|
||||
resp, err := cl.CreateMessagesStream(ctx, anth.MessagesStreamRequest{
|
||||
MessagesRequest: anthReq,
|
||||
OnContentBlockDelta: func(data anth.MessagesEventContentBlockDeltaData) {
|
||||
if data.Delta.Type == "text_delta" && data.Delta.Text != nil {
|
||||
events <- provider.StreamEvent{
|
||||
Type: provider.StreamEventText,
|
||||
Text: *data.Delta.Text,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("anthropic stream error: %w", err)
|
||||
}
|
||||
|
||||
result := p.convertResponse(resp)
|
||||
events <- provider.StreamEvent{
|
||||
Type: provider.StreamEventDone,
|
||||
Response: &result,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) buildRequest(req provider.Request) anth.MessagesRequest {
|
||||
anthReq := anth.MessagesRequest{
|
||||
Model: anth.Model(req.Model),
|
||||
MaxTokens: 4096,
|
||||
}
|
||||
|
||||
if req.MaxTokens != nil {
|
||||
anthReq.MaxTokens = *req.MaxTokens
|
||||
}
|
||||
|
||||
var msgs []anth.Message
|
||||
|
||||
for _, msg := range req.Messages {
|
||||
if msg.Role == "system" {
|
||||
if len(anthReq.System) > 0 {
|
||||
anthReq.System += "\n"
|
||||
}
|
||||
anthReq.System += msg.Content
|
||||
continue
|
||||
}
|
||||
|
||||
if msg.Role == "tool" {
|
||||
// Tool results in Anthropic format - use the helper
|
||||
toolUseID := msg.ToolCallID
|
||||
content := msg.Content
|
||||
isError := false
|
||||
msgs = append(msgs, anth.Message{
|
||||
Role: anth.RoleUser,
|
||||
Content: []anth.MessageContent{
|
||||
{
|
||||
Type: anth.MessagesContentTypeToolResult,
|
||||
MessageContentToolResult: &anth.MessageContentToolResult{
|
||||
ToolUseID: &toolUseID,
|
||||
Content: []anth.MessageContent{
|
||||
{
|
||||
Type: anth.MessagesContentTypeText,
|
||||
Text: &content,
|
||||
},
|
||||
},
|
||||
IsError: &isError,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
role := anth.RoleUser
|
||||
if msg.Role == "assistant" {
|
||||
role = anth.RoleAssistant
|
||||
}
|
||||
|
||||
m := anth.Message{
|
||||
Role: role,
|
||||
Content: []anth.MessageContent{},
|
||||
}
|
||||
|
||||
if msg.Content != "" {
|
||||
m.Content = append(m.Content, anth.MessageContent{
|
||||
Type: anth.MessagesContentTypeText,
|
||||
Text: &msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle tool calls in assistant messages
|
||||
for _, tc := range msg.ToolCalls {
|
||||
var input json.RawMessage
|
||||
if tc.Arguments != "" {
|
||||
input = json.RawMessage(tc.Arguments)
|
||||
} else {
|
||||
input = json.RawMessage("{}")
|
||||
}
|
||||
m.Content = append(m.Content, anth.MessageContent{
|
||||
Type: anth.MessagesContentTypeToolUse,
|
||||
MessageContentToolUse: &anth.MessageContentToolUse{
|
||||
ID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Input: input,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle images
|
||||
for _, img := range msg.Images {
|
||||
if role == anth.RoleAssistant {
|
||||
role = anth.RoleUser
|
||||
m.Role = anth.RoleUser
|
||||
}
|
||||
|
||||
if img.Base64 != "" {
|
||||
b64 := img.Base64
|
||||
contentType := img.ContentType
|
||||
|
||||
// Compress if > 5MiB
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err == nil && len(raw) >= 5242880 {
|
||||
compressed, mime, cerr := imageutil.CompressImage(b64, 5*1024*1024)
|
||||
if cerr == nil {
|
||||
b64 = compressed
|
||||
contentType = mime
|
||||
}
|
||||
}
|
||||
|
||||
m.Content = append(m.Content, anth.NewImageMessageContent(
|
||||
anth.NewMessageContentSource(
|
||||
anth.MessagesContentSourceTypeBase64,
|
||||
contentType,
|
||||
b64,
|
||||
)))
|
||||
} else if img.URL != "" {
|
||||
// Download and convert to base64 (Anthropic doesn't support URLs directly)
|
||||
resp, err := http.Get(img.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
m.Content = append(m.Content, anth.NewImageMessageContent(
|
||||
anth.NewMessageContentSource(
|
||||
anth.MessagesContentSourceTypeBase64,
|
||||
contentType,
|
||||
b64,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// Merge consecutive same-role messages (Anthropic requires alternating)
|
||||
if len(msgs) > 0 && msgs[len(msgs)-1].Role == role {
|
||||
msgs[len(msgs)-1].Content = append(msgs[len(msgs)-1].Content, m.Content...)
|
||||
} else {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tool := range req.Tools {
|
||||
anthReq.Tools = append(anthReq.Tools, anth.ToolDefinition{
|
||||
Name: tool.Name,
|
||||
Description: tool.Description,
|
||||
InputSchema: tool.Schema,
|
||||
})
|
||||
}
|
||||
|
||||
anthReq.Messages = msgs
|
||||
|
||||
if req.Temperature != nil {
|
||||
f := float32(*req.Temperature)
|
||||
anthReq.Temperature = &f
|
||||
}
|
||||
|
||||
if req.TopP != nil {
|
||||
f := float32(*req.TopP)
|
||||
anthReq.TopP = &f
|
||||
}
|
||||
|
||||
if len(req.Stop) > 0 {
|
||||
anthReq.StopSequences = req.Stop
|
||||
}
|
||||
|
||||
return anthReq
|
||||
}
|
||||
|
||||
func (p *Provider) convertResponse(resp anth.MessagesResponse) provider.Response {
|
||||
var res provider.Response
|
||||
var textParts []string
|
||||
|
||||
for _, block := range resp.Content {
|
||||
switch block.Type {
|
||||
case anth.MessagesContentTypeText:
|
||||
if block.Text != nil {
|
||||
textParts = append(textParts, *block.Text)
|
||||
}
|
||||
case anth.MessagesContentTypeToolUse:
|
||||
if block.MessageContentToolUse != nil {
|
||||
args, _ := json.Marshal(block.MessageContentToolUse.Input)
|
||||
res.ToolCalls = append(res.ToolCalls, provider.ToolCall{
|
||||
ID: block.MessageContentToolUse.ID,
|
||||
Name: block.MessageContentToolUse.Name,
|
||||
Arguments: string(args),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.Text = strings.Join(textParts, "")
|
||||
|
||||
res.Usage = &provider.Usage{
|
||||
InputTokens: resp.Usage.InputTokens,
|
||||
OutputTokens: resp.Usage.OutputTokens,
|
||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens,
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
Reference in New Issue
Block a user