- Implement 'Show Problems Only' toggle combining degraded status, high CPU/memory alerts, and needs backup filters - Add 'Investigate with AI' button to filter bar for problematic guests - Fix dashboard column sizing inconsistencies between bars and sparklines view modes - Fix PBS backups display and polling - Refine AI prompt for general-purpose usage - Fix frontend flickering and reload loops during initial load - Integrate persistent SQLite metrics store with Monitor - Fortify AI command routing with improved validation and logging - Fix CSRF token handling for note deletion - Debug and fix AI command execution issues - Various AI reliability improvements and command safety enhancements
340 lines
9.7 KiB
Go
340 lines
9.7 KiB
Go
package providers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const (
|
|
anthropicAPIURL = "https://api.anthropic.com/v1/messages"
|
|
anthropicAPIVersion = "2023-06-01"
|
|
maxRetries = 3
|
|
initialBackoff = 2 * time.Second
|
|
)
|
|
|
|
// AnthropicClient implements the Provider interface for Anthropic's Claude API
|
|
type AnthropicClient struct {
|
|
apiKey string
|
|
model string
|
|
client *http.Client
|
|
}
|
|
|
|
// NewAnthropicClient creates a new Anthropic API client
|
|
func NewAnthropicClient(apiKey, model string) *AnthropicClient {
|
|
return &AnthropicClient{
|
|
apiKey: apiKey,
|
|
model: model,
|
|
client: &http.Client{
|
|
// 5 minutes - Opus and other large models can take a very long time
|
|
Timeout: 300 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Name returns the provider name
|
|
func (c *AnthropicClient) Name() string {
|
|
return "anthropic"
|
|
}
|
|
|
|
// anthropicRequest is the request body for the Anthropic API
|
|
type anthropicRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []anthropicMessage `json:"messages"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
System string `json:"system,omitempty"`
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
Tools []anthropicTool `json:"tools,omitempty"`
|
|
}
|
|
|
|
type anthropicMessage struct {
|
|
Role string `json:"role"`
|
|
Content interface{} `json:"content"` // Can be string or []anthropicContent
|
|
}
|
|
|
|
// anthropicTool represents a regular function tool
|
|
type anthropicTool struct {
|
|
Type string `json:"type,omitempty"` // "web_search_20250305" for web search, omit for regular tools
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"` // Not used for web search
|
|
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // Not used for web search
|
|
MaxUses int `json:"max_uses,omitempty"` // For web search: limit searches per request
|
|
}
|
|
|
|
// anthropicResponse is the response from the Anthropic API
|
|
type anthropicResponse struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Content []anthropicContent `json:"content"`
|
|
Model string `json:"model"`
|
|
StopReason string `json:"stop_reason"`
|
|
StopSequence string `json:"stop_sequence,omitempty"`
|
|
Usage anthropicUsage `json:"usage"`
|
|
}
|
|
|
|
type anthropicContent struct {
|
|
Type string `json:"type"` // "text", "tool_use", "tool_result", "server_tool_use", "web_search_tool_result"
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"` // For tool_use
|
|
Name string `json:"name,omitempty"` // For tool_use
|
|
Input map[string]interface{} `json:"input,omitempty"` // For tool_use
|
|
ToolUseID string `json:"tool_use_id,omitempty"` // For tool_result
|
|
Content json.RawMessage `json:"content,omitempty"` // Can be string or array (for web_search_tool_result)
|
|
IsError bool `json:"is_error,omitempty"` // For tool_result
|
|
}
|
|
|
|
type anthropicUsage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
}
|
|
|
|
type anthropicError struct {
|
|
Type string `json:"type"`
|
|
Error anthropicErrorDetail `json:"error"`
|
|
}
|
|
|
|
type anthropicErrorDetail struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// Chat sends a chat request to the Anthropic API
|
|
func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
|
|
// Convert messages to Anthropic format
|
|
messages := make([]anthropicMessage, 0, len(req.Messages))
|
|
for _, m := range req.Messages {
|
|
// Anthropic doesn't use "system" role in messages array
|
|
if m.Role == "system" {
|
|
continue
|
|
}
|
|
|
|
// Handle tool results specially
|
|
if m.ToolResult != nil {
|
|
// Tool result message - Content needs to be JSON-encoded string
|
|
contentJSON, _ := json.Marshal(m.ToolResult.Content)
|
|
messages = append(messages, anthropicMessage{
|
|
Role: "user",
|
|
Content: []anthropicContent{
|
|
{
|
|
Type: "tool_result",
|
|
ToolUseID: m.ToolResult.ToolUseID,
|
|
Content: contentJSON,
|
|
IsError: m.ToolResult.IsError,
|
|
},
|
|
},
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Handle assistant messages with tool calls
|
|
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
|
contentBlocks := make([]anthropicContent, 0)
|
|
if m.Content != "" {
|
|
contentBlocks = append(contentBlocks, anthropicContent{
|
|
Type: "text",
|
|
Text: m.Content,
|
|
})
|
|
}
|
|
for _, tc := range m.ToolCalls {
|
|
contentBlocks = append(contentBlocks, anthropicContent{
|
|
Type: "tool_use",
|
|
ID: tc.ID,
|
|
Name: tc.Name,
|
|
Input: tc.Input,
|
|
})
|
|
}
|
|
messages = append(messages, anthropicMessage{
|
|
Role: "assistant",
|
|
Content: contentBlocks,
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Simple text message
|
|
messages = append(messages, anthropicMessage{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
})
|
|
}
|
|
|
|
// Use provided model or fall back to client default
|
|
model := req.Model
|
|
if model == "" {
|
|
model = c.model
|
|
}
|
|
|
|
// Set max tokens (Anthropic requires this)
|
|
maxTokens := req.MaxTokens
|
|
if maxTokens == 0 {
|
|
maxTokens = 4096
|
|
}
|
|
|
|
anthropicReq := anthropicRequest{
|
|
Model: model,
|
|
Messages: messages,
|
|
MaxTokens: maxTokens,
|
|
System: req.System,
|
|
}
|
|
|
|
if req.Temperature > 0 {
|
|
anthropicReq.Temperature = req.Temperature
|
|
}
|
|
|
|
// Add tools if provided
|
|
if len(req.Tools) > 0 {
|
|
anthropicReq.Tools = make([]anthropicTool, len(req.Tools))
|
|
for i, t := range req.Tools {
|
|
if t.Type == "web_search_20250305" {
|
|
// Web search tool has a special format
|
|
anthropicReq.Tools[i] = anthropicTool{
|
|
Type: t.Type,
|
|
Name: t.Name,
|
|
MaxUses: t.MaxUses,
|
|
}
|
|
} else {
|
|
// Regular function tool
|
|
anthropicReq.Tools[i] = anthropicTool{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
InputSchema: t.InputSchema,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
body, err := json.Marshal(anthropicReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
// Retry loop for transient errors (429, 529, 5xx)
|
|
var respBody []byte
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
// Exponential backoff: 2s, 4s, 8s
|
|
backoff := initialBackoff * time.Duration(1<<(attempt-1))
|
|
log.Warn().
|
|
Int("attempt", attempt).
|
|
Dur("backoff", backoff).
|
|
Str("last_error", lastErr.Error()).
|
|
Msg("Retrying Anthropic API request after transient error")
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("x-api-key", c.apiKey)
|
|
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
|
|
|
resp, err := c.client.Do(httpReq)
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("request failed: %w", err)
|
|
continue
|
|
}
|
|
|
|
respBody, err = io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
lastErr = fmt.Errorf("failed to read response: %w", err)
|
|
continue
|
|
}
|
|
|
|
// Check if this is a retryable error
|
|
if resp.StatusCode == 429 || resp.StatusCode == 529 || resp.StatusCode >= 500 {
|
|
var errResp anthropicError
|
|
errMsg := string(respBody)
|
|
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
|
|
errMsg = errResp.Error.Message
|
|
}
|
|
lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, errMsg)
|
|
continue
|
|
}
|
|
|
|
// Non-retryable error
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp anthropicError
|
|
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" {
|
|
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error.Message)
|
|
}
|
|
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
// Success - break out of retry loop
|
|
lastErr = nil
|
|
break
|
|
}
|
|
|
|
if lastErr != nil {
|
|
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
|
|
}
|
|
|
|
var anthropicResp anthropicResponse
|
|
if err := json.Unmarshal(respBody, &anthropicResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
// Extract content and tool calls from response
|
|
var textContent string
|
|
var toolCalls []ToolCall
|
|
for _, c := range anthropicResp.Content {
|
|
switch c.Type {
|
|
case "text":
|
|
textContent += c.Text
|
|
case "tool_use":
|
|
// Regular tool use - we need to execute these
|
|
toolCalls = append(toolCalls, ToolCall{
|
|
ID: c.ID,
|
|
Name: c.Name,
|
|
Input: c.Input,
|
|
})
|
|
case "server_tool_use":
|
|
// Server-side tool (like web_search) - Anthropic handles these automatically
|
|
// We just log it for debugging, no action needed
|
|
log.Debug().
|
|
Str("tool_name", c.Name).
|
|
Msg("Server tool use detected (handled by Anthropic)")
|
|
case "web_search_tool_result":
|
|
// Results from web search - already incorporated into Claude's response
|
|
log.Debug().Msg("Web search results received")
|
|
}
|
|
}
|
|
|
|
return &ChatResponse{
|
|
Content: textContent,
|
|
Model: anthropicResp.Model,
|
|
StopReason: anthropicResp.StopReason,
|
|
ToolCalls: toolCalls,
|
|
InputTokens: anthropicResp.Usage.InputTokens,
|
|
OutputTokens: anthropicResp.Usage.OutputTokens,
|
|
}, nil
|
|
}
|
|
|
|
// TestConnection validates the API key by making a minimal request
|
|
func (c *AnthropicClient) TestConnection(ctx context.Context) error {
|
|
// Make a minimal request to validate the API key
|
|
_, err := c.Chat(ctx, ChatRequest{
|
|
Messages: []Message{
|
|
{Role: "user", Content: "Hi"},
|
|
},
|
|
MaxTokens: 10, // Minimal tokens to save cost
|
|
})
|
|
return err
|
|
}
|