package rest import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "time" ) type xAIChatCompletionsRequest struct { Model string `json:"model"` Messages []xAIChatMessageReq `json:"messages"` Stream bool `json:"stream"` } type xAIChatMessageReq struct { Role string `json:"role"` Content string `json:"content"` } type xAIChatCompletionsResponse struct { Model string `json:"model"` Choices []xAIChoice `json:"choices"` OutputText string `json:"output_text,omitempty"` Output []openAIOutputItem `json:"output,omitempty"` } type xAIChoice struct { Message xAIChoiceMessage `json:"message"` } type xAIChoiceMessage struct { Role string `json:"role"` Content string `json:"content"` } type openAIOutputItem struct { Type string `json:"type"` Content []openAIOutputContent `json:"content"` } type openAIOutputContent struct { Type string `json:"type"` Text string `json:"text"` } func normalizeAIMessages(messages []AIChatMessage) []AIChatMessage { normalized := make([]AIChatMessage, 0, len(messages)) for _, message := range messages { role := strings.ToLower(strings.TrimSpace(message.Role)) if role != "assistant" && role != "user" && role != "system" { continue } content := clipString(strings.TrimSpace(message.Content), maxExplorerAIMessageChars) if content == "" { continue } normalized = append(normalized, AIChatMessage{ Role: role, Content: content, }) } if len(normalized) > maxExplorerAIMessages { normalized = normalized[len(normalized)-maxExplorerAIMessages:] } return normalized } func latestUserMessage(messages []AIChatMessage) string { for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { return messages[i].Content } } if len(messages) == 0 { return "" } return messages[len(messages)-1].Content } func (s *Server) callXAIChatCompletions(ctx context.Context, messages []AIChatMessage, contextEnvelope AIContextEnvelope) (string, string, error) { apiKey := strings.TrimSpace(os.Getenv("XAI_API_KEY")) if apiKey == "" { return "", "", fmt.Errorf("XAI_API_KEY is not configured") } model := explorerAIModel() baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("XAI_BASE_URL")), "/") if baseURL == "" { baseURL = "https://api.x.ai/v1" } contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ") contextText := clipString(string(contextJSON), maxExplorerAIContextChars) baseSystem := "You are the SolaceScan ecosystem assistant for Chain 138. Answer using the supplied indexed explorer data, route inventory, and workspace documentation. Be concise, operationally useful, and explicit about uncertainty. Never claim a route, deployment, or production status is live unless the provided context says it is live. If data is missing, say exactly what is missing." if !explorerAIOperatorToolsEnabled() { baseSystem += " Never instruct users to paste private keys or seed phrases. Do not direct users to run privileged mint, liquidity, or bridge execution from the public explorer UI. Operator changes belong on LAN-gated workflows and authenticated Track 4 APIs; PMM/MCP-style execution tools are disabled on this deployment unless EXPLORER_AI_OPERATOR_TOOLS_ENABLED=1." } input := []xAIChatMessageReq{ { Role: "system", Content: baseSystem, }, { Role: "system", Content: "Retrieved ecosystem context:\n" + contextText, }, } for _, message := range messages { input = append(input, xAIChatMessageReq{ Role: message.Role, Content: message.Content, }) } payload := xAIChatCompletionsRequest{ Model: model, Messages: input, Stream: false, } body, err := json.Marshal(payload) if err != nil { return "", model, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body)) if err != nil { return "", model, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return "", model, &AIUpstreamError{ StatusCode: http.StatusGatewayTimeout, Code: "upstream_timeout", Message: "explorer ai upstream timed out", Details: "xAI request exceeded the configured timeout", } } return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_transport_error", Message: "explorer ai upstream transport failed", Details: err.Error(), } } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) if err != nil { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream body could not be read", Details: err.Error(), } } if resp.StatusCode >= 400 { return "", model, parseXAIError(resp.StatusCode, responseBody) } var response xAIChatCompletionsResponse if err := json.Unmarshal(responseBody, &response); err != nil { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream returned invalid JSON", Details: err.Error(), } } reply := "" if len(response.Choices) > 0 { reply = strings.TrimSpace(response.Choices[0].Message.Content) } if reply == "" { reply = strings.TrimSpace(response.OutputText) } if reply == "" { reply = strings.TrimSpace(extractOutputText(response.Output)) } if reply == "" { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream returned no output text", Details: "xAI response did not include choices[0].message.content or output text", } } if strings.TrimSpace(response.Model) != "" { model = response.Model } return reply, model, nil } func parseXAIError(statusCode int, responseBody []byte) error { var parsed struct { Error struct { Message string `json:"message"` Type string `json:"type"` Code string `json:"code"` } `json:"error"` } _ = json.Unmarshal(responseBody, &parsed) details := clipString(strings.TrimSpace(parsed.Error.Message), 280) if details == "" { details = clipString(strings.TrimSpace(string(responseBody)), 280) } switch statusCode { case http.StatusUnauthorized, http.StatusForbidden: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_auth_failed", Message: "explorer ai upstream authentication failed", Details: details, } case http.StatusTooManyRequests: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_quota_exhausted", Message: "explorer ai upstream quota exhausted", Details: details, } case http.StatusRequestTimeout, http.StatusGatewayTimeout: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_timeout", Message: "explorer ai upstream timed out", Details: details, } default: return &AIUpstreamError{ StatusCode: statusCode, Code: "upstream_error", Message: "explorer ai upstream request failed", Details: details, } } } func extractOutputText(items []openAIOutputItem) string { parts := []string{} for _, item := range items { for _, content := range item.Content { if strings.TrimSpace(content.Text) != "" { parts = append(parts, strings.TrimSpace(content.Text)) } } } return strings.Join(parts, "\n\n") }