feat: soporte nativo Anthropic en el agente (tool use format)

Agrega callAnthropicAI() que convierte mensajes y herramientas al formato
nativo de Anthropic Messages API (tool_use / tool_result), con conversión
de vuelta al formato interno OpenAI para mantener el historial unificado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro GD
2026-07-13 19:16:11 +00:00
co-authored by Claude Sonnet 4.6
parent 5c81939bbf
commit 4f309ad9d5
+206 -3
View File
@@ -675,9 +675,58 @@ func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error)
return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg)
}
// ─── Tipos Anthropic ─────────────────────────────────────────────────────────
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema agentToolParam `json:"input_schema"`
}
type anthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
}
type anthropicMsg struct {
Role string `json:"role"`
Content interface{} `json:"content"` // string o []anthropicContentBlock
}
type anthropicReq struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []anthropicMsg `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
}
type anthropicResp struct {
Content []anthropicContentBlock `json:"content"`
StopReason string `json:"stop_reason"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
// ─── Llamada al AI con function calling ──────────────────────────────────────
// callAI despacha al provider correcto (Anthropic o OpenAI-compatible).
func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
if strings.ToLower(ai.Provider) == "anthropic" {
return callAnthropicAI(ai, messages, tools)
}
return callOpenAICompatibleAI(ai, messages, tools)
}
// callOpenAICompatibleAI usa el formato de OpenAI (también vale para qwen, groq, deepseek, etc.)
func callOpenAICompatibleAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
baseURL := ai.BaseURL
if baseURL == "" {
baseURL = providerDefaultURL(ai.Provider)
@@ -722,13 +771,167 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a
return &msg, nil
}
// callAnthropicAI llama a la API nativa de Anthropic con tool use.
func callAnthropicAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*agentMessage, error) {
// Convertir herramientas al formato Anthropic
anthropicTools := make([]anthropicTool, len(tools))
for i, t := range tools {
anthropicTools[i] = anthropicTool{
Name: t.Function.Name,
Description: t.Function.Description,
InputSchema: t.Function.Parameters,
}
}
// Extraer system prompt y convertir mensajes
var systemPrompt string
var anthropicMsgs []anthropicMsg
for _, m := range messages {
switch m.Role {
case "system":
if s, ok := m.Content.(string); ok {
systemPrompt = s
}
case "user":
content := ""
if s, ok := m.Content.(string); ok {
content = s
}
// Si el último mensaje ya es user, agregar tool_result como bloque adicional
if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" {
last := anthropicMsgs[len(anthropicMsgs)-1]
if blocks, ok := last.Content.([]anthropicContentBlock); ok {
anthropicMsgs[len(anthropicMsgs)-1].Content = append(blocks, anthropicContentBlock{
Type: "text",
Text: content,
})
continue
}
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "user", Content: content})
case "assistant":
if len(m.ToolCalls) > 0 {
// Convertir tool_calls a bloques tool_use
var blocks []anthropicContentBlock
if s, ok := m.Content.(string); ok && s != "" {
blocks = append(blocks, anthropicContentBlock{Type: "text", Text: s})
}
for _, tc := range m.ToolCalls {
blocks = append(blocks, anthropicContentBlock{
Type: "tool_use",
ID: tc.ID,
Name: tc.Function.Name,
Input: json.RawMessage(tc.Function.Arguments),
})
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: blocks})
} else {
content := ""
if s, ok := m.Content.(string); ok {
content = s
}
anthropicMsgs = append(anthropicMsgs, anthropicMsg{Role: "assistant", Content: content})
}
case "tool":
// Los resultados de tool deben ir en un mensaje de usuario con tipo tool_result
block := anthropicContentBlock{
Type: "tool_result",
ToolUseID: m.ToolCallID,
Content: fmt.Sprintf("%v", m.Content),
}
// Agrupar en el último mensaje user si existe, o crear uno nuevo
if len(anthropicMsgs) > 0 && anthropicMsgs[len(anthropicMsgs)-1].Role == "user" {
last := anthropicMsgs[len(anthropicMsgs)-1]
switch c := last.Content.(type) {
case []anthropicContentBlock:
anthropicMsgs[len(anthropicMsgs)-1].Content = append(c, block)
default:
anthropicMsgs[len(anthropicMsgs)-1].Content = []anthropicContentBlock{block}
}
} else {
anthropicMsgs = append(anthropicMsgs, anthropicMsg{
Role: "user",
Content: []anthropicContentBlock{block},
})
}
}
}
reqBody := anthropicReq{
Model: ai.ModelName,
MaxTokens: 4096,
System: systemPrompt,
Messages: anthropicMsgs,
Tools: anthropicTools,
}
payload, _ := json.Marshal(reqBody)
req, err := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", ai.ApiKey)
req.Header.Set("anthropic-version", "2023-06-01")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
var anthropicRsp anthropicResp
if err := json.Unmarshal(raw, &anthropicRsp); err != nil {
return nil, fmt.Errorf("respuesta inesperada de Anthropic: %s", string(raw[:min(200, len(raw))]))
}
if anthropicRsp.Error != nil {
return nil, fmt.Errorf("error de Anthropic: %s", anthropicRsp.Error.Message)
}
// Convertir respuesta Anthropic → agentMessage (formato interno OpenAI)
result := &agentMessage{Role: "assistant"}
var textParts []string
var toolCalls []agentToolCall
for _, block := range anthropicRsp.Content {
switch block.Type {
case "text":
if block.Text != "" {
textParts = append(textParts, block.Text)
}
case "tool_use":
inputJSON := "{}"
if block.Input != nil {
inputJSON = string(block.Input)
}
toolCalls = append(toolCalls, agentToolCall{
ID: block.ID,
Type: "function",
Function: struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}{Name: block.Name, Arguments: inputJSON},
})
}
}
if len(textParts) > 0 {
result.Content = strings.Join(textParts, "\n")
}
result.ToolCalls = toolCalls
return result, nil
}
func providerDefaultURL(provider string) string {
switch strings.ToLower(provider) {
case "openai":
return "https://api.openai.com/v1"
case "anthropic":
// Anthropic usa un formato diferente; para compatibilidad usar proxy OpenAI-compatible
return "https://api.openai.com/v1"
case "qwen", "dashscope":
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "groq":