package rest // ai.go holds the HTTP handlers, feature-flag helpers, and exported DTOs // for the /api/v1/ai/* endpoints. It is intentionally kept small; the // heavy lifting lives in: // // ai_context.go - buildAIContext + indexed DB queries // ai_routes.go - live aggregator route lookup + match scoring // ai_docs.go - workspace doc scraping // ai_xai.go - upstream xAI/OpenAI client (types + calls) // ai_helpers.go - pure-function utilities shared across the above // ai_runtime.go - rate limiting + metrics + audit log (pre-existing) import ( "encoding/json" "fmt" "net/http" "os" "strings" "time" ) const ( defaultExplorerAIModel = "grok-3" maxExplorerAIMessages = 12 maxExplorerAIMessageChars = 4000 maxExplorerAIContextChars = 22000 maxExplorerAIDocSnippets = 6 ) type AIChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type AIChatRequest struct { Messages []AIChatMessage `json:"messages"` PageContext map[string]string `json:"pageContext,omitempty"` } type AIContextResponse struct { Enabled bool `json:"enabled"` Query string `json:"query,omitempty"` GeneratedAt string `json:"generatedAt"` Model string `json:"model"` Context AIContextEnvelope `json:"context"` Warnings []string `json:"warnings,omitempty"` } type AIChatResponse struct { Reply string `json:"reply"` Model string `json:"model"` GeneratedAt string `json:"generatedAt"` Context AIContextEnvelope `json:"context"` Warnings []string `json:"warnings,omitempty"` } type AIContextEnvelope struct { ChainID int `json:"chainId"` Explorer string `json:"explorer"` PageContext map[string]string `json:"pageContext,omitempty"` Stats map[string]any `json:"stats,omitempty"` Address map[string]any `json:"address,omitempty"` Transaction map[string]any `json:"transaction,omitempty"` Block map[string]any `json:"block,omitempty"` RouteMatches []map[string]any `json:"routeMatches,omitempty"` DocSnippets []AIDocSnippet `json:"docSnippets,omitempty"` CapabilityNotice string `json:"capabilityNotice"` Sources []AIContextSource `json:"sources,omitempty"` } type AIDocSnippet struct { Path string `json:"path"` Line int `json:"line"` Snippet string `json:"snippet"` } type AIContextSource struct { Type string `json:"type"` Label string `json:"label"` Origin string `json:"origin,omitempty"` } func (s *Server) handleAIContext(w http.ResponseWriter, r *http.Request) { startedAt := time.Now() clientIP := clientIPAddress(r) if r.Method != http.MethodGet { writeMethodNotAllowed(w) return } if allowed, retryAfter := s.allowAIRequest(r, "context"); !allowed { w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds())) s.aiMetrics.Record("context", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP) s.logAIRequest("context", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited") writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai context rate limit exceeded", "please retry shortly") return } query := strings.TrimSpace(r.URL.Query().Get("q")) pageContext := map[string]string{ "path": strings.TrimSpace(r.URL.Query().Get("path")), "view": strings.TrimSpace(r.URL.Query().Get("view")), } ctxEnvelope, warnings := s.buildAIContext(r.Context(), query, pageContext) response := AIContextResponse{ Enabled: explorerAIEnabled(), Query: query, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Model: explorerAIModel(), Context: ctxEnvelope, Warnings: warnings, } s.aiMetrics.Record("context", http.StatusOK, time.Since(startedAt), "", clientIP) s.logAIRequest("context", http.StatusOK, time.Since(startedAt), clientIP, explorerAIModel(), "") writeJSON(w, http.StatusOK, response) } func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { startedAt := time.Now() clientIP := clientIPAddress(r) if r.Method != http.MethodPost { writeMethodNotAllowed(w) return } if allowed, retryAfter := s.allowAIRequest(r, "chat"); !allowed { w.Header().Set("Retry-After", fmt.Sprintf("%.0f", retryAfter.Seconds())) s.aiMetrics.Record("chat", http.StatusTooManyRequests, time.Since(startedAt), "rate_limited", clientIP) s.logAIRequest("chat", http.StatusTooManyRequests, time.Since(startedAt), clientIP, explorerAIModel(), "rate_limited") writeErrorDetailed(w, http.StatusTooManyRequests, "rate_limited", "explorer ai chat rate limit exceeded", "please retry shortly") return } if !explorerAIEnabled() { s.aiMetrics.Record("chat", http.StatusServiceUnavailable, time.Since(startedAt), "service_unavailable", clientIP) s.logAIRequest("chat", http.StatusServiceUnavailable, time.Since(startedAt), clientIP, explorerAIModel(), "service_unavailable") writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer ai is not configured; set XAI_API_KEY on the backend") return } defer r.Body.Close() body := http.MaxBytesReader(w, r.Body, 1<<20) var chatReq AIChatRequest if err := json.NewDecoder(body).Decode(&chatReq); err != nil { writeError(w, http.StatusBadRequest, "bad_request", "invalid ai chat payload") return } messages := normalizeAIMessages(chatReq.Messages) if len(messages) == 0 { writeError(w, http.StatusBadRequest, "bad_request", "at least one non-empty ai message is required") return } latestUser := latestUserMessage(messages) ctxEnvelope, warnings := s.buildAIContext(r.Context(), latestUser, chatReq.PageContext) reply, model, err := s.callXAIChatCompletions(r.Context(), messages, ctxEnvelope) if err != nil { statusCode, code, message, details := mapAIUpstreamError(err) s.aiMetrics.Record("chat", statusCode, time.Since(startedAt), code, clientIP) s.logAIRequest("chat", statusCode, time.Since(startedAt), clientIP, model, code) writeErrorDetailed(w, statusCode, code, message, details) return } response := AIChatResponse{ Reply: reply, Model: model, GeneratedAt: time.Now().UTC().Format(time.RFC3339), Context: ctxEnvelope, Warnings: warnings, } s.aiMetrics.Record("chat", http.StatusOK, time.Since(startedAt), "", clientIP) s.logAIRequest("chat", http.StatusOK, time.Since(startedAt), clientIP, model, "") writeJSON(w, http.StatusOK, response) } func explorerAIEnabled() bool { return strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" } // explorerAIOperatorToolsEnabled allows the model to discuss server-side operator/MCP automation (default off). func explorerAIOperatorToolsEnabled() bool { return strings.TrimSpace(os.Getenv("EXPLORER_AI_OPERATOR_TOOLS_ENABLED")) == "1" } func explorerAIModel() string { if model := strings.TrimSpace(os.Getenv("XAI_MODEL")); model != "" { return model } if model := strings.TrimSpace(os.Getenv("EXPLORER_AI_MODEL")); model != "" { return model } return defaultExplorerAIModel }