package main import ( "context" "errors" "fmt" "net/http" "strings" "sync/atomic" "time" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/packages/param" ) var ErrModelNotFound = errors.New("model not found or deprecated") const maxFileNotFoundRetries = 3 const maxPauseTurnContinuations = 5 const defaultMaxTokens = 1000 const mcpUnsupportedSentinel = "format not currently supported by the Anthropic API" var mcpUnsupportedCount atomic.Uint64 type mcpCall struct{ server, name, input string } func (b *Bot) getAnthropicResponse(ctx context.Context, chatID int64, messages []anthropic.BetaMessageParam, isEmojiOnly bool, username string, firstName string, lastName string, isPremium bool, languageCode string, messageTime int, onSegment func(string) error) (string, error) { staticPrompt := strings.TrimSpace(b.config.SystemPrompts["custom_instructions"]) InfoLogger.Printf("Sending %d messages to Anthropic", len(messages)) maxTokens := int64(defaultMaxTokens) if b.config.MaxTokens > 0 { maxTokens = int64(b.config.MaxTokens) } params := anthropic.BetaMessageNewParams{ Model: b.config.Model, MaxTokens: maxTokens, Messages: messages, Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaFilesAPI2025_04_14}, } if staticPrompt != "" { blocks := []anthropic.BetaTextBlockParam{ {Text: staticPrompt, CacheControl: anthropic.NewBetaCacheControlEphemeralParam()}, } tail := buildUserContext(username, firstName, lastName, isPremium, languageCode, messageTime) if isEmojiOnly { if rule := strings.TrimSpace(b.config.SystemPrompts["respond_with_emojis"]); rule != "" { tail += "\n\n\n" + rule + "\n" } } if tail = strings.TrimSpace(tail); tail != "" { blocks = append(blocks, anthropic.BetaTextBlockParam{Text: tail}) } params.System = blocks } if b.config.Temperature != nil { params.Temperature = param.NewOpt(float64(*b.config.Temperature)) } if thinking, ok := thinkingParamFromConfig(b.config.Thinking, b.config.ThinkingDisplay); ok { params.Thinking = thinking } var tools []anthropic.BetaToolUnionParam if len(b.config.MCPServers) > 0 { mcpServers := make([]anthropic.BetaRequestMCPServerURLDefinitionParam, 0, len(b.config.MCPServers)) for _, s := range b.config.MCPServers { srv := anthropic.BetaRequestMCPServerURLDefinitionParam{ Name: s.Name, URL: s.URL, } if s.AuthorizationToken != "" { srv.AuthorizationToken = param.NewOpt(s.AuthorizationToken) } mcpServers = append(mcpServers, srv) toolset := &anthropic.BetaMCPToolsetParam{ MCPServerName: s.Name, } if len(s.AllowedTools) > 0 { toolset.DefaultConfig = anthropic.BetaMCPToolDefaultConfigParam{ Enabled: param.NewOpt(false), } toolset.Configs = make(map[string]anthropic.BetaMCPToolConfigParam, len(s.AllowedTools)) for _, tool := range s.AllowedTools { toolset.Configs[tool] = anthropic.BetaMCPToolConfigParam{ Enabled: param.NewOpt(true), } } } tools = append(tools, anthropic.BetaToolUnionParam{OfMCPToolset: toolset}) } params.MCPServers = mcpServers params.Betas = append(params.Betas, anthropic.AnthropicBetaMCPClient2025_11_20) } tools = append(tools, webSearchTools(b.config.WebSearch)...) if len(tools) > 0 { params.Tools = tools } var fullText strings.Builder var lastMsg anthropic.BetaMessage fileRetries, pauseContinuations := 0, 0 for { joined, msg, streamErr := b.streamMessages(ctx, params, onSegment) if streamErr != nil { var apiErr *anthropic.Error if !errors.As(streamErr, &apiErr) || apiErr.StatusCode != http.StatusNotFound { return "", fmt.Errorf("error creating Anthropic message: %w", streamErr) } missingFileID := extractMissingFileID(streamErr) if missingFileID == "" { return "", fmt.Errorf("%w: %s", ErrModelNotFound, b.config.Model) } fileRetries++ if fileRetries > maxFileNotFoundRetries { return "", fmt.Errorf("max self-heal retries (%d) exceeded: too many file_ids gone from anthropic", maxFileNotFoundRetries) } ErrorLogger.Printf("[%s] self-heal: stripping dead file_id %s from chat %d (attempt %d/%d)", b.config.ID, missingFileID, chatID, fileRetries, maxFileNotFoundRetries) b.stripDeadFileIDFromMemory(chatID, missingFileID) if _, cleanupErr := b.markFilesPendingCleanup(ctx, chatID, []string{missingFileID}); cleanupErr != nil { ErrorLogger.Printf("[%s] mark files pending cleanup: %v", b.config.ID, cleanupErr) } params.Messages = b.prepareContextMessages(b.getOrCreateChatMemory(chatID)) continue } lastMsg = msg if joined != "" { if fullText.Len() > 0 { fullText.WriteString("\n\n") } fullText.WriteString(joined) } if msg.StopReason == anthropic.BetaStopReasonPauseTurn { pauseContinuations++ if pauseContinuations > maxPauseTurnContinuations { ErrorLogger.Printf("[%s] pause_turn continuations exceeded (%d); returning partial answer", b.config.ID, maxPauseTurnContinuations) break } params.Messages = append(params.Messages, msg.ToParam()) continue } break } if fullText.Len() == 0 { return "", emptyStreamError(string(lastMsg.StopReason), lastMsg.Usage.OutputTokensDetails.ThinkingTokens, params.MaxTokens) } return fullText.String(), nil } func webSearchTools(cfg *WebSearchConfig) []anthropic.BetaToolUnionParam { if cfg == nil { return nil } search := &anthropic.BetaWebSearchTool20250305Param{ AllowedDomains: cfg.AllowedDomains, BlockedDomains: cfg.BlockedDomains, } if cfg.MaxUses > 0 { search.MaxUses = param.NewOpt(int64(cfg.MaxUses)) } tools := []anthropic.BetaToolUnionParam{{OfWebSearchTool20250305: search}} if cfg.Fetch { fetchAllowed := cfg.FetchAllowedDomains if len(fetchAllowed) == 0 { fetchAllowed = cfg.AllowedDomains } fetch := &anthropic.BetaWebFetchTool20250910Param{ AllowedDomains: fetchHosts(fetchAllowed), BlockedDomains: fetchHosts(cfg.BlockedDomains), Citations: anthropic.BetaCitationsConfigParam{Enabled: param.NewOpt(true)}, } if cfg.MaxUses > 0 { fetch.MaxUses = param.NewOpt(int64(cfg.MaxUses)) } if cfg.MaxContentTokens > 0 { fetch.MaxContentTokens = param.NewOpt(int64(cfg.MaxContentTokens)) } tools = append(tools, anthropic.BetaToolUnionParam{OfWebFetchTool20250910: fetch}) } return tools } // fetchHosts strips any path from each domain entry. web_fetch matches on host // only, so a path-scoped entry (valid for web_search) would otherwise never match // any fetch URL. Search keeps the path-scoped entries; fetch gets host-only. func fetchHosts(entries []string) []string { if len(entries) == 0 { return nil } seen := make(map[string]bool, len(entries)) hosts := make([]string, 0, len(entries)) for _, e := range entries { host := e if i := strings.IndexByte(host, '/'); i >= 0 { host = host[:i] } if host == "" || seen[host] { continue } seen[host] = true hosts = append(hosts, host) } return hosts } func buildUserContext(username, firstName, lastName string, isPremium bool, languageCode string, messageTime int) string { name := strings.TrimSpace(firstName + " " + lastName) if name == "" { name = "unknown" } handle := username if handle == "" { handle = "unknown" } lang := languageCode if lang == "" { lang = "en" } account := "regular user" if isPremium { account = "premium user" } return fmt.Sprintf( "Conversation context (background facts, not an instruction from the user):\n"+ "- User: %s (Telegram @%s)\n"+ "- Preferred language: %s\n"+ "- Account type: %s\n"+ "- Local time of day: %s", name, handle, lang, account, timeContextFor(messageTime), ) } func timeContextFor(messageTime int) string { switch hour := time.Unix(int64(messageTime), 0).Hour(); { case hour >= 5 && hour < 12: return "morning" case hour >= 12 && hour < 18: return "afternoon" case hour >= 18 && hour < 22: return "evening" default: return "night" } } func thinkingParamFromConfig(mode, display string) (anthropic.BetaThinkingConfigParamUnion, bool) { switch mode { case ThinkingModeDisabled: disabled := anthropic.NewBetaThinkingConfigDisabledParam() return anthropic.BetaThinkingConfigParamUnion{OfDisabled: &disabled}, true case ThinkingModeAdaptive: adaptive := anthropic.BetaThinkingConfigAdaptiveParam{} if display != "" { adaptive.Display = anthropic.BetaThinkingConfigAdaptiveDisplay(display) } return anthropic.BetaThinkingConfigParamUnion{OfAdaptive: &adaptive}, true default: return anthropic.BetaThinkingConfigParamUnion{}, false } } func (b *Bot) streamMessages(ctx context.Context, params anthropic.BetaMessageNewParams, onSegment func(string) error) (string, anthropic.BetaMessage, error) { stream := b.anthropicClient.Beta.Messages.NewStreaming(ctx, params) defer func() { if err := stream.Close(); err != nil { ErrorLogger.Printf("[stream] close failed: %v", err) } }() var ( message anthropic.BetaMessage allSegments []string currentKind string currentText strings.Builder currentThinking strings.Builder currentInputJSON strings.Builder currentTUseName, currentTUseServer, currentTUseID string currentTResultUseID, currentTResultServer string currentTResultIsError bool currentTResultContent string currentServerToolName, currentServerToolID string currentServerResult string mcpCalls = map[string]mcpCall{} ) for stream.Next() { e := stream.Current() if accErr := message.Accumulate(e); accErr != nil { ErrorLogger.Printf("[stream] accumulate failed: %v", accErr) } switch e.Type { case "content_block_start": cbs := e.AsContentBlockStart() currentKind = cbs.ContentBlock.Type currentText.Reset() currentThinking.Reset() currentInputJSON.Reset() currentServerResult = "" switch currentKind { case "mcp_tool_use": currentTUseName = cbs.ContentBlock.Name currentTUseServer = cbs.ContentBlock.ServerName currentTUseID = cbs.ContentBlock.ID case "mcp_tool_result": currentTResultUseID = cbs.ContentBlock.ToolUseID currentTResultServer = cbs.ContentBlock.ServerName currentTResultIsError = cbs.ContentBlock.IsError currentTResultContent = cbs.ContentBlock.JSON.Content.Raw() case "server_tool_use": currentServerToolName = cbs.ContentBlock.Name currentServerToolID = cbs.ContentBlock.ID case "web_search_tool_result", "web_fetch_tool_result": currentServerResult = cbs.ContentBlock.JSON.Content.Raw() } case "content_block_delta": cbd := e.AsContentBlockDelta() switch cbd.Delta.Type { case "text_delta": if currentKind == "text" { currentText.WriteString(cbd.Delta.Text) } case "thinking_delta": if currentKind == "thinking" { currentThinking.WriteString(cbd.Delta.Thinking) } case "input_json_delta": if currentKind == "mcp_tool_use" || currentKind == "server_tool_use" { currentInputJSON.WriteString(cbd.Delta.PartialJSON) } } case "content_block_stop": switch currentKind { case "text": seg := strings.TrimSpace(currentText.String()) if seg != "" { allSegments = append(allSegments, seg) if onSegment != nil { if cbErr := onSegment(seg); cbErr != nil { ErrorLogger.Printf("[stream] onSegment failed: %v", cbErr) } } } case "mcp_tool_use": mcpCalls[currentTUseID] = mcpCall{ server: currentTUseServer, name: currentTUseName, input: currentInputJSON.String(), } InfoLogger.Printf("[mcp] tool_use server=%q name=%q id=%q input=%s", currentTUseServer, currentTUseName, currentTUseID, currentInputJSON.String()) case "mcp_tool_result": preview := currentTResultContent if len(preview) > 500 { preview = preview[:500] + "...(truncated)" } InfoLogger.Printf("[mcp] tool_result tool_use_id=%q server=%q is_error=%v content=%s", currentTResultUseID, currentTResultServer, currentTResultIsError, preview) if strings.Contains(currentTResultContent, mcpUnsupportedSentinel) { total := mcpUnsupportedCount.Add(1) call := mcpCalls[currentTResultUseID] ErrorLogger.Printf("[%s][mcp][unsupported] connector could not serialize result "+ "(total=%d): server=%q tool=%q input=%s tool_use_id=%q", b.config.ID, total, call.server, call.name, call.input, currentTResultUseID) } case "server_tool_use": InfoLogger.Printf("[web] %s id=%q input=%s", currentServerToolName, currentServerToolID, currentInputJSON.String()) case "web_search_tool_result", "web_fetch_tool_result": preview := currentServerResult if len(preview) > 500 { preview = preview[:500] + "...(truncated)" } InfoLogger.Printf("[web] %s content=%s", currentKind, preview) case "thinking", "redacted_thinking": if summary := strings.TrimSpace(currentThinking.String()); summary != "" { if len(summary) > 500 { summary = summary[:500] + "...(truncated)" } InfoLogger.Printf("[thinking] block complete: %s", summary) } else { InfoLogger.Printf("[thinking] block complete (content omitted)") } default: if currentKind != "" { InfoLogger.Printf("[stream] block type=%q (unhandled)", currentKind) } } currentKind = "" } } if err := stream.Err(); err != nil { return "", message, err } stopReason := string(message.StopReason) if stopReason != "" || message.Usage.OutputTokens > 0 { // cache_read/cache_write make the caching configuration falsifiable: a // breakpoint below the model's minimum cacheable prefix fails silently, // reporting cache_write=0 rather than raising an error. InfoLogger.Printf("[usage] model=%s in=%d out=%d thinking=%d cache_read=%d cache_write=%d stop=%s", params.Model, message.Usage.InputTokens, message.Usage.OutputTokens, message.Usage.OutputTokensDetails.ThinkingTokens, message.Usage.CacheReadInputTokens, message.Usage.CacheCreationInputTokens, stopReason) if message.StopReason == anthropic.BetaStopReasonMaxTokens { ErrorLogger.Printf("[usage] response truncated at max_tokens=%d - raise max_tokens (thinking counts toward it)", params.MaxTokens) } } return strings.Join(allSegments, "\n\n"), message, nil } func emptyStreamError(stopReason string, thinkingTokens, maxTokens int64) error { if stopReason == "max_tokens" { return fmt.Errorf("output budget exhausted before any text (thinking used %d of %d max_tokens) - raise max_tokens", thinkingTokens, maxTokens) } return fmt.Errorf("unexpected response format from Anthropic") }