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:
co-authored by
Claude Sonnet 4.6
parent
5c81939bbf
commit
4f309ad9d5
@@ -675,9 +675,58 @@ func coolifyAppAction(configIDInt int, uuid, action string) (interface{}, error)
|
|||||||
return coolifyCall("GET", "/applications/"+uuid+"/"+action, nil, cfg)
|
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 ──────────────────────────────────────
|
// ─── 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) {
|
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
|
baseURL := ai.BaseURL
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = providerDefaultURL(ai.Provider)
|
baseURL = providerDefaultURL(ai.Provider)
|
||||||
@@ -722,13 +771,167 @@ func callAI(ai *models.AiConfig, messages []agentMessage, tools []agentTool) (*a
|
|||||||
return &msg, nil
|
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 {
|
func providerDefaultURL(provider string) string {
|
||||||
switch strings.ToLower(provider) {
|
switch strings.ToLower(provider) {
|
||||||
case "openai":
|
case "openai":
|
||||||
return "https://api.openai.com/v1"
|
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":
|
case "qwen", "dashscope":
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
case "groq":
|
case "groq":
|
||||||
|
|||||||
Reference in New Issue
Block a user