Files
nofx/agent/config_validation.go
2026-04-28 11:58:58 +08:00

467 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package agent
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"nofx/security"
"nofx/store"
)
type ConfigValidationResult struct {
Warnings []string
}
type ConfigValidator interface {
Validate() error
}
var (
openAIAPIKeyPattern = regexp.MustCompile(`^sk-[A-Za-z0-9\-_]{4,}$`)
genericAPIKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_\-]{8,}$`)
hexCredentialPattern = regexp.MustCompile(`^(0x)?[A-Fa-f0-9]{16,}$`)
supportedModelProvider = map[string]struct{}{
"openai": {}, "deepseek": {}, "claude": {}, "gemini": {}, "qwen": {}, "kimi": {}, "grok": {}, "minimax": {}, "claw402": {}, "blockrun-base": {}, "blockrun-sol": {},
}
)
const (
manualTraderScanIntervalMin = 3
manualTraderScanIntervalMax = 60
manualTraderInitialBalance = 100.0
manualLighterAPIKeyIndexMin = 0
manualLighterAPIKeyIndexMax = 255
)
type modelConfigValidator struct {
provider string
enabled bool
apiKey string
customAPIURL string
customModelName string
modelID string
}
func (v modelConfigValidator) Validate() error {
provider := strings.ToLower(strings.TrimSpace(v.provider))
if provider == "" {
return fmt.Errorf("provider is required")
}
if _, ok := supportedModelProvider[provider]; !ok {
return fmt.Errorf("unsupported provider: %s", provider)
}
if trimmed := strings.TrimSpace(v.customAPIURL); trimmed != "" {
if err := security.ValidateURL(strings.TrimSuffix(trimmed, "#")); err != nil {
return fmt.Errorf("invalid custom_api_url: %w", err)
}
}
if v.enabled && !modelConfigUsable(provider, v.modelID, strings.TrimSpace(v.apiKey), strings.TrimSpace(v.customAPIURL), strings.TrimSpace(v.customModelName)) {
return fmt.Errorf("cannot enable model config before a usable API key, URL, and model are configured")
}
if provider == "openai" && strings.TrimSpace(v.apiKey) != "" && !openAIAPIKeyPattern.MatchString(strings.TrimSpace(v.apiKey)) {
return fmt.Errorf("OpenAI API Key format looks invalid")
}
return nil
}
type exchangeConfigValidator struct {
exchangeType string
enabled bool
apiKey string
secretKey string
passphrase string
hyperliquidWalletAddr string
asterUser string
asterSigner string
asterPrivateKey string
lighterWalletAddr string
lighterPrivateKey string
lighterAPIKeyPrivateKey string
}
func (v exchangeConfigValidator) Validate() error {
exchangeType := strings.ToLower(strings.TrimSpace(v.exchangeType))
if exchangeType == "" {
return fmt.Errorf("exchange_type is required")
}
if trimmed := strings.TrimSpace(v.apiKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) {
return fmt.Errorf("API Key format looks invalid")
}
if trimmed := strings.TrimSpace(v.secretKey); trimmed != "" && !genericAPIKeyPattern.MatchString(trimmed) && !hexCredentialPattern.MatchString(trimmed) {
return fmt.Errorf("Secret format looks invalid")
}
if v.enabled {
missing := store.MissingRequiredExchangeCredentialFields(
exchangeType,
v.apiKey,
v.secretKey,
v.passphrase,
v.hyperliquidWalletAddr,
v.asterUser,
v.asterSigner,
v.asterPrivateKey,
v.lighterWalletAddr,
v.lighterAPIKeyPrivateKey,
)
if len(missing) > 0 {
return fmt.Errorf("cannot enable exchange config before required fields are complete: %s", strings.Join(missing, ", "))
}
}
return nil
}
type traderBindingValidator struct {
store *store.Store
storeUserID string
aiModelID string
exchangeID string
strategyID string
}
func (v traderBindingValidator) Validate() error {
if v.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(v.aiModelID) == "" {
return fmt.Errorf("ai_model_id is required")
}
if strings.TrimSpace(v.exchangeID) == "" {
return fmt.Errorf("exchange_id is required")
}
model, err := v.store.AIModel().Get(v.storeUserID, strings.TrimSpace(v.aiModelID))
if err != nil {
return fmt.Errorf("invalid ai_model_id: %w", err)
}
if !model.Enabled {
return fmt.Errorf("ai model is disabled")
}
if !modelConfigUsable(model.Provider, model.ID, strings.TrimSpace(string(model.APIKey)), strings.TrimSpace(model.CustomAPIURL), strings.TrimSpace(model.CustomModelName)) {
return fmt.Errorf("ai model config is incomplete")
}
exchange, err := v.store.Exchange().GetByID(v.storeUserID, strings.TrimSpace(v.exchangeID))
if err != nil {
return fmt.Errorf("invalid exchange_id: %w", err)
}
if !exchange.Enabled {
return fmt.Errorf("exchange is disabled")
}
if err := (exchangeConfigValidator{
exchangeType: exchange.ExchangeType,
enabled: exchange.Enabled,
apiKey: strings.TrimSpace(string(exchange.APIKey)),
secretKey: strings.TrimSpace(string(exchange.SecretKey)),
passphrase: strings.TrimSpace(string(exchange.Passphrase)),
hyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
asterUser: exchange.AsterUser,
asterSigner: exchange.AsterSigner,
asterPrivateKey: strings.TrimSpace(string(exchange.AsterPrivateKey)),
lighterWalletAddr: exchange.LighterWalletAddr,
lighterPrivateKey: strings.TrimSpace(string(exchange.LighterPrivateKey)),
lighterAPIKeyPrivateKey: strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)),
}).Validate(); err != nil {
return fmt.Errorf("exchange config is incomplete: %w", err)
}
if trimmed := strings.TrimSpace(v.strategyID); trimmed != "" {
if _, err := v.store.Strategy().Get(v.storeUserID, trimmed); err != nil {
return fmt.Errorf("invalid strategy_id: %w", err)
}
}
return nil
}
func (a *Agent) validateModelDraft(storeUserID, modelID, provider string, enabled bool, apiKey, customAPIURL, customModelName string) error {
if a == nil || a.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(provider) == "" && strings.TrimSpace(modelID) != "" {
model, err := a.store.AIModel().Get(storeUserID, strings.TrimSpace(modelID))
if err != nil {
return err
}
provider = model.Provider
if strings.TrimSpace(apiKey) == "" {
apiKey = strings.TrimSpace(string(model.APIKey))
}
if strings.TrimSpace(customAPIURL) == "" {
customAPIURL = strings.TrimSpace(model.CustomAPIURL)
}
if strings.TrimSpace(customModelName) == "" {
customModelName = strings.TrimSpace(model.CustomModelName)
}
}
return (modelConfigValidator{
provider: provider,
enabled: enabled,
apiKey: apiKey,
customAPIURL: customAPIURL,
customModelName: customModelName,
modelID: modelID,
}).Validate()
}
func (a *Agent) validateExchangeDraft(storeUserID, exchangeID, exchangeType string, enabled bool, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) error {
if a == nil || a.store == nil {
return fmt.Errorf("store unavailable")
}
if strings.TrimSpace(exchangeType) == "" && strings.TrimSpace(exchangeID) != "" {
exchange, err := a.store.Exchange().GetByID(storeUserID, strings.TrimSpace(exchangeID))
if err != nil {
return err
}
exchangeType = exchange.ExchangeType
if strings.TrimSpace(apiKey) == "" {
apiKey = strings.TrimSpace(string(exchange.APIKey))
}
if strings.TrimSpace(secretKey) == "" {
secretKey = strings.TrimSpace(string(exchange.SecretKey))
}
if strings.TrimSpace(passphrase) == "" {
passphrase = strings.TrimSpace(string(exchange.Passphrase))
}
if strings.TrimSpace(hyperliquidWalletAddr) == "" {
hyperliquidWalletAddr = strings.TrimSpace(exchange.HyperliquidWalletAddr)
}
if strings.TrimSpace(asterUser) == "" {
asterUser = strings.TrimSpace(exchange.AsterUser)
}
if strings.TrimSpace(asterSigner) == "" {
asterSigner = strings.TrimSpace(exchange.AsterSigner)
}
if strings.TrimSpace(asterPrivateKey) == "" {
asterPrivateKey = strings.TrimSpace(string(exchange.AsterPrivateKey))
}
if strings.TrimSpace(lighterWalletAddr) == "" {
lighterWalletAddr = strings.TrimSpace(exchange.LighterWalletAddr)
}
if strings.TrimSpace(lighterAPIKeyPrivateKey) == "" {
lighterAPIKeyPrivateKey = strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey))
}
}
return (exchangeConfigValidator{
exchangeType: exchangeType,
enabled: enabled,
apiKey: apiKey,
secretKey: secretKey,
passphrase: passphrase,
hyperliquidWalletAddr: hyperliquidWalletAddr,
asterUser: asterUser,
asterSigner: asterSigner,
asterPrivateKey: asterPrivateKey,
lighterWalletAddr: lighterWalletAddr,
lighterAPIKeyPrivateKey: lighterAPIKeyPrivateKey,
}).Validate()
}
func (a *Agent) validateTraderDraft(storeUserID, aiModelID, exchangeID, strategyID string) error {
return (traderBindingValidator{
store: a.store,
storeUserID: storeUserID,
aiModelID: aiModelID,
exchangeID: exchangeID,
strategyID: strategyID,
}).Validate()
}
func formatValidationFeedback(lang, domain string, err error) string {
if err == nil {
return ""
}
raw := strings.TrimSpace(err.Error())
lower := strings.ToLower(raw)
if lang == "zh" {
switch {
case strings.Contains(lower, "openai api key format looks invalid"):
return "这份配置还有问题API Key 格式不对。OpenAI 的 API Key 通常以 `sk-` 开头,请直接发完整 Key我继续帮你补进当前草稿。"
case strings.Contains(lower, "api key format looks invalid"):
return "这份配置还有问题API Key 格式不对。请直接发完整的 API Key不要附带多余说明文字。"
case strings.Contains(lower, "secret format looks invalid"):
return "这份配置还有问题Secret 格式不对。请直接发完整的 Secret 值,不要和 API Key 填反。"
case strings.Contains(lower, "okx requires passphrase"):
return "这份配置还有问题OKX 账户缺少 Passphrase启用前需要补齐。你直接把 Passphrase 发我就行。"
case strings.Contains(lower, "hyperliquid requires wallet address"):
return "这份配置还有问题Hyperliquid 账户缺少钱包地址,启用前需要补齐。"
case strings.Contains(lower, "aster requires user, signer, and private key"):
return "这份配置还有问题Aster 账户还缺 user、signer 和 private key启用前需要补齐。"
case strings.Contains(lower, "lighter requires wallet address and api key private key"):
return "这份配置还有问题Lighter 账户还缺钱包地址和 API key private key启用前需要补齐。"
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
return "这份配置还有问题:要先把 API Key、接口地址和模型名称配完整才能启用。你可以继续把缺的字段发给我。"
case strings.Contains(lower, "unsupported provider"):
return "这份配置还有问题provider 不在支持范围内。请从 OpenAI、DeepSeek、Claude、Gemini、Qwen、Kimi、Grok、Minimax 里选一个。"
case strings.Contains(lower, "invalid custom_api_url"):
return "这份配置还有问题:接口地址格式不对。请给我完整的 URL或直接说使用默认地址。"
case strings.Contains(lower, "ai model is disabled"):
return "这份配置还有问题:绑定的模型当前是禁用状态。请换一个已启用模型,或先启用这个模型。"
case strings.Contains(lower, "exchange is disabled"):
return "这份配置还有问题:绑定的交易所当前已禁用。请换一个已启用交易所,或先启用这个交易所。"
case strings.Contains(lower, "ai model config is incomplete"):
return "这份配置还有问题:绑定的模型配置还没补完整,暂时不能使用。"
case strings.Contains(lower, "invalid ai_model_id"):
return "这份配置还有问题:模型引用无效。请明确告诉我你要绑定哪个模型。"
case strings.Contains(lower, "invalid exchange_id"):
return "这份配置还有问题:交易所引用无效。请明确告诉我你要绑定哪个交易所。"
case strings.Contains(lower, "invalid strategy_id"):
return "这份配置还有问题:策略引用无效。请明确告诉我你要绑定哪个策略。"
case strings.Contains(lower, "provider is required"):
return "这份配置还缺 provider。请先告诉我你要用哪个模型提供商。"
case strings.Contains(lower, "exchange_type is required"):
return "这份配置还缺交易所类型。请先告诉我你要接哪个交易所。"
}
switch domain {
case "model":
return "这份模型草稿还有问题:" + raw
case "exchange":
return "这份交易所草稿还有问题:" + raw
case "trader":
return "这份交易员草稿还有问题:" + raw
case "strategy":
return "这份策略草稿还有问题:" + raw
default:
return "这份配置还有问题:" + raw
}
}
switch {
case strings.Contains(lower, "openai api key format looks invalid"):
return "This draft still has an issue: the API key format looks wrong. OpenAI keys usually start with `sk-`. Send the full key and I'll keep filling the draft."
case strings.Contains(lower, "api key format looks invalid"):
return "This draft still has an issue: the API key format looks wrong. Send the full API key directly."
case strings.Contains(lower, "secret format looks invalid"):
return "This draft still has an issue: the secret format looks wrong. Send the full secret value directly."
case strings.Contains(lower, "okx requires passphrase"):
return "This draft still has an issue: an OKX config needs a passphrase before it can be enabled. Send the passphrase and I'll keep going."
case strings.Contains(lower, "cannot enable model config before a usable api key, url, and model are configured"):
return "This draft still has an issue: the API key, endpoint URL, and model name must be completed before the config can be enabled."
}
switch domain {
case "model":
return "This model draft still has an issue: " + raw
case "exchange":
return "This exchange draft still has an issue: " + raw
case "trader":
return "This trader draft still has an issue: " + raw
case "strategy":
return "This strategy draft still has an issue: " + raw
default:
return "This draft still has an issue: " + raw
}
}
func normalizeTraderArgsToManualLimits(lang string, args traderUpdateArgs) (traderUpdateArgs, []string) {
warnings := make([]string, 0, 2)
if args.ScanIntervalMinutes != nil {
requested := *args.ScanIntervalMinutes
normalized := requested
if normalized < manualTraderScanIntervalMin {
normalized = manualTraderScanIntervalMin
}
if normalized > manualTraderScanIntervalMax {
normalized = manualTraderScanIntervalMax
}
if normalized != requested {
args.ScanIntervalMinutes = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("扫描间隔手动可配置范围是 %d 到 %d 分钟,已从 %d 调整为 %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("scan interval is limited to %d-%d minutes in the manual config, adjusted from %d to %d", manualTraderScanIntervalMin, manualTraderScanIntervalMax, requested, normalized))
}
}
}
return args, warnings
}
func formatRiskControlAcceptancePrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,我已经先按风控范围收敛:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I normalized them first:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func formatRiskControlRefusalPrompt(lang string, warnings []string, confirmLabel string) string {
if len(warnings) == 0 {
return ""
}
if lang == "zh" {
lines := []string{
"这些配置超出了手动面板允许的范围,本次不会按你给的原值直接保存:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("如果接受当前安全范围,回复“%s”也可以继续告诉我你想怎么改。", confirmLabel))
return strings.Join(lines, "\n")
}
lines := []string{
"Some values were outside the manual editor limits, so I did not save the original request as-is:",
}
for _, warning := range warnings {
lines = append(lines, "- "+warning)
}
lines = append(lines, fmt.Sprintf("Reply %q to accept these safe values, or keep refining the draft.", confirmLabel))
return strings.Join(lines, "\n")
}
func marshalStringList(values []string) string {
if len(values) == 0 {
return ""
}
raw, err := json.Marshal(values)
if err != nil {
return ""
}
return string(raw)
}
func unmarshalStringList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil
}
return values
}
func normalizeExchangePatchToManualLimits(lang string, patch exchangeUpdatePatch) (exchangeUpdatePatch, []string) {
warnings := make([]string, 0, 1)
if patch.LighterAPIKeyIndex != nil {
requested := *patch.LighterAPIKeyIndex
normalized := requested
if normalized < manualLighterAPIKeyIndexMin {
normalized = manualLighterAPIKeyIndexMin
}
if normalized > manualLighterAPIKeyIndexMax {
normalized = manualLighterAPIKeyIndexMax
}
if normalized != requested {
patch.LighterAPIKeyIndex = &normalized
if lang == "zh" {
warnings = append(warnings, fmt.Sprintf("Lighter API Key Index 手动面板范围是 %d 到 %d已从 %d 调整为 %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
} else {
warnings = append(warnings, fmt.Sprintf("lighter API key index is limited to %d-%d in the manual editor, adjusted from %d to %d", manualLighterAPIKeyIndexMin, manualLighterAPIKeyIndexMax, requested, normalized))
}
}
}
return patch, warnings
}