mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
feat: AI cost tracking, pre-launch balance check, low balance alerts
- store/ai_charge.go: local AI cost tracking per call (SQLite) - wallet/usdc.go: shared USDC balance query (Base chain RPC) - Pre-launch: estimate daily cost + runway days - Low balance: warn <$1, error at $0 (every 10 cycles) - API: GET /api/ai-costs for cost history - Frontend: model cards show price per call - Frontend: wallet create + QR deposit + balance display
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AICharge represents a single AI call charge record
|
||||
type AICharge struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_ai_charges_trader" json:"trader_id"`
|
||||
Model string `gorm:"column:model;not null" json:"model"`
|
||||
Provider string `gorm:"column:provider;not null" json:"provider"`
|
||||
CostUSD float64 `gorm:"column:cost_usd;not null" json:"cost_usd"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (AICharge) TableName() string { return "ai_charges" }
|
||||
|
||||
// modelPrices maps model ID to approximate cost per call in USD
|
||||
var modelPrices = map[string]float64{
|
||||
"deepseek": 0.003,
|
||||
"deepseek-reasoner": 0.005,
|
||||
"gpt-5.4": 0.05,
|
||||
"gpt-5.4-pro": 0.50,
|
||||
"gpt-5.3": 0.01,
|
||||
"gpt-5-mini": 0.005,
|
||||
"claude-opus": 0.12,
|
||||
"qwen-max": 0.01,
|
||||
"qwen-plus": 0.005,
|
||||
"qwen-turbo": 0.002,
|
||||
"qwen-flash": 0.002,
|
||||
"grok-4.1": 0.06,
|
||||
"gemini-3.1-pro": 0.03,
|
||||
"kimi-k2.5": 0.008,
|
||||
}
|
||||
|
||||
// GetModelPrice returns the price per call for a given model
|
||||
func GetModelPrice(model string) float64 {
|
||||
if price, ok := modelPrices[model]; ok {
|
||||
return price
|
||||
}
|
||||
return 0.01 // default fallback
|
||||
}
|
||||
|
||||
// AIChargeStore handles AI charge records
|
||||
type AIChargeStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAIChargeStore creates a new AIChargeStore
|
||||
func NewAIChargeStore(db *gorm.DB) *AIChargeStore {
|
||||
return &AIChargeStore{db: db}
|
||||
}
|
||||
|
||||
func (s *AIChargeStore) initTables() error {
|
||||
return s.db.AutoMigrate(&AICharge{})
|
||||
}
|
||||
|
||||
// Record records a new AI charge
|
||||
func (s *AIChargeStore) Record(traderID, model, provider string) error {
|
||||
cost := GetModelPrice(model)
|
||||
charge := &AICharge{
|
||||
TraderID: traderID,
|
||||
Model: model,
|
||||
Provider: provider,
|
||||
CostUSD: cost,
|
||||
}
|
||||
return s.db.Create(charge).Error
|
||||
}
|
||||
|
||||
// GetCharges returns charges for a trader within a period, plus total cost
|
||||
func (s *AIChargeStore) GetCharges(traderID string, period string) ([]AICharge, float64, error) {
|
||||
var charges []AICharge
|
||||
query := s.db.Where("trader_id = ?", traderID)
|
||||
query = applyPeriodFilter(query, period)
|
||||
if err := query.Order("created_at DESC").Find(&charges).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, c := range charges {
|
||||
total += c.CostUSD
|
||||
}
|
||||
return charges, total, nil
|
||||
}
|
||||
|
||||
// GetDailyCost returns total cost across all traders for a period
|
||||
func (s *AIChargeStore) GetDailyCost(period string) float64 {
|
||||
var total float64
|
||||
query := s.db.Model(&AICharge{}).Select("COALESCE(SUM(cost_usd), 0)")
|
||||
query = applyPeriodFilter(query, period)
|
||||
query.Scan(&total)
|
||||
return total
|
||||
}
|
||||
|
||||
// GetSummary returns summary stats for a period
|
||||
func (s *AIChargeStore) GetSummary(period string) (total float64, count int64, byModel map[string]float64) {
|
||||
byModel = make(map[string]float64)
|
||||
|
||||
query := s.db.Model(&AICharge{})
|
||||
query = applyPeriodFilter(query, period)
|
||||
query.Count(&count)
|
||||
|
||||
query2 := s.db.Model(&AICharge{}).Select("COALESCE(SUM(cost_usd), 0)")
|
||||
query2 = applyPeriodFilter(query2, period)
|
||||
query2.Scan(&total)
|
||||
|
||||
// By model breakdown
|
||||
type modelCost struct {
|
||||
Model string `gorm:"column:model"`
|
||||
Total float64 `gorm:"column:total"`
|
||||
}
|
||||
var results []modelCost
|
||||
query3 := s.db.Model(&AICharge{}).Select("model, SUM(cost_usd) as total").Group("model")
|
||||
query3 = applyPeriodFilter(query3, period)
|
||||
query3.Find(&results)
|
||||
for _, r := range results {
|
||||
byModel[r.Model] = r.Total
|
||||
}
|
||||
|
||||
return total, count, byModel
|
||||
}
|
||||
|
||||
func applyPeriodFilter(query *gorm.DB, period string) *gorm.DB {
|
||||
now := time.Now()
|
||||
switch period {
|
||||
case "today":
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return query.Where("created_at >= ?", start)
|
||||
case "week":
|
||||
return query.Where("created_at >= ?", now.AddDate(0, 0, -7))
|
||||
case "month":
|
||||
return query.Where("created_at >= ?", now.AddDate(0, -1, 0))
|
||||
case "all":
|
||||
return query
|
||||
default:
|
||||
// Try parse as date
|
||||
if t, err := time.Parse("2006-01-02", period); err == nil {
|
||||
end := t.AddDate(0, 0, 1)
|
||||
return query.Where("created_at >= ? AND created_at < ?", t, end)
|
||||
}
|
||||
// Default to today
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
return query.Where("created_at >= ?", start)
|
||||
}
|
||||
}
|
||||
|
||||
// IsClaw402Config checks if a trader config uses claw402 payment provider
|
||||
func IsClaw402Config(aiModel string) bool {
|
||||
return aiModel == "claw402"
|
||||
}
|
||||
|
||||
// EstimateRunway estimates how many days the given USDC balance will last
|
||||
func EstimateRunway(usdcBalance float64, modelName string, scanIntervalMinutes int) (dailyCost float64, runwayDays float64) {
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 3
|
||||
}
|
||||
callsPerDay := float64(24*60) / float64(scanIntervalMinutes)
|
||||
pricePerCall := GetModelPrice(modelName)
|
||||
dailyCost = callsPerDay * pricePerCall
|
||||
if dailyCost > 0 && usdcBalance > 0 {
|
||||
runwayDays = usdcBalance / dailyCost
|
||||
}
|
||||
return dailyCost, runwayDays
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type Store struct {
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
aiCharge *AIChargeStore
|
||||
telegramConfig TelegramConfigStore
|
||||
|
||||
mu sync.RWMutex
|
||||
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
|
||||
}
|
||||
if err := s.AICharge().initTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize AI charge tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -283,6 +287,16 @@ func (s *Store) Grid() *GridStore {
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// AICharge gets AI charge storage
|
||||
func (s *Store) AICharge() *AIChargeStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.aiCharge == nil {
|
||||
s.aiCharge = NewAIChargeStore(s.gdb)
|
||||
}
|
||||
return s.aiCharge
|
||||
}
|
||||
|
||||
// TelegramConfig gets Telegram bot configuration storage
|
||||
func (s *Store) TelegramConfig() TelegramConfigStore {
|
||||
s.mu.Lock()
|
||||
|
||||
Reference in New Issue
Block a user