385 lines
10 KiB
Go
385 lines
10 KiB
Go
package anthropic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/anthropics/anthropic-sdk-go"
|
|
"github.com/anthropics/anthropic-sdk-go/option"
|
|
|
|
"github.com/ajac-zero/latticelm/internal/api"
|
|
"github.com/ajac-zero/latticelm/internal/config"
|
|
)
|
|
|
|
const Name = "anthropic"
|
|
|
|
// Provider implements the Anthropic SDK integration.
|
|
// It supports both direct Anthropic API and Azure-hosted (Microsoft Foundry) endpoints.
|
|
type Provider struct {
|
|
cfg config.ProviderConfig
|
|
client *anthropic.Client
|
|
azure bool
|
|
}
|
|
|
|
// New constructs a Provider for the direct Anthropic API.
|
|
func New(cfg config.ProviderConfig) *Provider {
|
|
var client *anthropic.Client
|
|
if cfg.APIKey != "" {
|
|
c := anthropic.NewClient(option.WithAPIKey(cfg.APIKey))
|
|
client = &c
|
|
}
|
|
return &Provider{
|
|
cfg: cfg,
|
|
client: client,
|
|
}
|
|
}
|
|
|
|
// NewAzure constructs a Provider targeting Azure-hosted Anthropic (Microsoft Foundry).
|
|
// The Azure endpoint uses api-key header auth and a base URL like
|
|
// https://<resource>.services.ai.azure.com/anthropic.
|
|
func NewAzure(azureCfg config.AzureAnthropicConfig) *Provider {
|
|
var client *anthropic.Client
|
|
if azureCfg.APIKey != "" && azureCfg.Endpoint != "" {
|
|
c := anthropic.NewClient(
|
|
option.WithBaseURL(azureCfg.Endpoint),
|
|
option.WithAPIKey("unused"),
|
|
option.WithAuthToken(azureCfg.APIKey),
|
|
)
|
|
client = &c
|
|
}
|
|
return &Provider{
|
|
cfg: config.ProviderConfig{
|
|
APIKey: azureCfg.APIKey,
|
|
Model: azureCfg.Model,
|
|
},
|
|
client: client,
|
|
azure: true,
|
|
}
|
|
}
|
|
|
|
func (p *Provider) Name() string { return Name }
|
|
|
|
// Generate routes the request to Anthropic's API.
|
|
func (p *Provider) Generate(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
|
|
if p.cfg.APIKey == "" {
|
|
return nil, fmt.Errorf("anthropic api key missing")
|
|
}
|
|
if p.client == nil {
|
|
return nil, fmt.Errorf("anthropic client not initialized")
|
|
}
|
|
|
|
// Convert messages to Anthropic format
|
|
anthropicMsgs := make([]anthropic.MessageParam, 0, len(messages))
|
|
var system string
|
|
|
|
for _, msg := range messages {
|
|
var content string
|
|
for _, block := range msg.Content {
|
|
if block.Type == "input_text" || block.Type == "output_text" {
|
|
content += block.Text
|
|
}
|
|
}
|
|
|
|
switch msg.Role {
|
|
case "user":
|
|
anthropicMsgs = append(anthropicMsgs, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
|
|
case "assistant":
|
|
// Build content blocks including text and tool calls
|
|
var contentBlocks []anthropic.ContentBlockParamUnion
|
|
if content != "" {
|
|
contentBlocks = append(contentBlocks, anthropic.NewTextBlock(content))
|
|
}
|
|
// Add tool use blocks
|
|
for _, tc := range msg.ToolCalls {
|
|
var input map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.Arguments), &input); err != nil {
|
|
// If unmarshal fails, skip this tool call
|
|
continue
|
|
}
|
|
contentBlocks = append(contentBlocks, anthropic.NewToolUseBlock(tc.ID, input, tc.Name))
|
|
}
|
|
if len(contentBlocks) > 0 {
|
|
anthropicMsgs = append(anthropicMsgs, anthropic.NewAssistantMessage(contentBlocks...))
|
|
}
|
|
case "tool":
|
|
// Tool results must be in user message with tool_result blocks
|
|
anthropicMsgs = append(anthropicMsgs, anthropic.NewUserMessage(
|
|
anthropic.NewToolResultBlock(msg.CallID, content, false),
|
|
))
|
|
case "system", "developer":
|
|
system = content
|
|
}
|
|
}
|
|
|
|
// Build request params
|
|
maxTokens := int64(4096)
|
|
if req.MaxOutputTokens != nil {
|
|
maxTokens = int64(*req.MaxOutputTokens)
|
|
}
|
|
|
|
params := anthropic.MessageNewParams{
|
|
Model: anthropic.Model(req.Model),
|
|
Messages: anthropicMsgs,
|
|
MaxTokens: maxTokens,
|
|
}
|
|
|
|
if system != "" {
|
|
systemBlocks := []anthropic.TextBlockParam{
|
|
{Text: system, Type: "text"},
|
|
}
|
|
params.System = systemBlocks
|
|
}
|
|
|
|
if req.Temperature != nil {
|
|
params.Temperature = anthropic.Float(*req.Temperature)
|
|
}
|
|
if req.TopP != nil {
|
|
params.TopP = anthropic.Float(*req.TopP)
|
|
}
|
|
|
|
// Add tools if present
|
|
if req.Tools != nil && len(req.Tools) > 0 {
|
|
tools, err := parseTools(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tools: %w", err)
|
|
}
|
|
params.Tools = tools
|
|
}
|
|
|
|
// Add tool_choice if present
|
|
if req.ToolChoice != nil && len(req.ToolChoice) > 0 {
|
|
toolChoice, err := parseToolChoice(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tool_choice: %w", err)
|
|
}
|
|
params.ToolChoice = toolChoice
|
|
}
|
|
|
|
// Call Anthropic API
|
|
resp, err := p.client.Messages.New(ctx, params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("anthropic api error: %w", err)
|
|
}
|
|
|
|
// Extract text and tool calls from response
|
|
var text string
|
|
var toolCalls []api.ToolCall
|
|
|
|
for _, block := range resp.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
text += block.AsText().Text
|
|
case "tool_use":
|
|
// Extract tool calls
|
|
toolUse := block.AsToolUse()
|
|
argsJSON, _ := json.Marshal(toolUse.Input)
|
|
toolCalls = append(toolCalls, api.ToolCall{
|
|
ID: toolUse.ID,
|
|
Name: toolUse.Name,
|
|
Arguments: string(argsJSON),
|
|
})
|
|
}
|
|
}
|
|
|
|
return &api.ProviderResult{
|
|
ID: resp.ID,
|
|
Model: string(resp.Model),
|
|
Text: text,
|
|
ToolCalls: toolCalls,
|
|
Usage: api.Usage{
|
|
InputTokens: int(resp.Usage.InputTokens),
|
|
OutputTokens: int(resp.Usage.OutputTokens),
|
|
TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// GenerateStream handles streaming requests to Anthropic.
|
|
func (p *Provider) GenerateStream(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
|
|
deltaChan := make(chan *api.ProviderStreamDelta)
|
|
errChan := make(chan error, 1)
|
|
|
|
go func() {
|
|
defer close(deltaChan)
|
|
defer close(errChan)
|
|
|
|
if p.cfg.APIKey == "" {
|
|
errChan <- fmt.Errorf("anthropic api key missing")
|
|
return
|
|
}
|
|
if p.client == nil {
|
|
errChan <- fmt.Errorf("anthropic client not initialized")
|
|
return
|
|
}
|
|
|
|
// Convert messages to Anthropic format
|
|
anthropicMsgs := make([]anthropic.MessageParam, 0, len(messages))
|
|
var system string
|
|
|
|
for _, msg := range messages {
|
|
var content string
|
|
for _, block := range msg.Content {
|
|
if block.Type == "input_text" || block.Type == "output_text" {
|
|
content += block.Text
|
|
}
|
|
}
|
|
|
|
switch msg.Role {
|
|
case "user":
|
|
anthropicMsgs = append(anthropicMsgs, anthropic.NewUserMessage(anthropic.NewTextBlock(content)))
|
|
case "assistant":
|
|
// Build content blocks including text and tool calls
|
|
var contentBlocks []anthropic.ContentBlockParamUnion
|
|
if content != "" {
|
|
contentBlocks = append(contentBlocks, anthropic.NewTextBlock(content))
|
|
}
|
|
// Add tool use blocks
|
|
for _, tc := range msg.ToolCalls {
|
|
var input map[string]interface{}
|
|
if err := json.Unmarshal([]byte(tc.Arguments), &input); err != nil {
|
|
// If unmarshal fails, skip this tool call
|
|
continue
|
|
}
|
|
contentBlocks = append(contentBlocks, anthropic.NewToolUseBlock(tc.ID, input, tc.Name))
|
|
}
|
|
if len(contentBlocks) > 0 {
|
|
anthropicMsgs = append(anthropicMsgs, anthropic.NewAssistantMessage(contentBlocks...))
|
|
}
|
|
case "tool":
|
|
// Tool results must be in user message with tool_result blocks
|
|
anthropicMsgs = append(anthropicMsgs, anthropic.NewUserMessage(
|
|
anthropic.NewToolResultBlock(msg.CallID, content, false),
|
|
))
|
|
case "system", "developer":
|
|
system = content
|
|
}
|
|
}
|
|
|
|
// Build params
|
|
maxTokens := int64(4096)
|
|
if req.MaxOutputTokens != nil {
|
|
maxTokens = int64(*req.MaxOutputTokens)
|
|
}
|
|
|
|
params := anthropic.MessageNewParams{
|
|
Model: anthropic.Model(req.Model),
|
|
Messages: anthropicMsgs,
|
|
MaxTokens: maxTokens,
|
|
}
|
|
|
|
if system != "" {
|
|
systemBlocks := []anthropic.TextBlockParam{
|
|
{Text: system, Type: "text"},
|
|
}
|
|
params.System = systemBlocks
|
|
}
|
|
|
|
if req.Temperature != nil {
|
|
params.Temperature = anthropic.Float(*req.Temperature)
|
|
}
|
|
if req.TopP != nil {
|
|
params.TopP = anthropic.Float(*req.TopP)
|
|
}
|
|
|
|
// Add tools if present
|
|
if req.Tools != nil && len(req.Tools) > 0 {
|
|
tools, err := parseTools(req)
|
|
if err != nil {
|
|
errChan <- fmt.Errorf("parse tools: %w", err)
|
|
return
|
|
}
|
|
params.Tools = tools
|
|
}
|
|
|
|
// Add tool_choice if present
|
|
if req.ToolChoice != nil && len(req.ToolChoice) > 0 {
|
|
toolChoice, err := parseToolChoice(req)
|
|
if err != nil {
|
|
errChan <- fmt.Errorf("parse tool_choice: %w", err)
|
|
return
|
|
}
|
|
params.ToolChoice = toolChoice
|
|
}
|
|
|
|
// Create stream
|
|
stream := p.client.Messages.NewStreaming(ctx, params)
|
|
|
|
// Track content block index and tool call state
|
|
var contentBlockIndex int
|
|
|
|
// Process stream
|
|
for stream.Next() {
|
|
event := stream.Current()
|
|
|
|
switch event.Type {
|
|
case "content_block_start":
|
|
// New content block (text or tool_use)
|
|
contentBlockIndex = int(event.Index)
|
|
if event.ContentBlock.Type == "tool_use" {
|
|
// Send tool call delta with ID and name
|
|
toolUse := event.ContentBlock.AsToolUse()
|
|
delta := &api.ToolCallDelta{
|
|
Index: contentBlockIndex,
|
|
ID: toolUse.ID,
|
|
Name: toolUse.Name,
|
|
}
|
|
select {
|
|
case deltaChan <- &api.ProviderStreamDelta{ToolCallDelta: delta}:
|
|
case <-ctx.Done():
|
|
errChan <- ctx.Err()
|
|
return
|
|
}
|
|
}
|
|
|
|
case "content_block_delta":
|
|
if event.Delta.Type == "text_delta" {
|
|
// Text streaming
|
|
select {
|
|
case deltaChan <- &api.ProviderStreamDelta{Text: event.Delta.Text}:
|
|
case <-ctx.Done():
|
|
errChan <- ctx.Err()
|
|
return
|
|
}
|
|
} else if event.Delta.Type == "input_json_delta" {
|
|
// Tool arguments streaming
|
|
delta := &api.ToolCallDelta{
|
|
Index: int(event.Index),
|
|
Arguments: event.Delta.PartialJSON,
|
|
}
|
|
select {
|
|
case deltaChan <- &api.ProviderStreamDelta{ToolCallDelta: delta}:
|
|
case <-ctx.Done():
|
|
errChan <- ctx.Err()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := stream.Err(); err != nil {
|
|
errChan <- fmt.Errorf("anthropic stream error: %w", err)
|
|
return
|
|
}
|
|
|
|
// Send final delta
|
|
select {
|
|
case deltaChan <- &api.ProviderStreamDelta{Done: true}:
|
|
case <-ctx.Done():
|
|
errChan <- ctx.Err()
|
|
}
|
|
}()
|
|
|
|
return deltaChan, errChan
|
|
}
|
|
|
|
func chooseModel(requested, defaultModel string) string {
|
|
if requested != "" {
|
|
return requested
|
|
}
|
|
if defaultModel != "" {
|
|
return defaultModel
|
|
}
|
|
return "claude-3-5-sonnet"
|
|
}
|