Files
nofx/api/strategy.go
T
Lance 7ae5bf8247 release: merge dev into main (2026-04-17) (#1484)
* feat(store): prevent deletion of active strategies and update translations (#1461)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* fix: allow model switching without re-entering wallet key

Users with existing wallets could not switch AI models because the
"Start Trading" button required a valid private key even when one was
already configured. Now the button is enabled when hasExistingWallet
is true, and handleSubmit passes an empty key so the backend preserves
the existing key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace window.location with useNavigate for routing in auth components (#1470)

Co-authored-by: Dean <afei.wuhao@gmail.com>

* feat(trader): implement margin mode handling for order and leverage settings

* refactor(trader): update SetMarginMode to avoid legacy endpoint and improve logging

* feat(api): enhance strategy handling by integrating claw402 wallet key validation

Added validation for the claw402 model's wallet key during strategy test runs. If the selected AI model is claw402, the server now checks for a valid wallet key and returns appropriate error messages if it's missing or if the model fails to load. This ensures better error handling and user feedback when working with AI models.

* refactor(api): streamline claw402 wallet key retrieval and error handling

Refactored the strategy handling logic to encapsulate claw402 wallet key retrieval in a new method, `resolveStrategyDataWalletKey`. This improves code readability and maintains consistent error handling for missing or invalid wallet keys during strategy test runs. The changes enhance the overall robustness of the AI model integration.

* feat(trader): add claw402 wallet key resolution for trader configuration

Implemented a new method, `resolveTraderDataWalletKey`, to retrieve the claw402 wallet key based on the selected AI model and user ID. This enhancement allows for better integration of the claw402 model within the trader configuration, ensuring that the correct wallet key is used for trading operations. The `AutoTraderConfig` struct has been updated to include the new `Claw402WalletKey` field, improving the overall handling of wallet keys in the trading process.

* feat(claw402): preflight USDC balance before AI calls (#1479)

* chore: ignore nofx-server build artifact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(claw402): preflight USDC balance before AI calls

Short-circuit claw402 Call/CallWithRequestFull when the wallet balance
can't cover the estimated cost of the call, surfacing ErrInsufficientFunds
instead of letting x402 fail mid-flight after the sign step.

- wallet: cached balance lookup (30s TTL, per-address mutex) to avoid
  hammering the Base RPC; separate error-returning and display-only APIs
  so callers can distinguish zero balance from an unreachable RPC.
- claw402: 1.5× safety multiplier on the flat per-call estimate, 4.0×
  for reasoner models whose chain-of-thought cost can blow past the
  flat rate. Fail-open on RPC errors — x402 still gates actually-empty
  wallets, and we prefer availability over extra strictness.
- shortAddr redacts the wallet in error strings to avoid leaking the
  full address into telemetry bundles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telemetry): report token usage for SSE streaming paths (#1475)

* fix(telemetry): report token usage for SSE streaming paths

ParseSSEStream already parsed the usage block from SSE chunks but only
printed it, so claw402 streaming calls (and native streaming) never
fired TokenUsageCallback. GA4 therefore undercounted AI usage on the
streaming path.

Return the parsed usage from ParseSSEStream and have both callers fire
the callback with their own Provider/Model.

* chore: drop leftover debug Printf in ParseSSEStream

Telemetry is now wired via TokenUsageCallback, so the Printf is
redundant noise in the stream path.

* fix(gemini): update default model to gemini-3.1-pro

Google discontinued gemini-3-pro-preview on 2026-03-26 and directs all
callers to gemini-3.1-pro / gemini-3.1-pro-preview. Users on their own
API key were getting errors from the native Gemini endpoint because the
provider default pointed at the retired ID. Claw402 was unaffected
because its route map already used gemini-3.1-pro.

Align both the native provider default and the handler's preset list
with gemini-3.1-pro so every code path sends a live model ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract ResolveClaw402WalletKey to store layer and expand OKX margin mode tests

- Move duplicated claw402 wallet resolution logic into store.AIModelStore.ResolveClaw402WalletKey
- api/strategy.go and manager/trader_manager.go now delegate to the shared method
- Add detailed doc comment on OKX SetMarginMode explaining the local-state-only approach
  and why the legacy /api/v5/account/set-isolated-mode endpoint is not called
- Add 3 new test cases: cross mode leverage, OpenShort tdMode, SetTakeProfit tdMode

* fix(auth): prevent SetupPage remount from wiping freshly-set auth token (#1481)

After #1470 moved routing into react-router, SetupPage is rendered at two
different tree positions (top-level guard + /setup Route). When register
success flushSync-sets `user`, the top-level guard stops matching and the
Route-level SetupPage mounts as a new instance, re-running its cleanup
useEffect and removing the auth_token that handlePostAuthSuccess just wrote.
Subsequent requests 401 and bounce the user back to /login.

Redirect /setup to /welcome when user is already set so SetupPage is never
re-mounted during the auth transition.

* fix(wallet): handle JSON-RPC null error field in balance query

Some RPC implementations return explicit "error": null on success.
json.RawMessage deserializes this as the 4-byte literal "null", so
len() > 0 was true, causing every balance query to fail with
"rpc error: null". Skip the null literal to avoid false positives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: deanokk <wuhao@vergex.trade>
Co-authored-by: Dean <afei.wuhao@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: root <root@localhost.localdomain>
2026-04-17 19:13:35 +08:00

713 lines
20 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/mcp"
_ "nofx/mcp/payment"
_ "nofx/mcp/provider"
"nofx/store"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// validateStrategyConfig validates strategy configuration and returns warnings
func validateStrategyConfig(config *store.StrategyConfig) []string {
var warnings []string
// Validate NofxOS API key if any NofxOS feature is enabled
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) &&
config.Indicators.NofxOSAPIKey == "" {
warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.")
}
return warnings
}
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
func (s *Server) handleEstimateTokens(c *gin.Context) {
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
estimate := req.Config.EstimateTokens()
c.JSON(http.StatusOK, estimate)
}
// handlePublicStrategies Get public strategies for strategy market (no auth required)
func (s *Server) handlePublicStrategies(c *gin.Context) {
strategies, err := s.store.Strategy().ListPublic()
if err != nil {
SafeInternalError(c, "Failed to get public strategies", err)
return
}
// Convert to frontend format with visibility control
result := make([]gin.H, 0, len(strategies))
for _, st := range strategies {
item := gin.H{
"id": st.ID,
"name": st.Name,
"description": st.Description,
"author_email": "", // Will be filled if we have user info
"is_public": st.IsPublic,
"config_visible": st.ConfigVisible,
"created_at": st.CreatedAt,
"updated_at": st.UpdatedAt,
}
// Only include config if config_visible is true
if st.ConfigVisible {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
item["config"] = config
}
result = append(result, item)
}
c.JSON(http.StatusOK, gin.H{
"strategies": result,
})
}
// handleGetStrategies Get strategy list
func (s *Server) handleGetStrategies(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
strategies, err := s.store.Strategy().List(userID)
if err != nil {
SafeInternalError(c, "Failed to get strategy list", err)
return
}
// Convert to frontend format
result := make([]gin.H, 0, len(strategies))
for _, st := range strategies {
var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config)
result = append(result, gin.H{
"id": st.ID,
"name": st.Name,
"description": st.Description,
"is_active": st.IsActive,
"is_default": st.IsDefault,
"is_public": st.IsPublic,
"config_visible": st.ConfigVisible,
"config": config,
"created_at": st.CreatedAt,
"updated_at": st.UpdatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"strategies": result,
})
}
// handleGetStrategy Get single strategy
func (s *Server) handleGetStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
strategy, err := s.store.Strategy().Get(userID, strategyID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Strategy not found"})
return
}
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
"name": strategy.Name,
"description": strategy.Description,
"is_active": strategy.IsActive,
"is_default": strategy.IsDefault,
"config": config,
"created_at": strategy.CreatedAt,
"updated_at": strategy.UpdatedAt,
})
}
// handleCreateStrategy Create strategy.
// If "config" is omitted from the request body, the system default config is used automatically.
func (s *Server) handleCreateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Use default config when none provided
if req.Config == nil {
lang := req.Lang
if lang == "" {
lang = "zh"
}
defaultCfg := store.GetDefaultStrategyConfig(lang)
req.Config = &defaultCfg
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
SafeInternalError(c, "Serialize configuration", err)
return
}
strategy := &store.Strategy{
ID: uuid.New().String(),
UserID: userID,
Name: req.Name,
Description: req.Description,
IsActive: false,
IsDefault: false,
Config: string(configJSON),
}
if err := s.store.Strategy().Create(strategy); err != nil {
SafeInternalError(c, "Failed to create strategy", err)
return
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(req.Config)
response := gin.H{
"id": strategy.ID,
"message": "Strategy created successfully",
}
if len(warnings) > 0 {
response["warnings"] = warnings
}
c.JSON(http.StatusOK, response)
}
// handleUpdateStrategy Update strategy.
// The incoming config is merged with the existing one: top-level sections present in the
// request overwrite the corresponding existing sections; absent sections are preserved.
// This prevents partial updates from zeroing out unmentioned fields.
func (s *Server) handleUpdateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Check if it's a system default strategy
existing, err := s.store.Strategy().Get(userID, strategyID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Strategy not found"})
return
}
if existing.IsDefault {
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot modify system default strategy"})
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Config json.RawMessage `json:"config"` // raw JSON so we can merge
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Start with the existing config as base — preserves all unmentioned fields.
var mergedConfig store.StrategyConfig
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
// If existing config is corrupt, start from zero
mergedConfig = store.StrategyConfig{}
}
// Apply incoming config on top: top-level sections present in the request overwrite
// their corresponding existing section; absent sections remain unchanged.
if len(req.Config) > 0 && string(req.Config) != "null" {
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
// Preserve existing name/description when not supplied
name := req.Name
if name == "" {
name = existing.Name
}
description := req.Description
if description == "" {
description = existing.Description
}
configJSON, err := json.Marshal(mergedConfig)
if err != nil {
SafeInternalError(c, "Serialize configuration", err)
return
}
strategy := &store.Strategy{
ID: strategyID,
UserID: userID,
Name: name,
Description: description,
Config: string(configJSON),
IsPublic: req.IsPublic,
ConfigVisible: req.ConfigVisible,
}
if err := s.store.Strategy().Update(strategy); err != nil {
SafeInternalError(c, "Failed to update strategy", err)
return
}
// Token overflow check — block save if all models exceed context limits
if mergedConfig.StrategyType == "" || mergedConfig.StrategyType == "ai_trading" {
estimate := mergedConfig.EstimateTokens()
allExceed := true
for _, ml := range estimate.ModelLimits {
if ml.UsagePct <= 100 {
allExceed = false
break
}
}
if allExceed && len(estimate.ModelLimits) > 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Estimated %d tokens exceeds all known model context limits. Reduce coins, timeframes, or K-line count.", estimate.Total),
"token_estimate": estimate,
})
return
}
}
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
response["warnings"] = warnings
}
c.JSON(http.StatusOK, response)
}
// handleDeleteStrategy Delete strategy
func (s *Server) handleDeleteStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": SanitizeError(err, "Failed to delete strategy")})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Strategy deleted successfully"})
}
// handleActivateStrategy Activate strategy
func (s *Server) handleActivateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := s.store.Strategy().SetActive(userID, strategyID); err != nil {
SafeInternalError(c, "Failed to activate strategy", err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Strategy activated successfully"})
}
// handleDuplicateStrategy Duplicate strategy
func (s *Server) handleDuplicateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
sourceID := c.Param("id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
newID := uuid.New().String()
if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil {
SafeInternalError(c, "Failed to duplicate strategy", err)
return
}
c.JSON(http.StatusOK, gin.H{
"id": newID,
"message": "Strategy duplicated successfully",
})
}
// handleGetActiveStrategy Get currently active strategy
func (s *Server) handleGetActiveStrategy(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
strategy, err := s.store.Strategy().GetActive(userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "No active strategy"})
return
}
var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config)
c.JSON(http.StatusOK, gin.H{
"id": strategy.ID,
"name": strategy.Name,
"description": strategy.Description,
"is_active": strategy.IsActive,
"is_default": strategy.IsDefault,
"config": config,
"created_at": strategy.CreatedAt,
"updated_at": strategy.UpdatedAt,
})
}
// handleGetDefaultStrategyConfig Get default strategy configuration template
func (s *Server) handleGetDefaultStrategyConfig(c *gin.Context) {
// Get language from query parameter, default to "en"
lang := c.Query("lang")
if lang != "zh" {
lang = "en"
}
// Return default configuration with i18n support
defaultConfig := store.GetDefaultStrategyConfig(lang)
c.JSON(http.StatusOK, defaultConfig)
}
// handlePreviewPrompt Preview prompt generated by strategy
func (s *Server) handlePreviewPrompt(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
AccountEquity float64 `json:"account_equity"`
PromptVariant string `json:"prompt_variant"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Use default values
if req.AccountEquity <= 0 {
req.AccountEquity = 1000.0 // Default simulated account equity
}
if req.PromptVariant == "" {
req.PromptVariant = "balanced"
}
// Create strategy engine to build prompt
engine := kernel.NewStrategyEngine(&req.Config)
// Build system prompt (using built-in method from strategy engine)
systemPrompt := engine.BuildSystemPrompt(
req.AccountEquity,
req.PromptVariant,
)
c.JSON(http.StatusOK, gin.H{
"system_prompt": systemPrompt,
"prompt_variant": req.PromptVariant,
"config_summary": gin.H{
"coin_source": req.Config.CoinSource.SourceType,
"primary_tf": req.Config.Indicators.Klines.PrimaryTimeframe,
"btc_eth_leverage": req.Config.RiskControl.BTCETHMaxLeverage,
"altcoin_leverage": req.Config.RiskControl.AltcoinMaxLeverage,
"max_positions": req.Config.RiskControl.MaxPositions,
},
})
}
// handleStrategyTestRun AI test run (does not execute trades, only returns AI analysis results)
func (s *Server) handleStrategyTestRun(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
var req struct {
Config store.StrategyConfig `json:"config" binding:"required"`
PromptVariant string `json:"prompt_variant"`
AIModelID string `json:"ai_model_id"`
RunRealAI bool `json:"run_real_ai"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
if req.PromptVariant == "" {
req.PromptVariant = "balanced"
}
claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
"ai_response": "",
})
return
}
// Create strategy engine to build prompt
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
// Get candidate coins
candidates, err := engine.GetCandidateCoins()
if err != nil {
logger.Errorf("[API Error] Failed to get candidate coins: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get candidate coins",
"ai_response": "",
})
return
}
// Get timeframe configuration
timeframes := req.Config.Indicators.Klines.SelectedTimeframes
primaryTimeframe := req.Config.Indicators.Klines.PrimaryTimeframe
klineCount := req.Config.Indicators.Klines.PrimaryCount
// If no timeframes selected, use default values
if len(timeframes) == 0 {
// Backward compatibility: use primary and longer timeframes
if primaryTimeframe != "" {
timeframes = append(timeframes, primaryTimeframe)
} else {
timeframes = append(timeframes, "3m")
}
if req.Config.Indicators.Klines.LongerTimeframe != "" {
timeframes = append(timeframes, req.Config.Indicators.Klines.LongerTimeframe)
}
}
if primaryTimeframe == "" {
primaryTimeframe = timeframes[0]
}
if klineCount <= 0 {
klineCount = 30
}
fmt.Printf("📊 Using timeframes: %v, primary: %s, kline count: %d\n", timeframes, primaryTimeframe, klineCount)
// Get real market data (using multiple timeframes)
marketDataMap := make(map[string]*market.Data)
for _, coin := range candidates {
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
if err != nil {
// If getting data for a coin fails, log but continue
fmt.Printf("⚠️ Failed to get market data for %s: %v\n", coin.Symbol, err)
continue
}
marketDataMap[coin.Symbol] = data
}
// Fetch quantitative data for each candidate coin
symbols := make([]string, 0, len(candidates))
for _, c := range candidates {
symbols = append(symbols, c.Symbol)
}
quantDataMap := engine.FetchQuantDataBatch(symbols)
// Fetch OI ranking data (market-wide position changes)
oiRankingData := engine.FetchOIRankingData()
// Fetch NetFlow ranking data (market-wide fund flow)
netFlowRankingData := engine.FetchNetFlowRankingData()
// Fetch Price ranking data (market-wide gainers/losers)
priceRankingData := engine.FetchPriceRankingData()
// Build real context (for generating User Prompt)
testContext := &kernel.Context{
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: 0,
CallCount: 1,
Account: kernel.AccountInfo{
TotalEquity: 1000.0,
AvailableBalance: 1000.0,
UnrealizedPnL: 0,
TotalPnL: 0,
TotalPnLPct: 0,
MarginUsed: 0,
MarginUsedPct: 0,
PositionCount: 0,
},
Positions: []kernel.PositionInfo{},
CandidateCoins: candidates,
PromptVariant: req.PromptVariant,
MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap,
OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,
}
// Build System Prompt
systemPrompt := engine.BuildSystemPrompt(1000.0, req.PromptVariant)
// Build User Prompt (using real market data)
userPrompt := engine.BuildUserPrompt(testContext)
// If requesting real AI call
if req.RunRealAI && req.AIModelID != "" {
aiResponse, aiErr := s.runRealAITest(userID, req.AIModelID, systemPrompt, userPrompt)
if aiErr != nil {
c.JSON(http.StatusOK, gin.H{
"system_prompt": systemPrompt,
"user_prompt": userPrompt,
"candidate_count": len(candidates),
"candidates": candidates,
"prompt_variant": req.PromptVariant,
"ai_response": fmt.Sprintf("❌ AI call failed: %s", aiErr.Error()),
"ai_error": aiErr.Error(),
"note": "AI call error",
})
return
}
c.JSON(http.StatusOK, gin.H{
"system_prompt": systemPrompt,
"user_prompt": userPrompt,
"candidate_count": len(candidates),
"candidates": candidates,
"prompt_variant": req.PromptVariant,
"ai_response": aiResponse,
"note": "✅ Real AI test run successful",
})
return
}
// Return result (without actually calling AI, only return built prompt)
c.JSON(http.StatusOK, gin.H{
"system_prompt": systemPrompt,
"user_prompt": userPrompt,
"candidate_count": len(candidates),
"candidates": candidates,
"prompt_variant": req.PromptVariant,
"ai_response": "Please select an AI model and click 'Run Test' to perform real AI analysis.",
"note": "AI model not selected or real AI call not enabled",
})
}
// runRealAITest Execute real AI test call
func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) (string, error) {
// Get AI model configuration
model, err := s.store.AIModel().Get(userID, modelID)
if err != nil {
return "", fmt.Errorf("failed to get AI model: %w", err)
}
if !model.Enabled {
return "", fmt.Errorf("AI model %s is not enabled", model.Name)
}
if model.APIKey == "" {
return "", fmt.Errorf("AI model %s is missing API Key", model.Name)
}
// Create AI client via registry
provider := model.Provider
apiKey := string(model.APIKey)
aiClient := mcp.NewAIClientByProvider(provider)
if aiClient == nil {
aiClient = mcp.NewClient()
}
// Payment providers ignore custom URL
switch provider {
case "claw402":
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
default:
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
}
// Call AI API
response, err := aiClient.CallWithMessages(systemPrompt, userPrompt)
if err != nil {
return "", fmt.Errorf("AI API call failed: %w", err)
}
return response, nil
}
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
}