Switch explorer AI provider to Grok

This commit is contained in:
defiQUG
2026-03-27 16:51:04 -07:00
parent d0964904d6
commit c1fe6ec6e3
3 changed files with 74 additions and 91 deletions

View File

@@ -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"`

View File

@@ -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`.

View File

@@ -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 <<EOF
rm -f /etc/systemd/system/explorer-config-api.service.d/openai.conf
if [ -n "'"$XAI_API_KEY_VALUE"'" ]; then
cat > /etc/systemd/system/explorer-config-api.service.d/xai.conf <<EOF
[Service]
Environment=OPENAI_API_KEY='"$OPENAI_API_KEY_VALUE"'
Environment=XAI_API_KEY='"$XAI_API_KEY_VALUE"'
EOF
chmod 600 /etc/systemd/system/explorer-config-api.service.d/openai.conf
chmod 600 /etc/systemd/system/explorer-config-api.service.d/xai.conf
else
rm -f /etc/systemd/system/explorer-config-api.service.d/xai.conf
fi
systemctl daemon-reload