Files
agents_and_robots/shell/llm/anthropic.go
T
egutierrez 5697b92ab8 feat: integrar structured logging en todos los componentes del shell
Se propaga *slog.Logger a todos los componentes impuros del shell:
- shell/bus/ — logs de subscribe, send, reply, timeout, unsubscribe
- shell/effects/ — duración y resultado de cada action ejecutada
- shell/llm/ (anthropic, openai, factory) — request/response con tokens, duración, fallback
- shell/memory/sqlite — open, save, recall, close con detalles
- shell/ssh/ — inicio, fin, errores y duración de comandos SSH
- tools/registry — registro, ejecución y errores de herramientas

Se usa el paquete shell/logger para field names consistentes (FieldDurationMS, FieldTokensUsed, etc.).
Cada componente recibe el logger por inyección de dependencias, sin globals.
Las firmas de New/FromConfig se actualizan para aceptar *slog.Logger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:53:31 +00:00

243 lines
7.0 KiB
Go

// Package llm contains impure LLM provider implementations.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
coretypes "github.com/enmanuel/agents/pkg/llm"
"github.com/enmanuel/agents/shell/logger"
)
const anthropicAPIBase = "https://api.anthropic.com/v1"
const anthropicVersion = "2023-06-01"
// NewAnthropicComplete returns a CompleteFunc backed by the Anthropic API.
func NewAnthropicComplete(apiKeyEnv, baseURL string, log *slog.Logger) coretypes.CompleteFunc {
if baseURL == "" {
baseURL = anthropicAPIBase
}
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
apiKey := os.Getenv(apiKeyEnv)
if apiKey == "" {
return coretypes.CompletionResponse{}, fmt.Errorf("env var %s is not set", apiKeyEnv)
}
log.Info("llm_request",
"provider", "anthropic",
"model", req.Model,
"messages", len(req.Messages),
"tools", len(req.Tools),
)
body := toAnthropicRequest(req)
raw, err := json.Marshal(body)
if err != nil {
return coretypes.CompletionResponse{}, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/messages", bytes.NewReader(raw))
if err != nil {
return coretypes.CompletionResponse{}, err
}
httpReq.Header.Set("x-api-key", apiKey)
httpReq.Header.Set("anthropic-version", anthropicVersion)
httpReq.Header.Set("content-type", "application/json")
start := time.Now()
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
ms := time.Since(start).Milliseconds()
log.Error("llm_error", "provider", "anthropic", logger.FieldDurationMS, ms, "err", err)
return coretypes.CompletionResponse{}, fmt.Errorf("anthropic request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return coretypes.CompletionResponse{}, fmt.Errorf("read response: %w", err)
}
ms := time.Since(start).Milliseconds()
if resp.StatusCode != http.StatusOK {
log.Error("llm_error", "provider", "anthropic", logger.FieldDurationMS, ms, "status", resp.StatusCode)
return coretypes.CompletionResponse{}, fmt.Errorf("anthropic error %d: %s", resp.StatusCode, respBytes)
}
result, err := fromAnthropicResponse(respBytes)
if err != nil {
log.Error("llm_error", "provider", "anthropic", logger.FieldDurationMS, ms, "err", err)
return result, err
}
log.Info("llm_response",
"provider", "anthropic",
"model", req.Model,
logger.FieldDurationMS, ms,
logger.FieldTokensUsed, result.Usage.TotalTokens,
"input_tokens", result.Usage.InputTokens,
"output_tokens", result.Usage.OutputTokens,
"tool_calls", len(result.ToolCalls),
"finish_reason", result.FinishReason,
)
return result, nil
}
}
// ── private conversion helpers ────────────────────────────────────────────
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"input_schema"`
}
// anthropicContentBlock represents a block in a content array.
type anthropicContentBlock struct {
Type string `json:"type"`
// text block
Text string `json:"text,omitempty"`
// tool_use block (in assistant responses)
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]any `json:"input,omitempty"`
// tool_result block (in user messages)
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
}
type anthropicResponse struct {
Content []anthropicContentBlock `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
StopReason string `json:"stop_reason"`
}
func toAnthropicRequest(req coretypes.CompletionRequest) anthropicRequest {
msgs := make([]anthropicMessage, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == coretypes.RoleSystem {
continue
}
msgs = append(msgs, toAnthropicMessage(m))
}
tools := make([]anthropicTool, len(req.Tools))
for i, t := range req.Tools {
tools[i] = anthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: t.InputSchema,
}
}
return anthropicRequest{
Model: req.Model,
MaxTokens: req.MaxTokens,
System: req.SystemPrompt,
Messages: msgs,
Tools: tools,
}
}
// toAnthropicMessage converts a core Message to the Anthropic format.
// Handles plain text, assistant messages with tool calls, and tool result messages.
func toAnthropicMessage(m coretypes.Message) anthropicMessage {
// Assistant message with tool calls → content array with text + tool_use blocks
if m.Role == coretypes.RoleAssistant && len(m.ToolCalls) > 0 {
blocks := make([]anthropicContentBlock, 0, len(m.ToolCalls)+1)
if m.Content != "" {
blocks = append(blocks, anthropicContentBlock{Type: "text", Text: m.Content})
}
for _, tc := range m.ToolCalls {
var input map[string]any
_ = json.Unmarshal([]byte(tc.Arguments), &input)
blocks = append(blocks, anthropicContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Name,
Input: input,
})
}
raw, _ := json.Marshal(blocks)
return anthropicMessage{Role: "assistant", Content: raw}
}
// Tool result message → user message with tool_result content array
if m.Role == coretypes.RoleTool {
blocks := []anthropicContentBlock{{
Type: "tool_result",
ToolUseID: m.ToolCallID,
Content: m.Content,
}}
raw, _ := json.Marshal(blocks)
return anthropicMessage{Role: "user", Content: raw}
}
// Plain text message
raw, _ := json.Marshal(m.Content)
return anthropicMessage{Role: string(m.Role), Content: raw}
}
func fromAnthropicResponse(raw []byte) (coretypes.CompletionResponse, error) {
var ar anthropicResponse
if err := json.Unmarshal(raw, &ar); err != nil {
return coretypes.CompletionResponse{}, fmt.Errorf("unmarshal response: %w", err)
}
var content string
var toolCalls []coretypes.ToolCall
for _, c := range ar.Content {
switch c.Type {
case "text":
content += c.Text
case "tool_use":
argsJSON, _ := json.Marshal(c.Input)
toolCalls = append(toolCalls, coretypes.ToolCall{
ID: c.ID,
Name: c.Name,
Arguments: string(argsJSON),
})
}
}
return coretypes.CompletionResponse{
Content: content,
ToolCalls: toolCalls,
FinishReason: ar.StopReason,
Usage: coretypes.TokenUsage{
InputTokens: ar.Usage.InputTokens,
OutputTokens: ar.Usage.OutputTokens,
TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens,
},
}, nil
}