diff --git a/backend/api/rest/ai.go b/backend/api/rest/ai.go index 8f75c23..5860ca3 100644 --- a/backend/api/rest/ai.go +++ b/backend/api/rest/ai.go @@ -18,12 +18,11 @@ import ( ) const ( - defaultExplorerAIModel = "gpt-5.4-mini" - defaultExplorerAIReasoningEffort = "low" - maxExplorerAIMessages = 12 - maxExplorerAIMessageChars = 4000 - maxExplorerAIContextChars = 22000 - maxExplorerAIDocSnippets = 6 + defaultExplorerAIModel = "grok-3" + maxExplorerAIMessages = 12 + maxExplorerAIMessageChars = 4000 + maxExplorerAIContextChars = 22000 + maxExplorerAIDocSnippets = 6 ) var ( @@ -85,30 +84,31 @@ type AIContextSource struct { Origin string `json:"origin,omitempty"` } -type openAIResponsesRequest struct { - Model string `json:"model"` - Input []openAIInputMessage `json:"input"` - Reasoning *openAIReasoning `json:"reasoning,omitempty"` +type xAIChatCompletionsRequest struct { + Model string `json:"model"` + Messages []xAIChatMessageReq `json:"messages"` + Stream bool `json:"stream"` } -type openAIReasoning struct { - Effort string `json:"effort,omitempty"` +type xAIChatMessageReq struct { + Role string `json:"role"` + Content string `json:"content"` } -type openAIInputMessage struct { - Role string `json:"role"` - Content []openAIInputContent `json:"content"` -} - -type openAIInputContent struct { - Type string `json:"type"` - Text string `json:"text"` -} - -type openAIResponsesResponse struct { +type xAIChatCompletionsResponse struct { Model string `json:"model"` - OutputText string `json:"output_text"` - Output []openAIOutputItem `json:"output"` + 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 { @@ -174,7 +174,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { 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 OPENAI_API_KEY on the backend") + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer ai is not configured; set XAI_API_KEY on the backend") return } @@ -196,7 +196,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { latestUser := latestUserMessage(messages) ctxEnvelope, warnings := s.buildAIContext(r.Context(), latestUser, chatReq.PageContext) - reply, model, err := s.callOpenAIResponses(r.Context(), messages, ctxEnvelope) + 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) @@ -219,11 +219,11 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request) { } func explorerAIEnabled() bool { - return strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" + return strings.TrimSpace(os.Getenv("XAI_API_KEY")) != "" } func explorerAIModel() string { - if model := strings.TrimSpace(os.Getenv("OPENAI_MODEL")); model != "" { + if model := strings.TrimSpace(os.Getenv("XAI_MODEL")); model != "" { return model } if model := strings.TrimSpace(os.Getenv("EXPLORER_AI_MODEL")); model != "" { @@ -232,16 +232,6 @@ func explorerAIModel() string { return defaultExplorerAIModel } -func explorerAIReasoningEffort() string { - if effort := strings.TrimSpace(os.Getenv("OPENAI_REASONING_EFFORT")); effort != "" { - return effort - } - if effort := strings.TrimSpace(os.Getenv("EXPLORER_AI_REASONING_EFFORT")); effort != "" { - return effort - } - return defaultExplorerAIReasoningEffort -} - func (s *Server) buildAIContext(ctx context.Context, query string, pageContext map[string]string) (AIContextEnvelope, []string) { warnings := []string{} envelope := AIContextEnvelope{ @@ -879,60 +869,43 @@ func latestUserMessage(messages []AIChatMessage) string { return messages[len(messages)-1].Content } -func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessage, contextEnvelope AIContextEnvelope) (string, string, error) { - apiKey := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) +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("OPENAI_API_KEY is not configured") + return "", "", fmt.Errorf("XAI_API_KEY is not configured") } model := explorerAIModel() - baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/") + baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("XAI_BASE_URL")), "/") if baseURL == "" { - baseURL = "https://api.openai.com/v1" + baseURL = "https://api.x.ai/v1" } contextJSON, _ := json.MarshalIndent(contextEnvelope, "", " ") contextText := clipString(string(contextJSON), maxExplorerAIContextChars) - input := []openAIInputMessage{ + input := []xAIChatMessageReq{ { - Role: "system", - Content: []openAIInputContent{ - { - Type: "input_text", - Text: "You are the SolaceScanScout 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.", - }, - }, + Role: "system", + Content: "You are the SolaceScanScout 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.", }, { - Role: "system", - Content: []openAIInputContent{ - { - Type: "input_text", - Text: "Retrieved ecosystem context:\n" + contextText, - }, - }, + Role: "system", + Content: "Retrieved ecosystem context:\n" + contextText, }, } for _, message := range messages { - input = append(input, openAIInputMessage{ - Role: message.Role, - Content: []openAIInputContent{ - { - Type: "input_text", - Text: message.Content, - }, - }, + input = append(input, xAIChatMessageReq{ + Role: message.Role, + Content: message.Content, }) } - payload := openAIResponsesRequest{ - Model: model, - Input: input, - Reasoning: &openAIReasoning{ - Effort: explorerAIReasoningEffort(), - }, + payload := xAIChatCompletionsRequest{ + Model: model, + Messages: input, + Stream: false, } body, err := json.Marshal(payload) @@ -940,7 +913,7 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa return "", model, err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/responses", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body)) if err != nil { return "", model, err } @@ -955,7 +928,7 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa StatusCode: http.StatusGatewayTimeout, Code: "upstream_timeout", Message: "explorer ai upstream timed out", - Details: "OpenAI request exceeded the configured timeout", + Details: "xAI request exceeded the configured timeout", } } return "", model, &AIUpstreamError{ @@ -977,10 +950,10 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa } } if resp.StatusCode >= 400 { - return "", model, parseOpenAIError(resp.StatusCode, responseBody) + return "", model, parseXAIError(resp.StatusCode, responseBody) } - var response openAIResponsesResponse + var response xAIChatCompletionsResponse if err := json.Unmarshal(responseBody, &response); err != nil { return "", model, &AIUpstreamError{ StatusCode: http.StatusBadGateway, @@ -990,7 +963,13 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa } } - reply := strings.TrimSpace(response.OutputText) + 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)) } @@ -999,7 +978,7 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa StatusCode: http.StatusBadGateway, Code: "upstream_bad_response", Message: "explorer ai upstream returned no output text", - Details: "OpenAI response did not include output_text or content text", + Details: "xAI response did not include choices[0].message.content or output text", } } if strings.TrimSpace(response.Model) != "" { @@ -1008,7 +987,7 @@ func (s *Server) callOpenAIResponses(ctx context.Context, messages []AIChatMessa return reply, model, nil } -func parseOpenAIError(statusCode int, responseBody []byte) error { +func parseXAIError(statusCode int, responseBody []byte) error { var parsed struct { Error struct { Message string `json:"message"` diff --git a/docs/EXPLORER_API_ACCESS.md b/docs/EXPLORER_API_ACCESS.md index 983d698..8b719c3 100644 --- a/docs/EXPLORER_API_ACCESS.md +++ b/docs/EXPLORER_API_ACCESS.md @@ -214,15 +214,15 @@ Use the dedicated deployment script when you need to: - refresh `/opt/explorer-ai-docs` - ensure a real `JWT_SECRET` - install or refresh the explorer database override used for AI indexed context -- optionally install `OPENAI_API_KEY` +- optionally install `XAI_API_KEY` - normalize nginx for `/explorer-api/v1/*` ```bash cd /path/to/explorer-monorepo -OPENAI_API_KEY=... bash scripts/deploy-explorer-ai-to-vmid5000.sh +XAI_API_KEY=... bash scripts/deploy-explorer-ai-to-vmid5000.sh ``` -If `OPENAI_API_KEY` is omitted, the AI context endpoint will still work, but chat will remain disabled with a backend `service_unavailable` response. +If `XAI_API_KEY` is omitted, the AI context endpoint will still work, but chat will remain disabled with a backend `service_unavailable` response. On VMID `5000`, the script also writes a dedicated `database.conf` drop-in for `explorer-config-api` so AI context can query the live Blockscout Postgres container instead of assuming `localhost:5432`. diff --git a/scripts/deploy-explorer-ai-to-vmid5000.sh b/scripts/deploy-explorer-ai-to-vmid5000.sh index 946a18d..c4ce542 100644 --- a/scripts/deploy-explorer-ai-to-vmid5000.sh +++ b/scripts/deploy-explorer-ai-to-vmid5000.sh @@ -9,7 +9,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" BACKEND_DIR="$REPO_ROOT/explorer-monorepo/backend" TMP_DIR="$(mktemp -d)" JWT_SECRET_VALUE="${JWT_SECRET_VALUE:-}" -EXPLORER_AI_MODEL_VALUE="${EXPLORER_AI_MODEL_VALUE:-gpt-5.4-mini}" +EXPLORER_AI_MODEL_VALUE="${EXPLORER_AI_MODEL_VALUE:-grok-3}" EXPLORER_DATABASE_URL_VALUE="${EXPLORER_DATABASE_URL_VALUE:-}" cleanup() { @@ -50,11 +50,11 @@ fi export JWT_SECRET_VALUE export EXPLORER_AI_MODEL_VALUE -export OPENAI_API_KEY_VALUE="${OPENAI_API_KEY:-}" +export XAI_API_KEY_VALUE="${XAI_API_KEY:-}" export EXPLORER_DATABASE_URL_VALUE ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@"$PROXMOX_HOST" \ - "JWT_SECRET_VALUE='$JWT_SECRET_VALUE' EXPLORER_AI_MODEL_VALUE='$EXPLORER_AI_MODEL_VALUE' OPENAI_API_KEY_VALUE='$OPENAI_API_KEY_VALUE' EXPLORER_DATABASE_URL_VALUE='$EXPLORER_DATABASE_URL_VALUE' bash -s" <<'REMOTE' + "JWT_SECRET_VALUE='$JWT_SECRET_VALUE' EXPLORER_AI_MODEL_VALUE='$EXPLORER_AI_MODEL_VALUE' XAI_API_KEY_VALUE='$XAI_API_KEY_VALUE' EXPLORER_DATABASE_URL_VALUE='$EXPLORER_DATABASE_URL_VALUE' bash -s" <<'REMOTE' set -euo pipefail VMID=5000 @@ -74,7 +74,7 @@ pct exec "$VMID" -- env \ DB_URL="$DB_URL" \ EXPLORER_AI_MODEL_VALUE="$EXPLORER_AI_MODEL_VALUE" \ JWT_SECRET_VALUE="$JWT_SECRET_VALUE" \ -OPENAI_API_KEY_VALUE="$OPENAI_API_KEY_VALUE" \ +XAI_API_KEY_VALUE="$XAI_API_KEY_VALUE" \ bash -lc ' set -euo pipefail rm -rf /opt/explorer-ai-docs/* @@ -123,12 +123,16 @@ EOF chmod 600 /etc/systemd/system/explorer-config-api.service.d/database.conf fi -if [ -n "'"$OPENAI_API_KEY_VALUE"'" ]; then -cat > /etc/systemd/system/explorer-config-api.service.d/openai.conf < /etc/systemd/system/explorer-config-api.service.d/xai.conf <