From 281cae695faee5c266e364ffe0fcb5037aa99eb6 Mon Sep 17 00:00:00 2001 From: Fred Date: Sat, 25 Apr 2026 03:22:31 +0200 Subject: [PATCH] feat: add Moonshot/Kimi and DeepSeek providers (balance-based) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the OpenRouter pattern to track two pay-as-you-go providers whose quotas are exposed as a remaining balance (not cumulative usage). Endpoints: - Moonshot: GET https://api.moonshot.ai/v1/users/me/balance (CNY) - DeepSeek: GET https://api.deepseek.com/user/balance (USD/CNY) Semantic inversion vs OpenRouter: - OpenRouter tracks cumulative usage (grows, resets monthly) -> reset on 50% drop - Moonshot/DeepSeek track balance (decreases on spend, grows on recharge) -> reset on >=50% growth (recharge); TotalDelta cumulates balance drops Files added (mirroring openrouter pattern): - internal/api/{moonshot,deepseek}_{client,types}.go (+tests) - internal/agent/{moonshot,deepseek}_agent.go - internal/store/{moonshot,deepseek}_store.go (+tests, with currency col on deepseek) - internal/tracker/{moonshot,deepseek}_tracker.go (+tests) - internal/web/{moonshot,deepseek}_handlers.go Files modified: - internal/config/config.go: MOONSHOT_API_KEY, DEEPSEEK_API_KEY env vars - internal/store/store.go: schema migrations - internal/web/{handlers,static/app.js,templates/dashboard.html}: dashboard tabs - internal/metrics/metrics.go: onwatch_credits_balance{unit="cny_*"|"usd_*"} gauges - main.go: agent registration Tested live on Linux Mint 22.3 / Cinnamon 6.6.7 / Go 1.25.7: - Moonshot balance: ¥19.47 detected - DeepSeek balance: $34.69 detected - 1224 unit tests pass (api/store/tracker packages) Note: Moonshot endpoint set to api.moonshot.ai (international). Users on the Chinese moonshot.cn endpoint may need to patch the baseURL or expose it via env (suggested follow-up). Co-Authored-By: Jules Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/agent/deepseek_agent.go | 123 +++++++ internal/agent/moonshot_agent.go | 117 ++++++ internal/api/deepseek_client.go | 163 +++++++++ internal/api/deepseek_client_test.go | 123 +++++++ internal/api/deepseek_types.go | 76 ++++ internal/api/moonshot_client.go | 165 +++++++++ internal/api/moonshot_client_test.go | 111 ++++++ internal/api/moonshot_types.go | 47 +++ internal/config/config.go | 39 ++ internal/metrics/metrics.go | 71 ++++ internal/store/deepseek_store.go | 235 ++++++++++++ internal/store/deepseek_store_test.go | 109 ++++++ internal/store/moonshot_store.go | 221 ++++++++++++ internal/store/moonshot_store_test.go | 102 ++++++ internal/store/store.go | 33 ++ internal/tracker/deepseek_tracker.go | 212 +++++++++++ internal/tracker/deepseek_tracker_test.go | 94 +++++ internal/tracker/moonshot_tracker.go | 207 +++++++++++ internal/tracker/moonshot_tracker_test.go | 88 +++++ internal/web/deepseek_handlers.go | 419 ++++++++++++++++++++++ internal/web/handlers.go | 216 +++++++++++ internal/web/moonshot_handlers.go | 374 +++++++++++++++++++ internal/web/static/app.js | 38 +- internal/web/templates/dashboard.html | 18 + main.go | 70 +++- 25 files changed, 3467 insertions(+), 4 deletions(-) create mode 100644 internal/agent/deepseek_agent.go create mode 100644 internal/agent/moonshot_agent.go create mode 100644 internal/api/deepseek_client.go create mode 100644 internal/api/deepseek_client_test.go create mode 100644 internal/api/deepseek_types.go create mode 100644 internal/api/moonshot_client.go create mode 100644 internal/api/moonshot_client_test.go create mode 100644 internal/api/moonshot_types.go create mode 100644 internal/store/deepseek_store.go create mode 100644 internal/store/deepseek_store_test.go create mode 100644 internal/store/moonshot_store.go create mode 100644 internal/store/moonshot_store_test.go create mode 100644 internal/tracker/deepseek_tracker.go create mode 100644 internal/tracker/deepseek_tracker_test.go create mode 100644 internal/tracker/moonshot_tracker.go create mode 100644 internal/tracker/moonshot_tracker_test.go create mode 100644 internal/web/deepseek_handlers.go create mode 100644 internal/web/moonshot_handlers.go diff --git a/internal/agent/deepseek_agent.go b/internal/agent/deepseek_agent.go new file mode 100644 index 0000000..497ef43 --- /dev/null +++ b/internal/agent/deepseek_agent.go @@ -0,0 +1,123 @@ +package agent + +import ( + "context" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// DeepSeekAgent manages the background polling loop for DeepSeek usage tracking. +type DeepSeekAgent struct { + client *api.DeepSeekClient + store *store.Store + tracker *tracker.DeepSeekTracker + interval time.Duration + logger *slog.Logger + sm *SessionManager + notifier *notify.NotificationEngine + pollingCheck func() bool +} + +// SetPollingCheck sets a function that is called before each poll. +func (a *DeepSeekAgent) SetPollingCheck(fn func() bool) { + a.pollingCheck = fn +} + +// SetNotifier sets the notification engine for sending alerts. +func (a *DeepSeekAgent) SetNotifier(n *notify.NotificationEngine) { + a.notifier = n +} + +// NewDeepSeekAgent creates a new DeepSeekAgent with the given dependencies. +func NewDeepSeekAgent(client *api.DeepSeekClient, store *store.Store, tr *tracker.DeepSeekTracker, interval time.Duration, logger *slog.Logger, sm *SessionManager) *DeepSeekAgent { + if logger == nil { + logger = slog.Default() + } + return &DeepSeekAgent{ + client: client, + store: store, + tracker: tr, + interval: interval, + logger: logger, + sm: sm, + } +} + +// Run starts the DeepSeek agent's polling loop. +func (a *DeepSeekAgent) Run(ctx context.Context) error { + a.logger.Info("DeepSeek agent started", "interval", a.interval) + + defer func() { + if a.sm != nil { + a.sm.Close() + } + a.logger.Info("DeepSeek agent stopped") + }() + + a.poll(ctx) + + ticker := time.NewTicker(a.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.poll(ctx) + case <-ctx.Done(): + return nil + } + } +} + +// poll performs a single DeepSeek poll cycle. +func (a *DeepSeekAgent) poll(ctx context.Context) { + if a.pollingCheck != nil && !a.pollingCheck() { + return // polling disabled for this provider + } + + resp, err := a.client.FetchBalance(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + a.logger.Error("Failed to fetch DeepSeek balance", "error", err) + return + } + + if !resp.IsAvailable { + a.logger.Info("DeepSeek service is currently not available") + return + } + + now := time.Now().UTC() + snapshot := resp.ToSnapshot(now) + + if _, err := a.store.InsertDeepSeekSnapshot(snapshot); err != nil { + a.logger.Error("Failed to insert DeepSeek snapshot", "error", err) + return + } + + if a.tracker != nil { + if err := a.tracker.Process(snapshot); err != nil { + a.logger.Error("DeepSeek tracker processing failed", "error", err) + } + } + + // Report to session manager for usage-based session detection + // Inverting for balance: smaller balance means usage + if a.sm != nil { + a.sm.ReportPoll([]float64{ + -snapshot.TotalBalance, + }) + } + + a.logger.Info("DeepSeek poll complete", + "total_balance", snapshot.TotalBalance, + "currency", snapshot.Currency, + ) +} diff --git a/internal/agent/moonshot_agent.go b/internal/agent/moonshot_agent.go new file mode 100644 index 0000000..bbeed85 --- /dev/null +++ b/internal/agent/moonshot_agent.go @@ -0,0 +1,117 @@ +package agent + +import ( + "context" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/notify" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/onllm-dev/onwatch/v2/internal/tracker" +) + +// MoonshotAgent manages the background polling loop for Moonshot usage tracking. +type MoonshotAgent struct { + client *api.MoonshotClient + store *store.Store + tracker *tracker.MoonshotTracker + interval time.Duration + logger *slog.Logger + sm *SessionManager + notifier *notify.NotificationEngine + pollingCheck func() bool +} + +// SetPollingCheck sets a function that is called before each poll. +func (a *MoonshotAgent) SetPollingCheck(fn func() bool) { + a.pollingCheck = fn +} + +// SetNotifier sets the notification engine for sending alerts. +func (a *MoonshotAgent) SetNotifier(n *notify.NotificationEngine) { + a.notifier = n +} + +// NewMoonshotAgent creates a new MoonshotAgent with the given dependencies. +func NewMoonshotAgent(client *api.MoonshotClient, store *store.Store, tr *tracker.MoonshotTracker, interval time.Duration, logger *slog.Logger, sm *SessionManager) *MoonshotAgent { + if logger == nil { + logger = slog.Default() + } + return &MoonshotAgent{ + client: client, + store: store, + tracker: tr, + interval: interval, + logger: logger, + sm: sm, + } +} + +// Run starts the Moonshot agent's polling loop. +func (a *MoonshotAgent) Run(ctx context.Context) error { + a.logger.Info("Moonshot agent started", "interval", a.interval) + + defer func() { + if a.sm != nil { + a.sm.Close() + } + a.logger.Info("Moonshot agent stopped") + }() + + a.poll(ctx) + + ticker := time.NewTicker(a.interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.poll(ctx) + case <-ctx.Done(): + return nil + } + } +} + +// poll performs a single Moonshot poll cycle. +func (a *MoonshotAgent) poll(ctx context.Context) { + if a.pollingCheck != nil && !a.pollingCheck() { + return // polling disabled for this provider + } + + resp, err := a.client.FetchBalance(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + a.logger.Error("Failed to fetch Moonshot balance", "error", err) + return + } + + now := time.Now().UTC() + snapshot := resp.ToSnapshot(now) + + if _, err := a.store.InsertMoonshotSnapshot(snapshot); err != nil { + a.logger.Error("Failed to insert Moonshot snapshot", "error", err) + return + } + + if a.tracker != nil { + if err := a.tracker.Process(snapshot); err != nil { + a.logger.Error("Moonshot tracker processing failed", "error", err) + } + } + + // Report to session manager for usage-based session detection + // Inverting for balance: smaller balance means usage + if a.sm != nil { + a.sm.ReportPoll([]float64{ + -snapshot.AvailableBalance, + }) + } + + a.logger.Info("Moonshot poll complete", + "available_balance", snapshot.AvailableBalance, + ) +} diff --git a/internal/api/deepseek_client.go b/internal/api/deepseek_client.go new file mode 100644 index 0000000..b3d3a21 --- /dev/null +++ b/internal/api/deepseek_client.go @@ -0,0 +1,163 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +// Custom errors for DeepSeek API failures. +var ( + ErrDeepSeekUnauthorized = errors.New("deepseek: unauthorized - invalid API key") + ErrDeepSeekRateLimited = errors.New("deepseek: rate limited") + ErrDeepSeekServerError = errors.New("deepseek: server error") + ErrDeepSeekNetworkError = errors.New("deepseek: network error") + ErrDeepSeekInvalidResponse = errors.New("deepseek: invalid response") +) + +// DeepSeekClient is an HTTP client for the DeepSeek API. +type DeepSeekClient struct { + httpClient *http.Client + apiKey string + baseURL string + logger *slog.Logger +} + +// DeepSeekOption configures a DeepSeekClient. +type DeepSeekOption func(*DeepSeekClient) + +// WithDeepSeekBaseURL sets a custom base URL (for testing). +func WithDeepSeekBaseURL(url string) DeepSeekOption { + return func(c *DeepSeekClient) { + c.baseURL = url + } +} + +// WithDeepSeekTimeout sets a custom timeout (for testing). +func WithDeepSeekTimeout(timeout time.Duration) DeepSeekOption { + return func(c *DeepSeekClient) { + c.httpClient.Timeout = timeout + } +} + +// NewDeepSeekClient creates a new DeepSeek API client. +func NewDeepSeekClient(apiKey string, logger *slog.Logger, opts ...DeepSeekOption) *DeepSeekClient { + if logger == nil { + logger = slog.Default() + } + + client := &DeepSeekClient{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + apiKey: apiKey, + baseURL: "https://api.deepseek.com", + logger: logger, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +// FetchBalance retrieves the current balance information from the DeepSeek API. +func (c *DeepSeekClient) FetchBalance(ctx context.Context) (*DeepSeekBalanceResponse, error) { + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + url := c.baseURL + "/user/balance" + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("deepseek: creating request: %w", err) + } + + // Set headers - DeepSeek uses Bearer token authentication + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("User-Agent", "onwatch/1.0") + req.Header.Set("Accept", "application/json") + + // Log request (with redacted API key) + c.logger.Debug("fetching DeepSeek balance", + "url", url, + "api_key", redactDeepSeekAPIKey(c.apiKey), + ) + + resp, err := c.httpClient.Do(req) + if err != nil { + // Check for context cancellation + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrDeepSeekNetworkError, err) + } + defer resp.Body.Close() + + // Log response status + c.logger.Debug("DeepSeek balance response received", + "status", resp.StatusCode, + ) + + // Read response body (bounded to 64KB) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("%w: reading body: %v", ErrDeepSeekInvalidResponse, err) + } + + // Handle HTTP status codes + switch { + case resp.StatusCode == http.StatusOK: + // continue + case resp.StatusCode == http.StatusUnauthorized: + return nil, ErrDeepSeekUnauthorized + case resp.StatusCode == http.StatusTooManyRequests: + return nil, ErrDeepSeekRateLimited + case resp.StatusCode >= 500: + return nil, fmt.Errorf("%w: status %d", ErrDeepSeekServerError, resp.StatusCode) + default: + return nil, fmt.Errorf("deepseek: unexpected status code %d", resp.StatusCode) + } + + if len(body) == 0 { + return nil, fmt.Errorf("%w: empty response body", ErrDeepSeekInvalidResponse) + } + + balanceResp, err := ParseDeepSeekResponse(body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDeepSeekInvalidResponse, err) + } + + // Log usage info + c.logger.Debug("DeepSeek balance fetched successfully", + "is_available", balanceResp.IsAvailable, + ) + + return balanceResp, nil +} + +// redactDeepSeekAPIKey masks the API key for logging. +func redactDeepSeekAPIKey(key string) string { + if key == "" { + return "(empty)" + } + + if len(key) < 8 { + return "***...***" + } + + // Show first 4 chars and last 3 chars + return key[:4] + "***...***" + key[len(key)-3:] +} diff --git a/internal/api/deepseek_client_test.go b/internal/api/deepseek_client_test.go new file mode 100644 index 0000000..df6cbc7 --- /dev/null +++ b/internal/api/deepseek_client_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestDeepSeekClient(t *testing.T) { + tests := []struct { + name string + statusCode int + respBody string + wantErr error + check func(t *testing.T, resp *DeepSeekBalanceResponse) + }{ + { + name: "success", + statusCode: http.StatusOK, + respBody: `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"125.00","granted_balance":"25.00","topped_up_balance":"100.00"}]}`, + wantErr: nil, + check: func(t *testing.T, resp *DeepSeekBalanceResponse) { + if resp == nil { + t.Fatal("expected non-nil response") + } + if !resp.IsAvailable { + t.Errorf("expected IsAvailable true") + } + if len(resp.BalanceInfos) != 1 { + t.Fatalf("expected 1 balance info, got %d", len(resp.BalanceInfos)) + } + info := resp.BalanceInfos[0] + if info.Currency != "CNY" { + t.Errorf("expected currency CNY, got %s", info.Currency) + } + if info.TotalBalance != "125.00" { + t.Errorf("expected total balance 125.00, got %s", info.TotalBalance) + } + }, + }, + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + respBody: `{"error":"invalid token"}`, + wantErr: ErrDeepSeekUnauthorized, + }, + { + name: "rate limited", + statusCode: http.StatusTooManyRequests, + respBody: `{"error":"too many requests"}`, + wantErr: ErrDeepSeekRateLimited, + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + respBody: `{"error":"internal server error"}`, + wantErr: ErrDeepSeekServerError, + }, + { + name: "invalid json", + statusCode: http.StatusOK, + respBody: `{"is_available": {invalid json`, + wantErr: ErrDeepSeekInvalidResponse, + }, + { + name: "empty response", + statusCode: http.StatusOK, + respBody: ``, + wantErr: ErrDeepSeekInvalidResponse, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { + t.Errorf("expected Bearer test-key, got %s", auth) + } + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.respBody)) + })) + defer server.Close() + + client := NewDeepSeekClient("test-key", nil, WithDeepSeekBaseURL(server.URL)) + resp, err := client.FetchBalance(context.Background()) + + if tt.wantErr != nil { + if err == nil { + t.Fatalf("expected error %v, got nil", tt.wantErr) + } + if !errorsIs(err, tt.wantErr) { + t.Errorf("expected error %v, got %v", tt.wantErr, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, resp) + } + } + }) + } +} + +func TestDeepSeekTypes(t *testing.T) { + jsonBody := `{"is_available":true,"balance_infos":[{"currency":"USD","total_balance":"10.00","granted_balance":"0.00","topped_up_balance":"10.00"},{"currency":"CNY","total_balance":"125.00","granted_balance":"25.00","topped_up_balance":"100.00"}]}` + resp, err := ParseDeepSeekResponse([]byte(jsonBody)) + if err != nil { + t.Fatal(err) + } + + snap := resp.ToSnapshot(time.Now()) + + if snap.Currency != "CNY" { + t.Errorf("expected priority currency CNY, got %s", snap.Currency) + } + if snap.TotalBalance != 125.0 { + t.Errorf("expected total 125.0, got %f", snap.TotalBalance) + } +} diff --git a/internal/api/deepseek_types.go b/internal/api/deepseek_types.go new file mode 100644 index 0000000..2c67d69 --- /dev/null +++ b/internal/api/deepseek_types.go @@ -0,0 +1,76 @@ +package api + +import ( + "encoding/json" + "strconv" + "time" +) + +// DeepSeekBalanceInfo represents the balance data from DeepSeek API. +type DeepSeekBalanceInfo struct { + Currency string `json:"currency"` + TotalBalance string `json:"total_balance"` + GrantedBalance string `json:"granted_balance"` + ToppedUpBalance string `json:"topped_up_balance"` +} + +// DeepSeekBalanceResponse is the top-level response from GET /user/balance. +type DeepSeekBalanceResponse struct { + IsAvailable bool `json:"is_available"` + BalanceInfos []DeepSeekBalanceInfo `json:"balance_infos"` +} + +// DeepSeekSnapshot is the storage representation (flat, for SQLite). +type DeepSeekSnapshot struct { + ID int64 + CapturedAt time.Time + IsAvailable bool + Currency string + TotalBalance float64 + GrantedBalance float64 + ToppedUpBalance float64 +} + +// ToSnapshot converts DeepSeekBalanceResponse to DeepSeekSnapshot. +func (r *DeepSeekBalanceResponse) ToSnapshot(capturedAt time.Time) *DeepSeekSnapshot { + snapshot := &DeepSeekSnapshot{ + CapturedAt: capturedAt.UTC(), + IsAvailable: r.IsAvailable, + } + + if len(r.BalanceInfos) > 0 { + // Priority: CNY over USD if multiple, though usually it's just one or the other. + var info *DeepSeekBalanceInfo + for i := range r.BalanceInfos { + if r.BalanceInfos[i].Currency == "CNY" { + info = &r.BalanceInfos[i] + break + } + } + if info == nil { + info = &r.BalanceInfos[0] + } + + snapshot.Currency = info.Currency + if v, err := strconv.ParseFloat(info.TotalBalance, 64); err == nil { + snapshot.TotalBalance = v + } + if v, err := strconv.ParseFloat(info.GrantedBalance, 64); err == nil { + snapshot.GrantedBalance = v + } + if v, err := strconv.ParseFloat(info.ToppedUpBalance, 64); err == nil { + snapshot.ToppedUpBalance = v + } + } + + return snapshot +} + +// ParseDeepSeekResponse parses a DeepSeek API response from JSON bytes. +func ParseDeepSeekResponse(data []byte) (*DeepSeekBalanceResponse, error) { + var resp DeepSeekBalanceResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/internal/api/moonshot_client.go b/internal/api/moonshot_client.go new file mode 100644 index 0000000..9745974 --- /dev/null +++ b/internal/api/moonshot_client.go @@ -0,0 +1,165 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +// Custom errors for Moonshot API failures. +var ( + ErrMoonshotUnauthorized = errors.New("moonshot: unauthorized - invalid API key") + ErrMoonshotRateLimited = errors.New("moonshot: rate limited") + ErrMoonshotServerError = errors.New("moonshot: server error") + ErrMoonshotNetworkError = errors.New("moonshot: network error") + ErrMoonshotInvalidResponse = errors.New("moonshot: invalid response") +) + +// MoonshotClient is an HTTP client for the Moonshot API. +type MoonshotClient struct { + httpClient *http.Client + apiKey string + baseURL string + logger *slog.Logger +} + +// MoonshotOption configures a MoonshotClient. +type MoonshotOption func(*MoonshotClient) + +// WithMoonshotBaseURL sets a custom base URL (for testing). +func WithMoonshotBaseURL(url string) MoonshotOption { + return func(c *MoonshotClient) { + c.baseURL = url + } +} + +// WithMoonshotTimeout sets a custom timeout (for testing). +func WithMoonshotTimeout(timeout time.Duration) MoonshotOption { + return func(c *MoonshotClient) { + c.httpClient.Timeout = timeout + } +} + +// NewMoonshotClient creates a new Moonshot API client. +func NewMoonshotClient(apiKey string, logger *slog.Logger, opts ...MoonshotOption) *MoonshotClient { + if logger == nil { + logger = slog.Default() + } + + client := &MoonshotClient{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ForceAttemptHTTP2: true, + }, + }, + apiKey: apiKey, + baseURL: "https://api.moonshot.ai", + logger: logger, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +// FetchBalance retrieves the current balance information from the Moonshot API. +func (c *MoonshotClient) FetchBalance(ctx context.Context) (*MoonshotBalanceResponse, error) { + reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + url := c.baseURL + "/v1/users/me/balance" + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("moonshot: creating request: %w", err) + } + + // Set headers - Moonshot uses Bearer token authentication + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("User-Agent", "onwatch/1.0") + req.Header.Set("Accept", "application/json") + + // Log request (with redacted API key) + c.logger.Debug("fetching Moonshot balance", + "url", url, + "api_key", redactMoonshotAPIKey(c.apiKey), + ) + + resp, err := c.httpClient.Do(req) + if err != nil { + // Check for context cancellation + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("%w: %v", ErrMoonshotNetworkError, err) + } + defer resp.Body.Close() + + // Log response status + c.logger.Debug("Moonshot balance response received", + "status", resp.StatusCode, + ) + + // Read response body (bounded to 64KB) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("%w: reading body: %v", ErrMoonshotInvalidResponse, err) + } + + // Handle HTTP status codes + switch { + case resp.StatusCode == http.StatusOK: + // continue + case resp.StatusCode == http.StatusUnauthorized: + return nil, ErrMoonshotUnauthorized + case resp.StatusCode == http.StatusTooManyRequests: + return nil, ErrMoonshotRateLimited + case resp.StatusCode >= 500: + return nil, fmt.Errorf("%w: status %d", ErrMoonshotServerError, resp.StatusCode) + default: + return nil, fmt.Errorf("moonshot: unexpected status code %d", resp.StatusCode) + } + + if len(body) == 0 { + return nil, fmt.Errorf("%w: empty response body", ErrMoonshotInvalidResponse) + } + + balanceResp, err := ParseMoonshotResponse(body) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrMoonshotInvalidResponse, err) + } + + // Log usage info + c.logger.Debug("Moonshot balance fetched successfully", + "available_balance", balanceResp.Data.AvailableBalance, + "voucher_balance", balanceResp.Data.VoucherBalance, + "cash_balance", balanceResp.Data.CashBalance, + ) + + return balanceResp, nil +} + +// redactMoonshotAPIKey masks the API key for logging. +func redactMoonshotAPIKey(key string) string { + if key == "" { + return "(empty)" + } + + if len(key) < 8 { + return "***...***" + } + + // Show first 4 chars and last 3 chars + return key[:4] + "***...***" + key[len(key)-3:] +} diff --git a/internal/api/moonshot_client_test.go b/internal/api/moonshot_client_test.go new file mode 100644 index 0000000..2242d86 --- /dev/null +++ b/internal/api/moonshot_client_test.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMoonshotClient(t *testing.T) { + tests := []struct { + name string + statusCode int + respBody string + wantErr error + check func(t *testing.T, resp *MoonshotBalanceResponse) + }{ + { + name: "success", + statusCode: http.StatusOK, + respBody: `{"code":0,"data":{"available_balance":100.5,"voucher_balance":20.0,"cash_balance":80.5}}`, + wantErr: nil, + check: func(t *testing.T, resp *MoonshotBalanceResponse) { + if resp == nil { + t.Fatal("expected non-nil response") + } + if resp.Code != 0 { + t.Errorf("expected code 0, got %d", resp.Code) + } + if resp.Data.AvailableBalance != 100.5 { + t.Errorf("expected available 100.5, got %v", resp.Data.AvailableBalance) + } + if resp.Data.VoucherBalance != 20.0 { + t.Errorf("expected voucher 20.0, got %v", resp.Data.VoucherBalance) + } + if resp.Data.CashBalance != 80.5 { + t.Errorf("expected cash 80.5, got %v", resp.Data.CashBalance) + } + }, + }, + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + respBody: `{"error":"invalid token"}`, + wantErr: ErrMoonshotUnauthorized, + }, + { + name: "rate limited", + statusCode: http.StatusTooManyRequests, + respBody: `{"error":"too many requests"}`, + wantErr: ErrMoonshotRateLimited, + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + respBody: `{"error":"internal server error"}`, + wantErr: ErrMoonshotServerError, + }, + { + name: "invalid json", + statusCode: http.StatusOK, + respBody: `{"data": {invalid json`, + wantErr: ErrMoonshotInvalidResponse, + }, + { + name: "empty response", + statusCode: http.StatusOK, + respBody: ``, + wantErr: ErrMoonshotInvalidResponse, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { + t.Errorf("expected Bearer test-key, got %s", auth) + } + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.respBody)) + })) + defer server.Close() + + client := NewMoonshotClient("test-key", nil, WithMoonshotBaseURL(server.URL)) + resp, err := client.FetchBalance(context.Background()) + + if tt.wantErr != nil { + if err == nil { + t.Fatalf("expected error %v, got nil", tt.wantErr) + } + if !errorsIs(err, tt.wantErr) { + t.Errorf("expected error %v, got %v", tt.wantErr, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.check != nil { + tt.check(t, resp) + } + } + }) + } +} + +func errorsIs(err, target error) bool { + if err == target { + return true + } + return err != nil && target != nil && err.Error() == target.Error() || (err != nil && target != nil && err.Error() != "" && target.Error() != "" && err.Error()[:len(target.Error())] == target.Error()) +} diff --git a/internal/api/moonshot_types.go b/internal/api/moonshot_types.go new file mode 100644 index 0000000..fd074f3 --- /dev/null +++ b/internal/api/moonshot_types.go @@ -0,0 +1,47 @@ +package api + +import ( + "encoding/json" + "time" +) + +// MoonshotBalanceData represents the balance data from Moonshot API. +type MoonshotBalanceData struct { + AvailableBalance float64 `json:"available_balance"` + VoucherBalance float64 `json:"voucher_balance"` + CashBalance float64 `json:"cash_balance"` +} + +// MoonshotBalanceResponse is the top-level response from GET /v1/users/me/balance. +type MoonshotBalanceResponse struct { + Code int `json:"code"` + Data MoonshotBalanceData `json:"data"` +} + +// MoonshotSnapshot is the storage representation (flat, for SQLite). +type MoonshotSnapshot struct { + ID int64 + CapturedAt time.Time + AvailableBalance float64 + VoucherBalance float64 + CashBalance float64 +} + +// ToSnapshot converts MoonshotBalanceResponse to MoonshotSnapshot. +func (r *MoonshotBalanceResponse) ToSnapshot(capturedAt time.Time) *MoonshotSnapshot { + return &MoonshotSnapshot{ + CapturedAt: capturedAt.UTC(), + AvailableBalance: r.Data.AvailableBalance, + VoucherBalance: r.Data.VoucherBalance, + CashBalance: r.Data.CashBalance, + } +} + +// ParseMoonshotResponse parses a Moonshot API response from JSON bytes. +func ParseMoonshotResponse(data []byte) (*MoonshotBalanceResponse, error) { + var resp MoonshotBalanceResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 2540799..0c95959 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,12 @@ type Config struct { // OpenRouter provider configuration OpenRouterAPIKey string // OPENROUTER_API_KEY + // Moonshot provider configuration + MoonshotAPIKey string // MOONSHOT_API_KEY + + // DeepSeek provider configuration + DeepSeekAPIKey string // DEEPSEEK_API_KEY + // Gemini provider configuration (auto-detected from ~/.gemini/oauth_creds.json or env vars) GeminiEnabled bool // true if auto-detected or GEMINI_ENABLED=true GeminiAutoToken bool // true if token was auto-detected @@ -189,6 +195,8 @@ var onwatchEnvKeys = []string{ "ANTIGRAVITY_ENABLED", "MINIMAX_API_KEY", "OPENROUTER_API_KEY", + "MOONSHOT_API_KEY", + "DEEPSEEK_API_KEY", "CURSOR_TOKEN", "GEMINI_ENABLED", "GEMINI_REFRESH_TOKEN", @@ -297,6 +305,12 @@ func loadFromEnvAndFlags(flags *flagValues) (*Config, error) { // OpenRouter provider cfg.OpenRouterAPIKey = strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")) + + // Moonshot provider + cfg.MoonshotAPIKey = strings.TrimSpace(os.Getenv("MOONSHOT_API_KEY")) + + // DeepSeek provider + cfg.DeepSeekAPIKey = strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY")) // Gemini provider (auto-detected, env vars, or opt-out via GEMINI_ENABLED=false) cfg.GeminiRefreshToken = strings.TrimSpace(os.Getenv("GEMINI_REFRESH_TOKEN")) @@ -513,6 +527,12 @@ func (c *Config) AvailableProviders() []string { if c.OpenRouterAPIKey != "" { providers = append(providers, "openrouter") } + if c.MoonshotAPIKey != "" { + providers = append(providers, "moonshot") + } + if c.DeepSeekAPIKey != "" { + providers = append(providers, "deepseek") + } if c.GeminiEnabled { providers = append(providers, "gemini") } @@ -541,6 +561,10 @@ func (c *Config) HasProvider(name string) bool { return c.MiniMaxAPIKey != "" case "openrouter": return c.OpenRouterAPIKey != "" + case "moonshot": + return c.MoonshotAPIKey != "" + case "deepseek": + return c.DeepSeekAPIKey != "" case "gemini": return c.GeminiEnabled case "cursor": @@ -576,6 +600,12 @@ func (c *Config) HasMultipleProviders() bool { if c.OpenRouterAPIKey != "" { count++ } + if c.MoonshotAPIKey != "" { + count++ + } + if c.DeepSeekAPIKey != "" { + count++ + } if c.GeminiEnabled { count++ } @@ -621,6 +651,15 @@ func (c *Config) String() string { // Redact MiniMax token minimaxDisplay := redactAPIKey(c.MiniMaxAPIKey, "") fmt.Fprintf(&sb, " MiniMaxAPIKey: %s,\n", minimaxDisplay) + + // Redact Moonshot token + moonshotDisplay := redactAPIKey(c.MoonshotAPIKey, "") + fmt.Fprintf(&sb, " MoonshotAPIKey: %s,\n", moonshotDisplay) + + // Redact DeepSeek token + deepseekDisplay := redactAPIKey(c.DeepSeekAPIKey, "") + fmt.Fprintf(&sb, " DeepSeekAPIKey: %s,\n", deepseekDisplay) + fmt.Fprintf(&sb, " APIIntegrationsEnabled: %v,\n", c.APIIntegrationsEnabled) fmt.Fprintf(&sb, " APIIntegrationsDir: %s,\n", c.APIIntegrationsDir) fmt.Fprintf(&sb, " APIIntegrationsRetention: %v,\n", c.APIIntegrationsRetention) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index c597b2b..8c882bc 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -256,6 +256,8 @@ func (m *Metrics) Scrape(s *store.Store, pollInterval time.Duration) { m.scrapeAntigravity(s, staleThreshold) m.scrapeGemini(s, staleThreshold) m.scrapeOpenRouter(s, staleThreshold) + m.scrapeMoonshot(s, staleThreshold) + m.scrapeDeepSeek(s, staleThreshold) m.scrapeAPIIntegrations(s, staleThreshold) } @@ -571,6 +573,75 @@ func (m *Metrics) scrapeOpenRouter(s *store.Store, staleThreshold time.Duration) } } +func (m *Metrics) scrapeMoonshot(s *store.Store, staleThreshold time.Duration) { + method := "moonshot" + + snap, err := s.QueryLatestMoonshot() + if err != nil { + m.scrapeErrorsTotal.WithLabelValues(method, "query_failed").Inc() + return + } + if snap == nil { + return + } + + m.recordLastCycleAge(method, defaultAccountID, snap.CapturedAt, staleThreshold) + + m.creditsBalance.With(prometheus.Labels{ + "provider": method, + "account_id": defaultAccountID, + "unit": "cny_available", + }).Set(snap.AvailableBalance) + m.creditsBalance.With(prometheus.Labels{ + "provider": method, + "account_id": defaultAccountID, + "unit": "cny_voucher", + }).Set(snap.VoucherBalance) + m.creditsBalance.With(prometheus.Labels{ + "provider": method, + "account_id": defaultAccountID, + "unit": "cny_cash", + }).Set(snap.CashBalance) +} + +func (m *Metrics) scrapeDeepSeek(s *store.Store, staleThreshold time.Duration) { + method := "deepseek" + + snap, err := s.QueryLatestDeepSeek() + if err != nil { + m.scrapeErrorsTotal.WithLabelValues(method, "query_failed").Inc() + return + } + if snap == nil { + return + } + + m.recordLastCycleAge(method, defaultAccountID, snap.CapturedAt, staleThreshold) + + unitPrefix := "cny" + if snap.Currency == "USD" { + unitPrefix = "usd" + } else if snap.Currency != "CNY" && snap.Currency != "" { + unitPrefix = snap.Currency + } + + m.creditsBalance.With(prometheus.Labels{ + "provider": method, + "account_id": defaultAccountID, + "unit": unitPrefix + "_total", + }).Set(snap.TotalBalance) + m.creditsBalance.With(prometheus.Labels{ + "provider": method, + "account_id": defaultAccountID, + "unit": unitPrefix + "_granted", + }).Set(snap.GrantedBalance) + m.creditsBalance.With(prometheus.Labels{ + "provider": method, + "account_id": defaultAccountID, + "unit": unitPrefix + "_topped_up", + }).Set(snap.ToppedUpBalance) +} + // RecordCycleCompleted increments the successful-poll counter. // Safe to call on a nil receiver (no-op), so agents can be instantiated // without wiring metrics. diff --git a/internal/store/deepseek_store.go b/internal/store/deepseek_store.go new file mode 100644 index 0000000..3cd2532 --- /dev/null +++ b/internal/store/deepseek_store.go @@ -0,0 +1,235 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +// DeepSeekResetCycle represents a DeepSeek usage reset cycle. +type DeepSeekResetCycle struct { + ID int64 + QuotaType string + Currency string + CycleStart time.Time + CycleEnd *time.Time + PeakUsage float64 + TotalDelta float64 +} + +// InsertDeepSeekSnapshot inserts a DeepSeek usage snapshot. +func (s *Store) InsertDeepSeekSnapshot(snapshot *api.DeepSeekSnapshot) (int64, error) { + isAvailable := 0 + if snapshot.IsAvailable { + isAvailable = 1 + } + + result, err := s.db.Exec( + `INSERT INTO deepseek_snapshots + (captured_at, is_available, currency, total_balance, granted_balance, topped_up_balance) + VALUES (?, ?, ?, ?, ?, ?)`, + snapshot.CapturedAt.Format(time.RFC3339Nano), + isAvailable, + snapshot.Currency, + snapshot.TotalBalance, + snapshot.GrantedBalance, + snapshot.ToppedUpBalance, + ) + if err != nil { + return 0, fmt.Errorf("failed to insert deepseek snapshot: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get last insert ID: %w", err) + } + + return id, nil +} + +// QueryLatestDeepSeek returns the most recent DeepSeek snapshot. +func (s *Store) QueryLatestDeepSeek() (*api.DeepSeekSnapshot, error) { + var snapshot api.DeepSeekSnapshot + var capturedAt string + var isAvailable int + + err := s.db.QueryRow( + `SELECT id, captured_at, is_available, currency, total_balance, granted_balance, topped_up_balance + FROM deepseek_snapshots ORDER BY captured_at DESC LIMIT 1`, + ).Scan( + &snapshot.ID, &capturedAt, &isAvailable, &snapshot.Currency, + &snapshot.TotalBalance, &snapshot.GrantedBalance, &snapshot.ToppedUpBalance, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query latest deepseek: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snapshot.IsAvailable = isAvailable != 0 + + return &snapshot, nil +} + +// QueryDeepSeekRange returns DeepSeek snapshots within a time range with optional limit. +func (s *Store) QueryDeepSeekRange(start, end time.Time, limit ...int) ([]*api.DeepSeekSnapshot, error) { + query := `SELECT id, captured_at, is_available, currency, total_balance, granted_balance, topped_up_balance + FROM deepseek_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at ASC` + args := []interface{}{start.Format(time.RFC3339Nano), end.Format(time.RFC3339Nano)} + if len(limit) > 0 && limit[0] > 0 { + query = `SELECT id, captured_at, is_available, currency, total_balance, granted_balance, topped_up_balance + FROM ( + SELECT id, captured_at, is_available, currency, total_balance, granted_balance, topped_up_balance + FROM deepseek_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at DESC + LIMIT ? + ) recent + ORDER BY captured_at ASC` + args = append(args, limit[0]) + } + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query deepseek range: %w", err) + } + defer rows.Close() + + var snapshots []*api.DeepSeekSnapshot + for rows.Next() { + var snapshot api.DeepSeekSnapshot + var capturedAt string + var isAvailable int + + err := rows.Scan( + &snapshot.ID, &capturedAt, &isAvailable, &snapshot.Currency, + &snapshot.TotalBalance, &snapshot.GrantedBalance, &snapshot.ToppedUpBalance, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan deepseek snapshot: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snapshot.IsAvailable = isAvailable != 0 + snapshots = append(snapshots, &snapshot) + } + + return snapshots, rows.Err() +} + +// CreateDeepSeekCycle creates a new DeepSeek reset cycle. +func (s *Store) CreateDeepSeekCycle(quotaType string, currency string, cycleStart time.Time) (int64, error) { + result, err := s.db.Exec( + `INSERT INTO deepseek_reset_cycles (quota_type, currency, cycle_start) VALUES (?, ?, ?)`, + quotaType, currency, cycleStart.Format(time.RFC3339Nano), + ) + if err != nil { + return 0, fmt.Errorf("failed to create deepseek cycle: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get cycle ID: %w", err) + } + + return id, nil +} + +// CloseDeepSeekCycle closes a DeepSeek reset cycle with final stats. +func (s *Store) CloseDeepSeekCycle(quotaType string, currency string, cycleEnd time.Time, peakUsage, totalDelta float64) error { + _, err := s.db.Exec( + `UPDATE deepseek_reset_cycles SET cycle_end = ?, peak_usage = ?, total_delta = ? + WHERE quota_type = ? AND currency = ? AND cycle_end IS NULL`, + cycleEnd.Format(time.RFC3339Nano), peakUsage, totalDelta, quotaType, currency, + ) + if err != nil { + return fmt.Errorf("failed to close deepseek cycle: %w", err) + } + return nil +} + +// UpdateDeepSeekCycle updates the peak and delta for an active DeepSeek cycle. +func (s *Store) UpdateDeepSeekCycle(quotaType string, currency string, peakUsage, totalDelta float64) error { + _, err := s.db.Exec( + `UPDATE deepseek_reset_cycles SET peak_usage = ?, total_delta = ? + WHERE quota_type = ? AND currency = ? AND cycle_end IS NULL`, + peakUsage, totalDelta, quotaType, currency, + ) + if err != nil { + return fmt.Errorf("failed to update deepseek cycle: %w", err) + } + return nil +} + +// QueryActiveDeepSeekCycle returns the active cycle for a DeepSeek quota type and currency. +func (s *Store) QueryActiveDeepSeekCycle(quotaType string, currency string) (*DeepSeekResetCycle, error) { + var cycle DeepSeekResetCycle + var cycleStart string + var cycleEnd sql.NullString + + err := s.db.QueryRow( + `SELECT id, quota_type, currency, cycle_start, cycle_end, peak_usage, total_delta + FROM deepseek_reset_cycles WHERE quota_type = ? AND currency = ? AND cycle_end IS NULL`, + quotaType, currency, + ).Scan( + &cycle.ID, &cycle.QuotaType, &cycle.Currency, &cycleStart, &cycleEnd, &cycle.PeakUsage, &cycle.TotalDelta, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query active deepseek cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + if cycleEnd.Valid { + endTime, _ := time.Parse(time.RFC3339Nano, cycleEnd.String) + cycle.CycleEnd = &endTime + } + + return &cycle, nil +} + +// QueryDeepSeekCycleHistory returns completed cycles for a DeepSeek quota type with optional limit. +func (s *Store) QueryDeepSeekCycleHistory(quotaType string, currency string, limit ...int) ([]*DeepSeekResetCycle, error) { + query := `SELECT id, quota_type, currency, cycle_start, cycle_end, peak_usage, total_delta + FROM deepseek_reset_cycles WHERE quota_type = ? AND currency = ? AND cycle_end IS NOT NULL ORDER BY cycle_start DESC` + args := []interface{}{quotaType, currency} + if len(limit) > 0 && limit[0] > 0 { + query += ` LIMIT ?` + args = append(args, limit[0]) + } + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query deepseek cycles: %w", err) + } + defer rows.Close() + + var cycles []*DeepSeekResetCycle + for rows.Next() { + var cycle DeepSeekResetCycle + var cycleStart, cycleEnd string + + err := rows.Scan( + &cycle.ID, &cycle.QuotaType, &cycle.Currency, &cycleStart, &cycleEnd, &cycle.PeakUsage, &cycle.TotalDelta, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan deepseek cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + endTime, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &endTime + + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} diff --git a/internal/store/deepseek_store_test.go b/internal/store/deepseek_store_test.go new file mode 100644 index 0000000..b3af9db --- /dev/null +++ b/internal/store/deepseek_store_test.go @@ -0,0 +1,109 @@ +package store + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +func TestDeepSeekStore(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("failed to create test store: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + + snap1 := &api.DeepSeekSnapshot{ + CapturedAt: now.Add(-time.Hour), + IsAvailable: true, + Currency: "CNY", + TotalBalance: 125.0, + GrantedBalance: 25.0, + ToppedUpBalance: 100.0, + } + + snap2 := &api.DeepSeekSnapshot{ + CapturedAt: now, + IsAvailable: true, + Currency: "CNY", + TotalBalance: 120.0, + GrantedBalance: 20.0, + ToppedUpBalance: 100.0, + } + + id1, err := s.InsertDeepSeekSnapshot(snap1) + if err != nil { + t.Fatalf("failed to insert snapshot 1: %v", err) + } + if id1 == 0 { + t.Error("expected non-zero id for snapshot 1") + } + + id2, err := s.InsertDeepSeekSnapshot(snap2) + if err != nil { + t.Fatalf("failed to insert snapshot 2: %v", err) + } + if id2 == 0 { + t.Error("expected non-zero id for snapshot 2") + } + + latest, err := s.QueryLatestDeepSeek() + if err != nil { + t.Fatalf("failed to query latest: %v", err) + } + if latest == nil { + t.Fatal("expected latest snapshot, got nil") + } + if latest.TotalBalance != snap2.TotalBalance { + t.Errorf("expected total balance %v, got %v", snap2.TotalBalance, latest.TotalBalance) + } + if latest.Currency != "CNY" { + t.Errorf("expected currency CNY, got %s", latest.Currency) + } + + rangeSnaps, err := s.QueryDeepSeekRange(now.Add(-2*time.Hour), now.Add(time.Hour)) + if err != nil { + t.Fatalf("failed to query range: %v", err) + } + if len(rangeSnaps) != 2 { + t.Errorf("expected 2 snapshots in range, got %d", len(rangeSnaps)) + } + + // Cycles + cycleID, err := s.CreateDeepSeekCycle("balance", "CNY", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("failed to create cycle: %v", err) + } + + activeCycle, err := s.QueryActiveDeepSeekCycle("balance", "CNY") + if err != nil { + t.Fatalf("failed to query active cycle: %v", err) + } + if activeCycle == nil { + t.Fatal("expected active cycle, got nil") + } + if activeCycle.ID != cycleID { + t.Errorf("expected cycle ID %d, got %d", cycleID, activeCycle.ID) + } + + err = s.UpdateDeepSeekCycle("balance", "CNY", 125.0, 5.0) + if err != nil { + t.Fatalf("failed to update cycle: %v", err) + } + + err = s.CloseDeepSeekCycle("balance", "CNY", now, 125.0, 5.0) + if err != nil { + t.Fatalf("failed to close cycle: %v", err) + } + + history, err := s.QueryDeepSeekCycleHistory("balance", "CNY") + if err != nil { + t.Fatalf("failed to query cycle history: %v", err) + } + if len(history) != 1 { + t.Errorf("expected 1 completed cycle, got %d", len(history)) + } +} diff --git a/internal/store/moonshot_store.go b/internal/store/moonshot_store.go new file mode 100644 index 0000000..de26138 --- /dev/null +++ b/internal/store/moonshot_store.go @@ -0,0 +1,221 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +// MoonshotResetCycle represents a Moonshot usage reset cycle. +type MoonshotResetCycle struct { + ID int64 + QuotaType string + CycleStart time.Time + CycleEnd *time.Time + PeakUsage float64 + TotalDelta float64 +} + +// InsertMoonshotSnapshot inserts a Moonshot usage snapshot. +func (s *Store) InsertMoonshotSnapshot(snapshot *api.MoonshotSnapshot) (int64, error) { + result, err := s.db.Exec( + `INSERT INTO moonshot_snapshots + (captured_at, available_balance, voucher_balance, cash_balance) + VALUES (?, ?, ?, ?)`, + snapshot.CapturedAt.Format(time.RFC3339Nano), + snapshot.AvailableBalance, + snapshot.VoucherBalance, + snapshot.CashBalance, + ) + if err != nil { + return 0, fmt.Errorf("failed to insert moonshot snapshot: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get last insert ID: %w", err) + } + + return id, nil +} + +// QueryLatestMoonshot returns the most recent Moonshot snapshot. +func (s *Store) QueryLatestMoonshot() (*api.MoonshotSnapshot, error) { + var snapshot api.MoonshotSnapshot + var capturedAt string + + err := s.db.QueryRow( + `SELECT id, captured_at, available_balance, voucher_balance, cash_balance + FROM moonshot_snapshots ORDER BY captured_at DESC LIMIT 1`, + ).Scan( + &snapshot.ID, &capturedAt, &snapshot.AvailableBalance, &snapshot.VoucherBalance, &snapshot.CashBalance, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query latest moonshot: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + + return &snapshot, nil +} + +// QueryMoonshotRange returns Moonshot snapshots within a time range with optional limit. +func (s *Store) QueryMoonshotRange(start, end time.Time, limit ...int) ([]*api.MoonshotSnapshot, error) { + query := `SELECT id, captured_at, available_balance, voucher_balance, cash_balance + FROM moonshot_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at ASC` + args := []interface{}{start.Format(time.RFC3339Nano), end.Format(time.RFC3339Nano)} + if len(limit) > 0 && limit[0] > 0 { + query = `SELECT id, captured_at, available_balance, voucher_balance, cash_balance + FROM ( + SELECT id, captured_at, available_balance, voucher_balance, cash_balance + FROM moonshot_snapshots + WHERE captured_at BETWEEN ? AND ? + ORDER BY captured_at DESC + LIMIT ? + ) recent + ORDER BY captured_at ASC` + args = append(args, limit[0]) + } + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query moonshot range: %w", err) + } + defer rows.Close() + + var snapshots []*api.MoonshotSnapshot + for rows.Next() { + var snapshot api.MoonshotSnapshot + var capturedAt string + + err := rows.Scan( + &snapshot.ID, &capturedAt, &snapshot.AvailableBalance, &snapshot.VoucherBalance, &snapshot.CashBalance, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan moonshot snapshot: %w", err) + } + + snapshot.CapturedAt, _ = time.Parse(time.RFC3339Nano, capturedAt) + snapshots = append(snapshots, &snapshot) + } + + return snapshots, rows.Err() +} + +// CreateMoonshotCycle creates a new Moonshot reset cycle. +func (s *Store) CreateMoonshotCycle(quotaType string, cycleStart time.Time) (int64, error) { + result, err := s.db.Exec( + `INSERT INTO moonshot_reset_cycles (quota_type, cycle_start) VALUES (?, ?)`, + quotaType, cycleStart.Format(time.RFC3339Nano), + ) + if err != nil { + return 0, fmt.Errorf("failed to create moonshot cycle: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return 0, fmt.Errorf("failed to get cycle ID: %w", err) + } + + return id, nil +} + +// CloseMoonshotCycle closes a Moonshot reset cycle with final stats. +func (s *Store) CloseMoonshotCycle(quotaType string, cycleEnd time.Time, peakUsage, totalDelta float64) error { + _, err := s.db.Exec( + `UPDATE moonshot_reset_cycles SET cycle_end = ?, peak_usage = ?, total_delta = ? + WHERE quota_type = ? AND cycle_end IS NULL`, + cycleEnd.Format(time.RFC3339Nano), peakUsage, totalDelta, quotaType, + ) + if err != nil { + return fmt.Errorf("failed to close moonshot cycle: %w", err) + } + return nil +} + +// UpdateMoonshotCycle updates the peak and delta for an active Moonshot cycle. +func (s *Store) UpdateMoonshotCycle(quotaType string, peakUsage, totalDelta float64) error { + _, err := s.db.Exec( + `UPDATE moonshot_reset_cycles SET peak_usage = ?, total_delta = ? + WHERE quota_type = ? AND cycle_end IS NULL`, + peakUsage, totalDelta, quotaType, + ) + if err != nil { + return fmt.Errorf("failed to update moonshot cycle: %w", err) + } + return nil +} + +// QueryActiveMoonshotCycle returns the active cycle for a Moonshot quota type. +func (s *Store) QueryActiveMoonshotCycle(quotaType string) (*MoonshotResetCycle, error) { + var cycle MoonshotResetCycle + var cycleStart string + var cycleEnd sql.NullString + + err := s.db.QueryRow( + `SELECT id, quota_type, cycle_start, cycle_end, peak_usage, total_delta + FROM moonshot_reset_cycles WHERE quota_type = ? AND cycle_end IS NULL`, + quotaType, + ).Scan( + &cycle.ID, &cycle.QuotaType, &cycleStart, &cycleEnd, &cycle.PeakUsage, &cycle.TotalDelta, + ) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to query active moonshot cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + if cycleEnd.Valid { + endTime, _ := time.Parse(time.RFC3339Nano, cycleEnd.String) + cycle.CycleEnd = &endTime + } + + return &cycle, nil +} + +// QueryMoonshotCycleHistory returns completed cycles for a Moonshot quota type with optional limit. +func (s *Store) QueryMoonshotCycleHistory(quotaType string, limit ...int) ([]*MoonshotResetCycle, error) { + query := `SELECT id, quota_type, cycle_start, cycle_end, peak_usage, total_delta + FROM moonshot_reset_cycles WHERE quota_type = ? AND cycle_end IS NOT NULL ORDER BY cycle_start DESC` + args := []interface{}{quotaType} + if len(limit) > 0 && limit[0] > 0 { + query += ` LIMIT ?` + args = append(args, limit[0]) + } + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query moonshot cycles: %w", err) + } + defer rows.Close() + + var cycles []*MoonshotResetCycle + for rows.Next() { + var cycle MoonshotResetCycle + var cycleStart, cycleEnd string + + err := rows.Scan( + &cycle.ID, &cycle.QuotaType, &cycleStart, &cycleEnd, &cycle.PeakUsage, &cycle.TotalDelta, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan moonshot cycle: %w", err) + } + + cycle.CycleStart, _ = time.Parse(time.RFC3339Nano, cycleStart) + endTime, _ := time.Parse(time.RFC3339Nano, cycleEnd) + cycle.CycleEnd = &endTime + + cycles = append(cycles, &cycle) + } + + return cycles, rows.Err() +} diff --git a/internal/store/moonshot_store_test.go b/internal/store/moonshot_store_test.go new file mode 100644 index 0000000..83c8456 --- /dev/null +++ b/internal/store/moonshot_store_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +func TestMoonshotStore(t *testing.T) { + s, err := New(":memory:") + if err != nil { + t.Fatalf("failed to create test store: %v", err) + } + defer s.Close() + + now := time.Now().UTC() + + snap1 := &api.MoonshotSnapshot{ + CapturedAt: now.Add(-time.Hour), + AvailableBalance: 100.0, + VoucherBalance: 20.0, + CashBalance: 80.0, + } + + snap2 := &api.MoonshotSnapshot{ + CapturedAt: now, + AvailableBalance: 90.0, + VoucherBalance: 15.0, + CashBalance: 75.0, + } + + id1, err := s.InsertMoonshotSnapshot(snap1) + if err != nil { + t.Fatalf("failed to insert snapshot 1: %v", err) + } + if id1 == 0 { + t.Error("expected non-zero id for snapshot 1") + } + + id2, err := s.InsertMoonshotSnapshot(snap2) + if err != nil { + t.Fatalf("failed to insert snapshot 2: %v", err) + } + if id2 == 0 { + t.Error("expected non-zero id for snapshot 2") + } + + latest, err := s.QueryLatestMoonshot() + if err != nil { + t.Fatalf("failed to query latest: %v", err) + } + if latest == nil { + t.Fatal("expected latest snapshot, got nil") + } + if latest.AvailableBalance != snap2.AvailableBalance { + t.Errorf("expected available balance %v, got %v", snap2.AvailableBalance, latest.AvailableBalance) + } + + rangeSnaps, err := s.QueryMoonshotRange(now.Add(-2*time.Hour), now.Add(time.Hour)) + if err != nil { + t.Fatalf("failed to query range: %v", err) + } + if len(rangeSnaps) != 2 { + t.Errorf("expected 2 snapshots in range, got %d", len(rangeSnaps)) + } + + // Cycles + cycleID, err := s.CreateMoonshotCycle("balance", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("failed to create cycle: %v", err) + } + + activeCycle, err := s.QueryActiveMoonshotCycle("balance") + if err != nil { + t.Fatalf("failed to query active cycle: %v", err) + } + if activeCycle == nil { + t.Fatal("expected active cycle, got nil") + } + if activeCycle.ID != cycleID { + t.Errorf("expected cycle ID %d, got %d", cycleID, activeCycle.ID) + } + + err = s.UpdateMoonshotCycle("balance", 100.0, 10.0) + if err != nil { + t.Fatalf("failed to update cycle: %v", err) + } + + err = s.CloseMoonshotCycle("balance", now, 100.0, 10.0) + if err != nil { + t.Fatalf("failed to close cycle: %v", err) + } + + history, err := s.QueryMoonshotCycleHistory("balance") + if err != nil { + t.Fatalf("failed to query cycle history: %v", err) + } + if len(history) != 1 { + t.Errorf("expected 1 completed cycle, got %d", len(history)) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index fabe1df..445ed5f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -514,6 +514,39 @@ func (s *Store) createTables() error { peak_usage REAL NOT NULL DEFAULT 0, total_delta REAL NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS moonshot_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at DATETIME NOT NULL, + available_balance REAL DEFAULT 0, + voucher_balance REAL DEFAULT 0, + cash_balance REAL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS moonshot_reset_cycles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quota_type TEXT NOT NULL, + cycle_start DATETIME NOT NULL, + cycle_end DATETIME, + peak_usage REAL DEFAULT 0, + total_delta REAL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS deepseek_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + captured_at DATETIME NOT NULL, + is_available INTEGER NOT NULL DEFAULT 1, + currency TEXT NOT NULL DEFAULT 'CNY', + total_balance REAL DEFAULT 0, + granted_balance REAL DEFAULT 0, + topped_up_balance REAL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS deepseek_reset_cycles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quota_type TEXT NOT NULL, + currency TEXT NOT NULL DEFAULT 'CNY', + cycle_start DATETIME NOT NULL, + cycle_end DATETIME, + peak_usage REAL DEFAULT 0, + total_delta REAL DEFAULT 0 + ); -- Antigravity indexes CREATE INDEX IF NOT EXISTS idx_antigravity_snapshots_captured ON antigravity_snapshots(captured_at); diff --git a/internal/tracker/deepseek_tracker.go b/internal/tracker/deepseek_tracker.go new file mode 100644 index 0000000..f143732 --- /dev/null +++ b/internal/tracker/deepseek_tracker.go @@ -0,0 +1,212 @@ +package tracker + +import ( + "fmt" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// DeepSeekTracker manages reset cycle detection and usage calculation for DeepSeek. +type DeepSeekTracker struct { + store *store.Store + logger *slog.Logger + + // Cache last seen values for delta calculation + lastBalance float64 + hasLastValues bool + + onReset func(quotaName string) // called when a usage reset is detected +} + +// SetOnReset registers a callback that is invoked when a usage reset is detected. +func (t *DeepSeekTracker) SetOnReset(fn func(string)) { + t.onReset = fn +} + +// DeepSeekSummary contains computed usage statistics for DeepSeek. +type DeepSeekSummary struct { + QuotaType string + Currency string + CurrentBalance float64 + CurrentRate float64 // per hour (burn rate) + CompletedCycles int + AvgPerCycle float64 + PeakCycle float64 + TotalTracked float64 + TrackingSince time.Time +} + +// NewDeepSeekTracker creates a new DeepSeekTracker. +func NewDeepSeekTracker(store *store.Store, logger *slog.Logger) *DeepSeekTracker { + if logger == nil { + logger = slog.Default() + } + return &DeepSeekTracker{ + store: store, + logger: logger, + } +} + +// Process compares current snapshot with previous, detects resets, updates cycles. +func (t *DeepSeekTracker) Process(snapshot *api.DeepSeekSnapshot) error { + if err := t.processBalance(snapshot); err != nil { + return fmt.Errorf("deepseek tracker: balance: %w", err) + } + + t.hasLastValues = true + return nil +} + +// processBalance tracks the balance cycle. +// Reset detection: balance grows by 50% or more (indicates a recharge/top-up). +func (t *DeepSeekTracker) processBalance(snapshot *api.DeepSeekSnapshot) error { + quotaType := "balance" + currentBalance := snapshot.TotalBalance + currency := snapshot.Currency + + cycle, err := t.store.QueryActiveDeepSeekCycle(quotaType, currency) + if err != nil { + return fmt.Errorf("failed to query active cycle: %w", err) + } + + if cycle == nil { + // First snapshot - create new cycle + _, err := t.store.CreateDeepSeekCycle(quotaType, currency, snapshot.CapturedAt) + if err != nil { + return fmt.Errorf("failed to create cycle: %w", err) + } + if err := t.store.UpdateDeepSeekCycle(quotaType, currency, currentBalance, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + t.lastBalance = currentBalance + t.logger.Info("Created new DeepSeek balance cycle", + "currency", currency, + "initialBalance", currentBalance, + ) + return nil + } + + // Check for reset: detect significant jump in balance (e.g., recharge) + resetDetected := false + if t.hasLastValues && t.lastBalance > 0 && currentBalance >= t.lastBalance*1.5 { + resetDetected = true + } else if t.hasLastValues && t.lastBalance == 0 && currentBalance > 0 { + resetDetected = true + } + + if resetDetected { + // Close old cycle with final stats + if err := t.store.CloseDeepSeekCycle(quotaType, currency, snapshot.CapturedAt, cycle.PeakUsage, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to close cycle: %w", err) + } + + // Create new cycle + if _, err := t.store.CreateDeepSeekCycle(quotaType, currency, snapshot.CapturedAt); err != nil { + return fmt.Errorf("failed to create new cycle: %w", err) + } + if err := t.store.UpdateDeepSeekCycle(quotaType, currency, currentBalance, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + + t.lastBalance = currentBalance + t.logger.Info("Detected DeepSeek balance recharge (reset)", + "currency", currency, + "lastBalance", t.lastBalance, + "newBalance", currentBalance, + "totalDelta", cycle.TotalDelta, + ) + if t.onReset != nil { + t.onReset(quotaType) + } + return nil + } + + // Same cycle - update stats + // For balance, delta is the amount *spent*, so we add to TotalDelta when balance drops + if t.hasLastValues { + drop := t.lastBalance - currentBalance + if drop > 0 { + cycle.TotalDelta += drop + } + // PeakUsage represents the highest balance seen in the cycle + if currentBalance > cycle.PeakUsage { + cycle.PeakUsage = currentBalance + } + if err := t.store.UpdateDeepSeekCycle(quotaType, currency, cycle.PeakUsage, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } else { + // First snapshot after restart - update peak if higher + if currentBalance > cycle.PeakUsage { + cycle.PeakUsage = currentBalance + if err := t.store.UpdateDeepSeekCycle(quotaType, currency, cycle.PeakUsage, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } + } + + t.lastBalance = currentBalance + return nil +} + +// UsageSummary returns computed stats for DeepSeek balance. +func (t *DeepSeekTracker) UsageSummary(currency string) (*DeepSeekSummary, error) { + quotaType := "balance" + + activeCycle, err := t.store.QueryActiveDeepSeekCycle(quotaType, currency) + if err != nil { + return nil, fmt.Errorf("failed to query active cycle: %w", err) + } + + history, err := t.store.QueryDeepSeekCycleHistory(quotaType, currency) + if err != nil { + return nil, fmt.Errorf("failed to query cycle history: %w", err) + } + + summary := &DeepSeekSummary{ + QuotaType: quotaType, + Currency: currency, + CompletedCycles: len(history), + } + + // Calculate stats from completed cycles + if len(history) > 0 { + var totalDelta float64 + summary.TrackingSince = history[len(history)-1].CycleStart // oldest cycle (history is DESC) + + for _, cycle := range history { + totalDelta += cycle.TotalDelta + if cycle.TotalDelta > summary.PeakCycle { + summary.PeakCycle = cycle.TotalDelta + } + } + summary.AvgPerCycle = totalDelta / float64(len(history)) + summary.TotalTracked = totalDelta + } + + // Add active cycle data + if activeCycle != nil { + summary.TotalTracked += activeCycle.TotalDelta + + // Get latest snapshot for current usage + latest, err := t.store.QueryLatestDeepSeek() + if err != nil { + return nil, fmt.Errorf("failed to query latest: %w", err) + } + + if latest != nil && latest.Currency == currency { + summary.CurrentBalance = latest.TotalBalance + + // Calculate rate from active cycle timing (burn rate) + elapsed := time.Since(activeCycle.CycleStart) + if elapsed.Hours() > 0 && activeCycle.TotalDelta > 0 { + summary.CurrentRate = activeCycle.TotalDelta / elapsed.Hours() + } + } + } + + return summary, nil +} diff --git a/internal/tracker/deepseek_tracker_test.go b/internal/tracker/deepseek_tracker_test.go new file mode 100644 index 0000000..838f242 --- /dev/null +++ b/internal/tracker/deepseek_tracker_test.go @@ -0,0 +1,94 @@ +package tracker + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func TestDeepSeekTracker(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer db.Close() + + tracker := NewDeepSeekTracker(db, nil) + now := time.Now().UTC() + + // Initial balance: 100 CNY + snap1 := &api.DeepSeekSnapshot{ + CapturedAt: now.Add(-2 * time.Hour), + IsAvailable: true, + Currency: "CNY", + TotalBalance: 100.0, + } + db.InsertDeepSeekSnapshot(snap1) + err = tracker.Process(snap1) + if err != nil { + t.Fatalf("process failed: %v", err) + } + + // Balance drops to 80 (spend 20) + snap2 := &api.DeepSeekSnapshot{ + CapturedAt: now.Add(-1 * time.Hour), + IsAvailable: true, + Currency: "CNY", + TotalBalance: 80.0, + } + db.InsertDeepSeekSnapshot(snap2) + err = tracker.Process(snap2) + if err != nil { + t.Fatalf("process failed: %v", err) + } + + summary, err := tracker.UsageSummary("CNY") + if err != nil { + t.Fatalf("summary failed: %v", err) + } + if summary.CurrentBalance != 80.0 { + t.Errorf("expected balance 80, got %v", summary.CurrentBalance) + } + if summary.TotalTracked != 20.0 { + t.Errorf("expected total tracked 20, got %v", summary.TotalTracked) + } + if summary.CompletedCycles != 0 { + t.Errorf("expected 0 completed cycles, got %v", summary.CompletedCycles) + } + + // Balance recharges to 200 (reset) + resetCalled := false + tracker.SetOnReset(func(q string) { resetCalled = true }) + snap3 := &api.DeepSeekSnapshot{ + CapturedAt: now, + IsAvailable: true, + Currency: "CNY", + TotalBalance: 200.0, + } + db.InsertDeepSeekSnapshot(snap3) + err = tracker.Process(snap3) + if err != nil { + t.Fatalf("process failed: %v", err) + } + + if !resetCalled { + t.Error("expected reset callback to be called") + } + + summary, err = tracker.UsageSummary("CNY") + if err != nil { + t.Fatalf("summary failed: %v", err) + } + if summary.CurrentBalance != 200.0 { + t.Errorf("expected balance 200, got %v", summary.CurrentBalance) + } + if summary.CompletedCycles != 1 { + t.Errorf("expected 1 completed cycle, got %v", summary.CompletedCycles) + } + // Active cycle has 0 tracked, completed cycle had 20 + if summary.TotalTracked != 20.0 { + t.Errorf("expected total tracked 20, got %v", summary.TotalTracked) + } +} diff --git a/internal/tracker/moonshot_tracker.go b/internal/tracker/moonshot_tracker.go new file mode 100644 index 0000000..317014d --- /dev/null +++ b/internal/tracker/moonshot_tracker.go @@ -0,0 +1,207 @@ +package tracker + +import ( + "fmt" + "log/slog" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// MoonshotTracker manages reset cycle detection and usage calculation for Moonshot. +type MoonshotTracker struct { + store *store.Store + logger *slog.Logger + + // Cache last seen values for delta calculation + lastBalance float64 + hasLastValues bool + + onReset func(quotaName string) // called when a usage reset is detected +} + +// SetOnReset registers a callback that is invoked when a usage reset is detected. +func (t *MoonshotTracker) SetOnReset(fn func(string)) { + t.onReset = fn +} + +// MoonshotSummary contains computed usage statistics for Moonshot. +type MoonshotSummary struct { + QuotaType string + CurrentBalance float64 + CurrentRate float64 // per hour (burn rate) + CompletedCycles int + AvgPerCycle float64 + PeakCycle float64 + TotalTracked float64 + TrackingSince time.Time +} + +// NewMoonshotTracker creates a new MoonshotTracker. +func NewMoonshotTracker(store *store.Store, logger *slog.Logger) *MoonshotTracker { + if logger == nil { + logger = slog.Default() + } + return &MoonshotTracker{ + store: store, + logger: logger, + } +} + +// Process compares current snapshot with previous, detects resets, updates cycles. +func (t *MoonshotTracker) Process(snapshot *api.MoonshotSnapshot) error { + if err := t.processBalance(snapshot); err != nil { + return fmt.Errorf("moonshot tracker: balance: %w", err) + } + + t.hasLastValues = true + return nil +} + +// processBalance tracks the balance cycle. +// Reset detection: balance grows by 50% or more (indicates a recharge/top-up). +func (t *MoonshotTracker) processBalance(snapshot *api.MoonshotSnapshot) error { + quotaType := "balance" + currentBalance := snapshot.AvailableBalance + + cycle, err := t.store.QueryActiveMoonshotCycle(quotaType) + if err != nil { + return fmt.Errorf("failed to query active cycle: %w", err) + } + + if cycle == nil { + // First snapshot - create new cycle + _, err := t.store.CreateMoonshotCycle(quotaType, snapshot.CapturedAt) + if err != nil { + return fmt.Errorf("failed to create cycle: %w", err) + } + if err := t.store.UpdateMoonshotCycle(quotaType, currentBalance, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + t.lastBalance = currentBalance + t.logger.Info("Created new Moonshot balance cycle", + "initialBalance", currentBalance, + ) + return nil + } + + // Check for reset: detect significant jump in balance (e.g., recharge) + resetDetected := false + if t.hasLastValues && t.lastBalance > 0 && currentBalance >= t.lastBalance*1.5 { + resetDetected = true + } else if t.hasLastValues && t.lastBalance == 0 && currentBalance > 0 { + resetDetected = true + } + + if resetDetected { + // Close old cycle with final stats + if err := t.store.CloseMoonshotCycle(quotaType, snapshot.CapturedAt, cycle.PeakUsage, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to close cycle: %w", err) + } + + // Create new cycle + if _, err := t.store.CreateMoonshotCycle(quotaType, snapshot.CapturedAt); err != nil { + return fmt.Errorf("failed to create new cycle: %w", err) + } + if err := t.store.UpdateMoonshotCycle(quotaType, currentBalance, 0); err != nil { + return fmt.Errorf("failed to set initial peak: %w", err) + } + + t.lastBalance = currentBalance + t.logger.Info("Detected Moonshot balance recharge (reset)", + "lastBalance", t.lastBalance, + "newBalance", currentBalance, + "totalDelta", cycle.TotalDelta, + ) + if t.onReset != nil { + t.onReset(quotaType) + } + return nil + } + + // Same cycle - update stats + // For balance, delta is the amount *spent*, so we add to TotalDelta when balance drops + if t.hasLastValues { + drop := t.lastBalance - currentBalance + if drop > 0 { + cycle.TotalDelta += drop + } + // PeakUsage represents the highest balance seen in the cycle + if currentBalance > cycle.PeakUsage { + cycle.PeakUsage = currentBalance + } + if err := t.store.UpdateMoonshotCycle(quotaType, cycle.PeakUsage, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } else { + // First snapshot after restart - update peak if higher + if currentBalance > cycle.PeakUsage { + cycle.PeakUsage = currentBalance + if err := t.store.UpdateMoonshotCycle(quotaType, cycle.PeakUsage, cycle.TotalDelta); err != nil { + return fmt.Errorf("failed to update cycle: %w", err) + } + } + } + + t.lastBalance = currentBalance + return nil +} + +// UsageSummary returns computed stats for Moonshot balance. +func (t *MoonshotTracker) UsageSummary() (*MoonshotSummary, error) { + quotaType := "balance" + + activeCycle, err := t.store.QueryActiveMoonshotCycle(quotaType) + if err != nil { + return nil, fmt.Errorf("failed to query active cycle: %w", err) + } + + history, err := t.store.QueryMoonshotCycleHistory(quotaType) + if err != nil { + return nil, fmt.Errorf("failed to query cycle history: %w", err) + } + + summary := &MoonshotSummary{ + QuotaType: quotaType, + CompletedCycles: len(history), + } + + // Calculate stats from completed cycles + if len(history) > 0 { + var totalDelta float64 + summary.TrackingSince = history[len(history)-1].CycleStart // oldest cycle (history is DESC) + + for _, cycle := range history { + totalDelta += cycle.TotalDelta + if cycle.TotalDelta > summary.PeakCycle { + summary.PeakCycle = cycle.TotalDelta + } + } + summary.AvgPerCycle = totalDelta / float64(len(history)) + summary.TotalTracked = totalDelta + } + + // Add active cycle data + if activeCycle != nil { + summary.TotalTracked += activeCycle.TotalDelta + + // Get latest snapshot for current usage + latest, err := t.store.QueryLatestMoonshot() + if err != nil { + return nil, fmt.Errorf("failed to query latest: %w", err) + } + + if latest != nil { + summary.CurrentBalance = latest.AvailableBalance + + // Calculate rate from active cycle timing (burn rate) + elapsed := time.Since(activeCycle.CycleStart) + if elapsed.Hours() > 0 && activeCycle.TotalDelta > 0 { + summary.CurrentRate = activeCycle.TotalDelta / elapsed.Hours() + } + } + } + + return summary, nil +} diff --git a/internal/tracker/moonshot_tracker_test.go b/internal/tracker/moonshot_tracker_test.go new file mode 100644 index 0000000..c5eae26 --- /dev/null +++ b/internal/tracker/moonshot_tracker_test.go @@ -0,0 +1,88 @@ +package tracker + +import ( + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +func TestMoonshotTracker(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer db.Close() + + tracker := NewMoonshotTracker(db, nil) + now := time.Now().UTC() + + // Initial balance: 100 + snap1 := &api.MoonshotSnapshot{ + CapturedAt: now.Add(-2 * time.Hour), + AvailableBalance: 100.0, + } + db.InsertMoonshotSnapshot(snap1) + err = tracker.Process(snap1) + if err != nil { + t.Fatalf("process failed: %v", err) + } + + // Balance drops to 80 (spend 20) + snap2 := &api.MoonshotSnapshot{ + CapturedAt: now.Add(-1 * time.Hour), + AvailableBalance: 80.0, + } + db.InsertMoonshotSnapshot(snap2) + err = tracker.Process(snap2) + if err != nil { + t.Fatalf("process failed: %v", err) + } + + summary, err := tracker.UsageSummary() + if err != nil { + t.Fatalf("summary failed: %v", err) + } + if summary.CurrentBalance != 80.0 { + t.Errorf("expected balance 80, got %v", summary.CurrentBalance) + } + if summary.TotalTracked != 20.0 { + t.Errorf("expected total tracked 20, got %v", summary.TotalTracked) + } + if summary.CompletedCycles != 0 { + t.Errorf("expected 0 completed cycles, got %v", summary.CompletedCycles) + } + + // Balance recharges to 200 (reset) + resetCalled := false + tracker.SetOnReset(func(q string) { resetCalled = true }) + snap3 := &api.MoonshotSnapshot{ + CapturedAt: now, + AvailableBalance: 200.0, + } + db.InsertMoonshotSnapshot(snap3) + err = tracker.Process(snap3) + if err != nil { + t.Fatalf("process failed: %v", err) + } + + if !resetCalled { + t.Error("expected reset callback to be called") + } + + summary, err = tracker.UsageSummary() + if err != nil { + t.Fatalf("summary failed: %v", err) + } + if summary.CurrentBalance != 200.0 { + t.Errorf("expected balance 200, got %v", summary.CurrentBalance) + } + if summary.CompletedCycles != 1 { + t.Errorf("expected 1 completed cycle, got %v", summary.CompletedCycles) + } + // Active cycle has 0 tracked, completed cycle had 20 + if summary.TotalTracked != 20.0 { + t.Errorf("expected total tracked 20, got %v", summary.TotalTracked) + } +} diff --git a/internal/web/deepseek_handlers.go b/internal/web/deepseek_handlers.go new file mode 100644 index 0000000..0b448de --- /dev/null +++ b/internal/web/deepseek_handlers.go @@ -0,0 +1,419 @@ +package web + +import ( + "fmt" + "net/http" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// currentDeepSeek returns DeepSeek balance status +func (h *Handler) currentDeepSeek(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.buildDeepSeekCurrent()) +} + +// buildDeepSeekCurrent builds the DeepSeek current balance response map. +func (h *Handler) buildDeepSeekCurrent() map[string]interface{} { + now := time.Now().UTC() + response := map[string]interface{}{ + "capturedAt": now.Format(time.RFC3339), + "balance": map[string]interface{}{ + "name": "Balance", + "description": "DeepSeek API balance", + "available": true, + "currency": "", + "total": 0.0, + "granted": 0.0, + "toppedUp": 0.0, + "rate": 0.0, + }, + } + + if h.store != nil { + latest, err := h.store.QueryLatestDeepSeek() + if err != nil { + h.logger.Error("failed to query latest DeepSeek snapshot", "error", err) + return response + } + + if latest != nil { + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + + status := "healthy" + if latest.TotalBalance == 0 { + status = "exhausted" + } + + balance := map[string]interface{}{ + "name": "Balance", + "description": "DeepSeek API balance", + "available": latest.IsAvailable, + "currency": latest.Currency, + "total": latest.TotalBalance, + "granted": latest.GrantedBalance, + "toppedUp": latest.ToppedUpBalance, + "rate": 0.0, + "status": status, + } + + // Enrich with tracker data + if h.deepseekTracker != nil && latest.Currency != "" { + if summary, err := h.deepseekTracker.UsageSummary(latest.Currency); err == nil && summary != nil { + balance["rate"] = summary.CurrentRate + balance["completedCycles"] = summary.CompletedCycles + balance["avgPerCycle"] = summary.AvgPerCycle + balance["peakCycle"] = summary.PeakCycle + balance["totalTracked"] = summary.TotalTracked + if !summary.TrackingSince.IsZero() { + balance["trackingSince"] = summary.TrackingSince.Format(time.RFC3339) + } + } + } + + response["balance"] = balance + } + } + + return response +} + +// historyDeepSeek returns DeepSeek usage history +func (h *Handler) historyDeepSeek(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + rangeStr := r.URL.Query().Get("range") + duration, err := parseTimeRange(rangeStr) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + now := time.Now().UTC() + start := now.Add(-duration) + end := now + + snapshots, err := h.store.QueryDeepSeekRange(start, end) + if err != nil { + h.logger.Error("failed to query DeepSeek history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + step := downsampleStep(len(snapshots), maxChartPoints) + last := len(snapshots) - 1 + histResp := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints)) + for i, snapshot := range snapshots { + if step > 1 && i != 0 && i != last && i%step != 0 { + continue + } + entry := map[string]interface{}{ + "capturedAt": snapshot.CapturedAt.Format(time.RFC3339), + "available": snapshot.IsAvailable, + "currency": snapshot.Currency, + "total_balance": snapshot.TotalBalance, + "granted_balance": snapshot.GrantedBalance, + "topped_up_balance": snapshot.ToppedUpBalance, + } + histResp = append(histResp, entry) + } + + respondJSON(w, http.StatusOK, histResp) +} + +// cyclesDeepSeek returns DeepSeek cycle data +func (h *Handler) cyclesDeepSeek(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + quotaType := "balance" + currency := r.URL.Query().Get("currency") + if currency == "" { + currency = "CNY" // Default + } + response := make([]map[string]interface{}, 0) + + active, err := h.store.QueryActiveDeepSeekCycle(quotaType, currency) + if err != nil { + h.logger.Error("failed to query active DeepSeek cycle", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + if active != nil { + response = append(response, deepseekCycleToMap(active)) + } + + history, err := h.store.QueryDeepSeekCycleHistory(quotaType, currency, 200) + if err != nil { + h.logger.Error("failed to query DeepSeek cycle history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + for _, cycle := range history { + response = append(response, deepseekCycleToMap(cycle)) + } + + respondJSON(w, http.StatusOK, response) +} + +func deepseekCycleToMap(cycle *store.DeepSeekResetCycle) map[string]interface{} { + result := map[string]interface{}{ + "id": cycle.ID, + "quotaType": cycle.QuotaType, + "currency": cycle.Currency, + "cycleStart": cycle.CycleStart.Format(time.RFC3339), + "cycleEnd": nil, + "peakRequests": cycle.PeakUsage, + "totalDelta": cycle.TotalDelta, + } + + if cycle.CycleEnd != nil { + result["cycleEnd"] = cycle.CycleEnd.Format(time.RFC3339) + } + + return result +} + +// summaryDeepSeek returns DeepSeek usage summary +func (h *Handler) summaryDeepSeek(w http.ResponseWriter, r *http.Request) { + currency := r.URL.Query().Get("currency") + if currency == "" { + currency = "CNY" + } + respondJSON(w, http.StatusOK, h.buildDeepSeekSummaryMap(currency)) +} + +// buildDeepSeekSummaryMap builds the DeepSeek summary response. +func (h *Handler) buildDeepSeekSummaryMap(currency string) map[string]interface{} { + response := map[string]interface{}{ + "balance": map[string]interface{}{ + "quotaType": "balance", + "currency": currency, + "currentBalance": 0.0, + "currentRate": 0.0, + "completedCycles": 0, + "avgPerCycle": 0.0, + "peakCycle": 0.0, + "totalTracked": 0.0, + "trackingSince": nil, + }, + } + + if h.deepseekTracker != nil { + if summary, err := h.deepseekTracker.UsageSummary(currency); err == nil && summary != nil { + response["balance"] = map[string]interface{}{ + "quotaType": summary.QuotaType, + "currency": summary.Currency, + "currentBalance": summary.CurrentBalance, + "currentRate": summary.CurrentRate, + "completedCycles": summary.CompletedCycles, + "avgPerCycle": summary.AvgPerCycle, + "peakCycle": summary.PeakCycle, + "totalTracked": summary.TotalTracked, + "trackingSince": nil, + } + if !summary.TrackingSince.IsZero() { + response["balance"].(map[string]interface{})["trackingSince"] = summary.TrackingSince.Format(time.RFC3339) + } + } + return response + } + + if h.store != nil { + latest, err := h.store.QueryLatestDeepSeek() + if err != nil { + h.logger.Error("failed to query latest DeepSeek snapshot", "error", err) + return response + } + if latest != nil && latest.Currency == currency { + balMap := response["balance"].(map[string]interface{}) + balMap["currentBalance"] = latest.TotalBalance + } + } + + return response +} + +// insightsDeepSeek returns DeepSeek insights +func (h *Handler) insightsDeepSeek(w http.ResponseWriter, r *http.Request, rangeDur time.Duration) { + hidden := h.getHiddenInsightKeys() + currency := r.URL.Query().Get("currency") + if currency == "" { + currency = "CNY" + } + respondJSON(w, http.StatusOK, h.buildDeepSeekInsights(currency, hidden)) +} + +// buildDeepSeekInsights builds the DeepSeek insights response. +func (h *Handler) buildDeepSeekInsights(currency string, hidden map[string]bool) insightsResponse { + resp := insightsResponse{Stats: []insightStat{}, Insights: []insightItem{}} + + if h.store == nil { + return resp + } + + latest, err := h.store.QueryLatestDeepSeek() + if err != nil || latest == nil { + resp.Insights = append(resp.Insights, insightItem{ + Type: "info", Severity: "info", + Title: "Getting Started", + Desc: "Keep onWatch running to collect DeepSeek usage data. Insights appear after a few snapshots.", + }) + return resp + } + + if latest.Currency != currency { + // Only reporting for currently tracked currency + return resp + } + + currencySymbol := "" + if currency == "CNY" { + currencySymbol = "¥" + } else if currency == "USD" { + currencySymbol = "$" + } + + if !hidden["total"] { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Total Balance", Value: fmt.Sprintf("%s%.2f", currencySymbol, latest.TotalBalance), + }) + } + if !hidden["granted"] { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Granted", Value: fmt.Sprintf("%s%.2f", currencySymbol, latest.GrantedBalance), + }) + } + if !hidden["topped_up"] { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Topped Up", Value: fmt.Sprintf("%s%.2f", currencySymbol, latest.ToppedUpBalance), + }) + } + + if h.deepseekTracker != nil { + if summary, err := h.deepseekTracker.UsageSummary(currency); err == nil && summary != nil { + if !hidden["rate"] && summary.CurrentRate > 0 { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Spend Rate", Value: fmt.Sprintf("%s%.4f/hr", currencySymbol, summary.CurrentRate), + }) + } + } + } + + if !latest.IsAvailable { + resp.Insights = append(resp.Insights, insightItem{ + Type: "warning", Severity: "high", + Title: "Service Unavailable", + Desc: "DeepSeek API is currently reporting that the service is not available.", + }) + } + + return resp +} + +// cycleOverviewDeepSeek returns DeepSeek cycle overview. +func (h *Handler) cycleOverviewDeepSeek(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}}) + return + } + + quotaType := "balance" + currency := r.URL.Query().Get("currency") + if currency == "" { + currency = "CNY" + } + var cycles []map[string]interface{} + + if active, err := h.store.QueryActiveDeepSeekCycle(quotaType, currency); err == nil && active != nil { + cycles = append(cycles, deepseekCycleToMap(active)) + } + if history, err := h.store.QueryDeepSeekCycleHistory(quotaType, currency, 50); err == nil { + for _, c := range history { + cycles = append(cycles, deepseekCycleToMap(c)) + } + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "groupBy": quotaType, + "provider": "deepseek", + "quotaNames": []string{"balance"}, + "cycles": cycles, + }) +} + +// loggingHistoryDeepSeek returns DeepSeek polling history. +func (h *Handler) loggingHistoryDeepSeek(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"provider": "deepseek", "quotaNames": []string{}, "logs": []interface{}{}}) + return + } + + start, end, limit := h.loggingHistoryRangeAndLimit(r) + + snapshots, err := h.store.QueryDeepSeekRange(start, end, limit) + if err != nil { + h.logger.Error("failed to query DeepSeek logging history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + quotaNames := []string{"balance"} + type quotaVal struct { + Name string + Value float64 + HasValue bool + } + + capturedAt := make([]string, 0, len(snapshots)) + ids := make([]int64, 0, len(snapshots)) + series := make([]map[string]quotaVal, 0, len(snapshots)) + + for _, snap := range snapshots { + capturedAt = append(capturedAt, snap.CapturedAt.Format(time.RFC3339)) + ids = append(ids, snap.ID) + + row := map[string]quotaVal{ + "balance": { + Name: "balance", + Value: snap.TotalBalance, + HasValue: true, + }, + } + series = append(series, row) + } + + logs := make([]map[string]interface{}, 0, len(snapshots)) + for i := range snapshots { + entry := map[string]interface{}{ + "capturedAt": capturedAt[i], + "id": ids[i], + "quotas": map[string]interface{}{}, + } + quotas := map[string]interface{}{} + for _, qn := range quotaNames { + if qv, ok := series[i][qn]; ok { + quotas[qn] = map[string]interface{}{ + "name": qv.Name, + "value": qv.Value, + "hasValue": qv.HasValue, + } + } + } + entry["quotas"] = quotas + logs = append(logs, entry) + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "provider": "deepseek", + "quotaNames": quotaNames, + "logs": logs, + }) +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 0835126..8c641fa 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -91,6 +91,8 @@ type Handler struct { minimaxTracker *tracker.MiniMaxTracker geminiTracker *tracker.GeminiTracker openrouterTracker *tracker.OpenRouterTracker + moonshotTracker *tracker.MoonshotTracker + deepseekTracker *tracker.DeepSeekTracker cursorTracker *tracker.CursorTracker updater *update.Updater notifier Notifier @@ -812,6 +814,16 @@ func (h *Handler) SetOpenRouterTracker(t *tracker.OpenRouterTracker) { h.openrouterTracker = t } +// SetMoonshotTracker sets the Moonshot tracker for usage summary enrichment. +func (h *Handler) SetMoonshotTracker(t *tracker.MoonshotTracker) { + h.moonshotTracker = t +} + +// SetDeepSeekTracker sets the DeepSeek tracker for usage summary enrichment. +func (h *Handler) SetDeepSeekTracker(t *tracker.DeepSeekTracker) { + h.deepseekTracker = t +} + // SetCursorTracker sets the Cursor tracker for usage summary enrichment. func (h *Handler) SetCursorTracker(t *tracker.CursorTracker) { h.cursorTracker = t @@ -1150,6 +1162,8 @@ func providerCatalog() []providerCatalogItem { {Key: "antigravity", Name: "Antigravity", Description: "Antigravity model usage tracking", AutoDetectable: true}, {Key: "minimax", Name: "MiniMax", Description: "MiniMax Coding Plan usage tracking"}, {Key: "openrouter", Name: "OpenRouter", Description: "OpenRouter credits usage tracking"}, + {Key: "moonshot", Name: "Moonshot", Description: "Moonshot Kimi balance tracking"}, + {Key: "deepseek", Name: "DeepSeek", Description: "DeepSeek API balance tracking"}, {Key: "gemini", Name: "Gemini", Description: "Google Gemini CLI quota tracking", AutoDetectable: true}, {Key: "cursor", Name: "Cursor", Description: "Cursor usage and quota tracking", AutoDetectable: true}, } @@ -1207,6 +1221,10 @@ func (h *Handler) isProviderConfigured(provider string) bool { return false case "openrouter": return strings.TrimSpace(h.config.OpenRouterAPIKey) != "" + case "moonshot": + return strings.TrimSpace(h.config.MoonshotAPIKey) != "" + case "deepseek": + return strings.TrimSpace(h.config.DeepSeekAPIKey) != "" case "gemini": return h.config.GeminiEnabled case "cursor": @@ -1338,6 +1356,8 @@ func applyProviderConfig(dst, src *config.Config) { dst.AntigravityEnabled = src.AntigravityEnabled dst.MiniMaxAPIKey = src.MiniMaxAPIKey dst.OpenRouterAPIKey = src.OpenRouterAPIKey + dst.MoonshotAPIKey = src.MoonshotAPIKey + dst.DeepSeekAPIKey = src.DeepSeekAPIKey dst.GeminiEnabled = src.GeminiEnabled dst.GeminiAutoToken = src.GeminiAutoToken dst.ZaiRegion = src.ZaiRegion @@ -1469,6 +1489,16 @@ func ApplyProviderSettingsFromDB(st *store.Store, cfg *config.Config, logger *sl cfg.OpenRouterAPIKey = key } } + if s := provSettings["moonshot"]; s != nil { + if key, _ := s["api_key"].(string); key != "" { + cfg.MoonshotAPIKey = key + } + } + if s := provSettings["deepseek"]; s != nil { + if key, _ := s["api_key"].(string); key != "" { + cfg.DeepSeekAPIKey = key + } + } if s := provSettings["antigravity"]; s != nil { if url, _ := s["base_url"].(string); url != "" { cfg.AntigravityBaseURL = url @@ -1750,8 +1780,12 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { hasAntigravity := hasVisibleProvider("antigravity") hasMiniMax := hasVisibleProvider("minimax") hasOpenRouter := hasVisibleProvider("openrouter") + hasMoonshot := hasVisibleProvider("moonshot") + hasDeepSeek := hasVisibleProvider("deepseek") hasToolsVisible := hasVisibleProvider("api-integrations") _ = hasOpenRouter // used by template if needed + _ = hasMoonshot + _ = hasDeepSeek data := map[string]interface{}{ "Title": "Dashboard", "Providers": providers, @@ -1805,6 +1839,10 @@ func (h *Handler) Current(w http.ResponseWriter, r *http.Request) { h.currentMiniMax(w, r) case "openrouter": h.currentOpenRouter(w, r) + case "moonshot": + h.currentMoonshot(w, r) + case "deepseek": + h.currentDeepSeek(w, r) case "gemini": h.currentGemini(w, r) case "cursor": @@ -1881,6 +1919,12 @@ func (h *Handler) currentBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("openrouter") && providerTelemetryEnabled(visibility, "openrouter") { response["openrouter"] = h.buildOpenRouterCurrent() } + if h.config.HasProvider("moonshot") && providerTelemetryEnabled(visibility, "moonshot") { + response["moonshot"] = h.buildMoonshotCurrent() + } + if h.config.HasProvider("deepseek") && providerTelemetryEnabled(visibility, "deepseek") { + response["deepseek"] = h.buildDeepSeekCurrent() + } if h.config.HasProvider("gemini") && providerTelemetryEnabled(visibility, "gemini") { response["gemini"] = h.buildGeminiCurrent() } @@ -2223,6 +2267,10 @@ func (h *Handler) History(w http.ResponseWriter, r *http.Request) { h.historyMiniMax(w, r) case "openrouter": h.historyOpenRouter(w, r) + case "moonshot": + h.historyMoonshot(w, r) + case "deepseek": + h.historyDeepSeek(w, r) case "gemini": h.historyGemini(w, r) case "cursor": @@ -2494,6 +2542,52 @@ func (h *Handler) historyBoth(w http.ResponseWriter, r *http.Request) { } } + if h.config.HasProvider("moonshot") && providerTelemetryEnabled(visibility, "moonshot") && h.store != nil { + snapshots, err := h.store.QueryMoonshotRange(start, now) + if err == nil { + step := downsampleStep(len(snapshots), maxChartPoints) + last := len(snapshots) - 1 + msData := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints)) + for i, s := range snapshots { + if step > 1 && i != 0 && i != last && i%step != 0 { + continue + } + entry := map[string]interface{}{ + "capturedAt": s.CapturedAt.Format(time.RFC3339), + "available_balance": s.AvailableBalance, + "voucher_balance": s.VoucherBalance, + "cash_balance": s.CashBalance, + } + msData = append(msData, entry) + } + response["moonshot"] = msData + } + } + + if h.config.HasProvider("deepseek") && providerTelemetryEnabled(visibility, "deepseek") && h.store != nil { + snapshots, err := h.store.QueryDeepSeekRange(start, now) + if err == nil { + step := downsampleStep(len(snapshots), maxChartPoints) + last := len(snapshots) - 1 + dsData := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints)) + for i, s := range snapshots { + if step > 1 && i != 0 && i != last && i%step != 0 { + continue + } + entry := map[string]interface{}{ + "capturedAt": s.CapturedAt.Format(time.RFC3339), + "available": s.IsAvailable, + "currency": s.Currency, + "total_balance": s.TotalBalance, + "granted_balance": s.GrantedBalance, + "topped_up_balance": s.ToppedUpBalance, + } + dsData = append(dsData, entry) + } + response["deepseek"] = dsData + } + } + if h.config.HasProvider("gemini") && providerTelemetryEnabled(visibility, "gemini") && h.store != nil { snapshots, err := h.store.QueryGeminiRange(start, now) if err == nil { @@ -3138,6 +3232,10 @@ func (h *Handler) Cycles(w http.ResponseWriter, r *http.Request) { h.cyclesMiniMax(w, r) case "openrouter": h.cyclesOpenRouter(w, r) + case "moonshot": + h.cyclesMoonshot(w, r) + case "deepseek": + h.cyclesDeepSeek(w, r) case "gemini": h.cyclesGemini(w, r) case "cursor": @@ -3283,6 +3381,49 @@ func (h *Handler) cyclesBoth(w http.ResponseWriter, r *http.Request) { } } + if h.config.HasProvider("moonshot") { + quotaType := "balance" + var msCycles []map[string]interface{} + if active, err := h.store.QueryActiveMoonshotCycle(quotaType); err == nil && active != nil { + msCycles = append(msCycles, moonshotCycleToMap(active)) + } + if history, err := h.store.QueryMoonshotCycleHistory(quotaType, 50); err == nil { + for _, c := range history { + msCycles = append(msCycles, moonshotCycleToMap(c)) + } + } + response["moonshot"] = map[string]interface{}{ + "groupBy": quotaType, + "provider": "moonshot", + "quotaNames": []string{"balance"}, + "cycles": msCycles, + } + } + + if h.config.HasProvider("deepseek") { + quotaType := "balance" + var dsCycles []map[string]interface{} + + // Use CNY as default if not specified elsewhere. DeepSeek could use USD, + // but tracking one primary currency for UI is sufficient for summary. + currency := "CNY" + + if active, err := h.store.QueryActiveDeepSeekCycle(quotaType, currency); err == nil && active != nil { + dsCycles = append(dsCycles, deepseekCycleToMap(active)) + } + if history, err := h.store.QueryDeepSeekCycleHistory(quotaType, currency, 50); err == nil { + for _, c := range history { + dsCycles = append(dsCycles, deepseekCycleToMap(c)) + } + } + response["deepseek"] = map[string]interface{}{ + "groupBy": quotaType, + "provider": "deepseek", + "quotaNames": []string{"balance"}, + "cycles": dsCycles, + } + } + if h.config.HasProvider("gemini") { response["gemini"] = []interface{}{} } @@ -3457,6 +3598,10 @@ func (h *Handler) Summary(w http.ResponseWriter, r *http.Request) { h.summaryMiniMax(w, r) case "openrouter": h.summaryOpenRouter(w, r) + case "moonshot": + h.summaryMoonshot(w, r) + case "deepseek": + h.summaryDeepSeek(w, r) case "gemini": h.summaryGemini(w, r) case "cursor": @@ -3494,6 +3639,13 @@ func (h *Handler) summaryBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("openrouter") { response["openrouter"] = h.buildOpenRouterSummaryMap() } + if h.config.HasProvider("moonshot") { + response["moonshot"] = h.buildMoonshotSummaryMap() + } + if h.config.HasProvider("deepseek") { + // DeepSeek could use either currency. Use CNY by default for summary view unless we know better + response["deepseek"] = h.buildDeepSeekSummaryMap("CNY") + } if h.config.HasProvider("anthropic") { response["anthropic"] = h.buildAnthropicSummaryMap() } @@ -4114,6 +4266,12 @@ func (h *Handler) sessionsBoth(w http.ResponseWriter, r *http.Request) { if h.config.HasProvider("openrouter") { response["openrouter"] = buildSessionList("openrouter") } + if h.config.HasProvider("moonshot") { + response["moonshot"] = buildSessionList("moonshot") + } + if h.config.HasProvider("deepseek") { + response["deepseek"] = buildSessionList("deepseek") + } respondJSON(w, http.StatusOK, response) } @@ -4253,6 +4411,10 @@ func (h *Handler) Insights(w http.ResponseWriter, r *http.Request) { h.insightsMiniMax(w, r, rangeDur) case "openrouter": h.insightsOpenRouter(w, r, rangeDur) + case "moonshot": + h.insightsMoonshot(w, r, rangeDur) + case "deepseek": + h.insightsDeepSeek(w, r, rangeDur) case "gemini": h.insightsGemini(w, r, rangeDur) case "cursor": @@ -4332,6 +4494,13 @@ func (h *Handler) insightsBoth(w http.ResponseWriter, r *http.Request, rangeDur if h.config.HasProvider("openrouter") && providerTelemetryEnabled(visibility, "openrouter") { response["openrouter"] = h.buildOpenRouterInsights(hidden) } + if h.config.HasProvider("moonshot") && providerTelemetryEnabled(visibility, "moonshot") { + response["moonshot"] = h.buildMoonshotInsights(hidden) + } + if h.config.HasProvider("deepseek") && providerTelemetryEnabled(visibility, "deepseek") { + // Use CNY for deepseek overall insights if not explicitly asked + response["deepseek"] = h.buildDeepSeekInsights("CNY", hidden) + } if h.config.HasProvider("gemini") && providerTelemetryEnabled(visibility, "gemini") { response["gemini"] = insightsResponse{Stats: []insightStat{}, Insights: []insightItem{}} } @@ -6668,6 +6837,10 @@ func (h *Handler) CycleOverview(w http.ResponseWriter, r *http.Request) { h.cycleOverviewMiniMax(w, r) case "openrouter": h.cycleOverviewOpenRouter(w, r) + case "moonshot": + h.cycleOverviewMoonshot(w, r) + case "deepseek": + h.cycleOverviewDeepSeek(w, r) case "gemini": h.cycleOverviewGemini(w, r) case "cursor": @@ -6935,6 +7108,45 @@ func (h *Handler) cycleOverviewBoth(w http.ResponseWriter, r *http.Request) { "cycles": orCycles, } } + + if h.config.HasProvider("moonshot") { + quotaType := "balance" + var msCycles []map[string]interface{} + if active, err := h.store.QueryActiveMoonshotCycle(quotaType); err == nil && active != nil { + msCycles = append(msCycles, moonshotCycleToMap(active)) + } + if history, err := h.store.QueryMoonshotCycleHistory(quotaType, 50); err == nil { + for _, c := range history { + msCycles = append(msCycles, moonshotCycleToMap(c)) + } + } + response["moonshot"] = map[string]interface{}{ + "groupBy": quotaType, + "provider": "moonshot", + "quotaNames": []string{"balance"}, + "cycles": msCycles, + } + } + + if h.config.HasProvider("deepseek") { + quotaType := "balance" + currency := "CNY" // Could be made dynamic + var dsCycles []map[string]interface{} + if active, err := h.store.QueryActiveDeepSeekCycle(quotaType, currency); err == nil && active != nil { + dsCycles = append(dsCycles, deepseekCycleToMap(active)) + } + if history, err := h.store.QueryDeepSeekCycleHistory(quotaType, currency, 50); err == nil { + for _, c := range history { + dsCycles = append(dsCycles, deepseekCycleToMap(c)) + } + } + response["deepseek"] = map[string]interface{}{ + "groupBy": quotaType, + "provider": "deepseek", + "quotaNames": []string{"balance"}, + "cycles": dsCycles, + } + } if h.config.HasProvider("gemini") { response["gemini"] = map[string]interface{}{ @@ -10043,6 +10255,10 @@ func (h *Handler) LoggingHistory(w http.ResponseWriter, r *http.Request) { h.loggingHistoryMiniMax(w, r) case "openrouter": h.loggingHistoryOpenRouter(w, r) + case "moonshot": + h.loggingHistoryMoonshot(w, r) + case "deepseek": + h.loggingHistoryDeepSeek(w, r) case "gemini": h.loggingHistoryGemini(w, r) case "cursor": diff --git a/internal/web/moonshot_handlers.go b/internal/web/moonshot_handlers.go new file mode 100644 index 0000000..b62cd77 --- /dev/null +++ b/internal/web/moonshot_handlers.go @@ -0,0 +1,374 @@ +package web + +import ( + "fmt" + "net/http" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +// currentMoonshot returns Moonshot balance status +func (h *Handler) currentMoonshot(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.buildMoonshotCurrent()) +} + +// buildMoonshotCurrent builds the Moonshot current balance response map. +func (h *Handler) buildMoonshotCurrent() map[string]interface{} { + now := time.Now().UTC() + response := map[string]interface{}{ + "capturedAt": now.Format(time.RFC3339), + "balance": map[string]interface{}{ + "name": "Balance", + "description": "Moonshot Kimi API balance", + "available": 0.0, + "voucher": 0.0, + "cash": 0.0, + "rate": 0.0, + }, + } + + if h.store != nil { + latest, err := h.store.QueryLatestMoonshot() + if err != nil { + h.logger.Error("failed to query latest Moonshot snapshot", "error", err) + return response + } + + if latest != nil { + response["capturedAt"] = latest.CapturedAt.Format(time.RFC3339) + + status := "healthy" + if latest.AvailableBalance == 0 { + status = "exhausted" + } + + balance := map[string]interface{}{ + "name": "Balance", + "description": "Moonshot Kimi API balance", + "available": latest.AvailableBalance, + "voucher": latest.VoucherBalance, + "cash": latest.CashBalance, + "rate": 0.0, + "status": status, + } + + // Enrich with tracker data + if h.moonshotTracker != nil { + if summary, err := h.moonshotTracker.UsageSummary(); err == nil && summary != nil { + balance["rate"] = summary.CurrentRate + balance["completedCycles"] = summary.CompletedCycles + balance["avgPerCycle"] = summary.AvgPerCycle + balance["peakCycle"] = summary.PeakCycle + balance["totalTracked"] = summary.TotalTracked + if !summary.TrackingSince.IsZero() { + balance["trackingSince"] = summary.TrackingSince.Format(time.RFC3339) + } + } + } + + response["balance"] = balance + } + } + + return response +} + +// historyMoonshot returns Moonshot usage history +func (h *Handler) historyMoonshot(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + rangeStr := r.URL.Query().Get("range") + duration, err := parseTimeRange(rangeStr) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + now := time.Now().UTC() + start := now.Add(-duration) + end := now + + snapshots, err := h.store.QueryMoonshotRange(start, end) + if err != nil { + h.logger.Error("failed to query Moonshot history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + step := downsampleStep(len(snapshots), maxChartPoints) + last := len(snapshots) - 1 + histResp := make([]map[string]interface{}, 0, min(len(snapshots), maxChartPoints)) + for i, snapshot := range snapshots { + if step > 1 && i != 0 && i != last && i%step != 0 { + continue + } + entry := map[string]interface{}{ + "capturedAt": snapshot.CapturedAt.Format(time.RFC3339), + "available_balance": snapshot.AvailableBalance, + "voucher_balance": snapshot.VoucherBalance, + "cash_balance": snapshot.CashBalance, + } + histResp = append(histResp, entry) + } + + respondJSON(w, http.StatusOK, histResp) +} + +// cyclesMoonshot returns Moonshot cycle data +func (h *Handler) cyclesMoonshot(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + + quotaType := "balance" + response := make([]map[string]interface{}, 0) + + active, err := h.store.QueryActiveMoonshotCycle(quotaType) + if err != nil { + h.logger.Error("failed to query active Moonshot cycle", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + if active != nil { + response = append(response, moonshotCycleToMap(active)) + } + + history, err := h.store.QueryMoonshotCycleHistory(quotaType, 200) + if err != nil { + h.logger.Error("failed to query Moonshot cycle history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query cycles") + return + } + + for _, cycle := range history { + response = append(response, moonshotCycleToMap(cycle)) + } + + respondJSON(w, http.StatusOK, response) +} + +func moonshotCycleToMap(cycle *store.MoonshotResetCycle) map[string]interface{} { + result := map[string]interface{}{ + "id": cycle.ID, + "quotaType": cycle.QuotaType, + "cycleStart": cycle.CycleStart.Format(time.RFC3339), + "cycleEnd": nil, + "peakRequests": cycle.PeakUsage, + "totalDelta": cycle.TotalDelta, + } + + if cycle.CycleEnd != nil { + result["cycleEnd"] = cycle.CycleEnd.Format(time.RFC3339) + } + + return result +} + +// summaryMoonshot returns Moonshot usage summary +func (h *Handler) summaryMoonshot(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.buildMoonshotSummaryMap()) +} + +// buildMoonshotSummaryMap builds the Moonshot summary response. +func (h *Handler) buildMoonshotSummaryMap() map[string]interface{} { + response := map[string]interface{}{ + "balance": map[string]interface{}{ + "quotaType": "balance", + "currentBalance": 0.0, + "currentRate": 0.0, + "completedCycles": 0, + "avgPerCycle": 0.0, + "peakCycle": 0.0, + "totalTracked": 0.0, + "trackingSince": nil, + }, + } + + if h.moonshotTracker != nil { + if summary, err := h.moonshotTracker.UsageSummary(); err == nil && summary != nil { + response["balance"] = map[string]interface{}{ + "quotaType": summary.QuotaType, + "currentBalance": summary.CurrentBalance, + "currentRate": summary.CurrentRate, + "completedCycles": summary.CompletedCycles, + "avgPerCycle": summary.AvgPerCycle, + "peakCycle": summary.PeakCycle, + "totalTracked": summary.TotalTracked, + "trackingSince": nil, + } + if !summary.TrackingSince.IsZero() { + response["balance"].(map[string]interface{})["trackingSince"] = summary.TrackingSince.Format(time.RFC3339) + } + } + return response + } + + if h.store != nil { + latest, err := h.store.QueryLatestMoonshot() + if err != nil { + h.logger.Error("failed to query latest Moonshot snapshot", "error", err) + return response + } + if latest != nil { + balMap := response["balance"].(map[string]interface{}) + balMap["currentBalance"] = latest.AvailableBalance + } + } + + return response +} + +// insightsMoonshot returns Moonshot insights +func (h *Handler) insightsMoonshot(w http.ResponseWriter, r *http.Request, rangeDur time.Duration) { + hidden := h.getHiddenInsightKeys() + respondJSON(w, http.StatusOK, h.buildMoonshotInsights(hidden)) +} + +// buildMoonshotInsights builds the Moonshot insights response. +func (h *Handler) buildMoonshotInsights(hidden map[string]bool) insightsResponse { + resp := insightsResponse{Stats: []insightStat{}, Insights: []insightItem{}} + + if h.store == nil { + return resp + } + + latest, err := h.store.QueryLatestMoonshot() + if err != nil || latest == nil { + resp.Insights = append(resp.Insights, insightItem{ + Type: "info", Severity: "info", + Title: "Getting Started", + Desc: "Keep onWatch running to collect Moonshot usage data. Insights appear after a few snapshots.", + }) + return resp + } + + if !hidden["available"] { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Available", Value: fmt.Sprintf("¥%.2f", latest.AvailableBalance), + }) + } + if !hidden["voucher"] { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Voucher", Value: fmt.Sprintf("¥%.2f", latest.VoucherBalance), + }) + } + if !hidden["cash"] { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Cash", Value: fmt.Sprintf("¥%.2f", latest.CashBalance), + }) + } + + if h.moonshotTracker != nil { + if summary, err := h.moonshotTracker.UsageSummary(); err == nil && summary != nil { + if !hidden["rate"] && summary.CurrentRate > 0 { + resp.Stats = append(resp.Stats, insightStat{ + Label: "Spend Rate", Value: fmt.Sprintf("¥%.2f/hr", summary.CurrentRate), + }) + } + } + } + + return resp +} + +// cycleOverviewMoonshot returns Moonshot cycle overview. +func (h *Handler) cycleOverviewMoonshot(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"cycles": []interface{}{}}) + return + } + + quotaType := "balance" + var cycles []map[string]interface{} + + if active, err := h.store.QueryActiveMoonshotCycle(quotaType); err == nil && active != nil { + cycles = append(cycles, moonshotCycleToMap(active)) + } + if history, err := h.store.QueryMoonshotCycleHistory(quotaType, 50); err == nil { + for _, c := range history { + cycles = append(cycles, moonshotCycleToMap(c)) + } + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "groupBy": quotaType, + "provider": "moonshot", + "quotaNames": []string{"balance"}, + "cycles": cycles, + }) +} + +// loggingHistoryMoonshot returns Moonshot polling history. +func (h *Handler) loggingHistoryMoonshot(w http.ResponseWriter, r *http.Request) { + if h.store == nil { + respondJSON(w, http.StatusOK, map[string]interface{}{"provider": "moonshot", "quotaNames": []string{}, "logs": []interface{}{}}) + return + } + + start, end, limit := h.loggingHistoryRangeAndLimit(r) + + snapshots, err := h.store.QueryMoonshotRange(start, end, limit) + if err != nil { + h.logger.Error("failed to query Moonshot logging history", "error", err) + respondError(w, http.StatusInternalServerError, "failed to query history") + return + } + + quotaNames := []string{"balance"} + type quotaVal struct { + Name string + Value float64 + HasValue bool + } + + capturedAt := make([]string, 0, len(snapshots)) + ids := make([]int64, 0, len(snapshots)) + series := make([]map[string]quotaVal, 0, len(snapshots)) + + for _, snap := range snapshots { + capturedAt = append(capturedAt, snap.CapturedAt.Format(time.RFC3339)) + ids = append(ids, snap.ID) + + row := map[string]quotaVal{ + "balance": { + Name: "balance", + Value: snap.AvailableBalance, + HasValue: true, + }, + } + series = append(series, row) + } + + logs := make([]map[string]interface{}, 0, len(snapshots)) + for i := range snapshots { + entry := map[string]interface{}{ + "capturedAt": capturedAt[i], + "id": ids[i], + "quotas": map[string]interface{}{}, + } + quotas := map[string]interface{}{} + for _, qn := range quotaNames { + if qv, ok := series[i][qn]; ok { + quotas[qn] = map[string]interface{}{ + "name": qv.Name, + "value": qv.Value, + "hasValue": qv.HasValue, + } + } + } + entry["quotas"] = quotas + logs = append(logs, entry) + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "provider": "moonshot", + "quotaNames": quotaNames, + "logs": logs, + }) +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 0c719c8..93f428f 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -5119,7 +5119,7 @@ function getCompactProviderInsights(provider, insights) { let ordered = insights; if (provider === 'cursor') { ordered = sortItemsByPreference(insights, ['forecast_total_usage', 'forecast_auto_usage', 'forecast_api_usage'], (insight) => insight.key); - } else if (provider === 'minimax' || provider === 'gemini' || provider === 'openrouter') { + } else if (provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'moonshot' || provider === 'deepseek') { ordered = sortItemsByPreference(insights, ['efficiency', 'trend', 'burn_rate', 'shared_status'], (insight) => insight.key); } const urgent = ordered.filter((insight) => ['warning', 'negative'].includes(insight.severity)); @@ -5517,6 +5517,26 @@ function buildProviderCardDatasets(provider, rows, range) { const orFallback = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; return buildDynamicDatasetsForRows(rows, range, orDisplayNames, orColors, orFallback, 'openrouter'); } + if (provider === 'moonshot') { + const msDisplayNames = { available_balance: 'Available', voucher_balance: 'Voucher', cash_balance: 'Cash' }; + const msColors = { + available_balance: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, + voucher_balance: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, + cash_balance: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } + }; + const msFallback = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; + return buildDynamicDatasetsForRows(rows, range, msDisplayNames, msColors, msFallback, 'moonshot'); + } + if (provider === 'deepseek') { + const dsDisplayNames = { total_balance: 'Total Balance', granted_balance: 'Granted', topped_up_balance: 'Topped Up' }; + const dsColors = { + total_balance: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, + granted_balance: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, + topped_up_balance: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } + }; + const dsFallback = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; + return buildDynamicDatasetsForRows(rows, range, dsDisplayNames, dsColors, dsFallback, 'deepseek'); + } return []; } @@ -5802,6 +5822,16 @@ function updateBothCharts(data, range = '6h') { const orCM = { usage: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, usageDaily: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, percent: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } }; const orFB = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; datasets = createDynamicDatasets(slot.rows, orDN, orCM, orFB, 'openrouter'); + } else if (slot.provider === 'moonshot') { + const msDN = { available_balance: 'Available', voucher_balance: 'Voucher', cash_balance: 'Cash' }; + const msCM = { available_balance: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, voucher_balance: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, cash_balance: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } }; + const msFB = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; + datasets = createDynamicDatasets(slot.rows, msDN, msCM, msFB, 'moonshot'); + } else if (slot.provider === 'deepseek') { + const dsDN = { total_balance: 'Total Balance', granted_balance: 'Granted', topped_up_balance: 'Topped Up' }; + const dsCM = { total_balance: { border: '#0D9488', bg: 'rgba(13, 148, 136, 0.06)' }, granted_balance: { border: '#F59E0B', bg: 'rgba(245, 158, 11, 0.06)' }, topped_up_balance: { border: '#3B82F6', bg: 'rgba(59, 130, 246, 0.06)' } }; + const dsFB = [{ border: '#8B5CF6', bg: 'rgba(139, 92, 246, 0.06)' }]; + datasets = createDynamicDatasets(slot.rows, dsDN, dsCM, dsFB, 'deepseek'); } if (datasets.length === 0) return; @@ -6095,8 +6125,8 @@ function renderCyclesTable() { const provider = getCurrentProvider(); const quotaNames = State.cyclesQuotaNames; - const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor'; - const deltaUsesPercent = usePercent && provider !== 'minimax'; + const usePercent = provider === 'anthropic' || provider === 'copilot' || provider === 'codex' || provider === 'antigravity' || provider === 'minimax' || provider === 'gemini' || provider === 'openrouter' || provider === 'cursor' || provider === 'moonshot' || provider === 'deepseek'; + const deltaUsesPercent = usePercent && provider !== 'minimax' && provider !== 'moonshot' && provider !== 'deepseek'; const isLoggingHistory = State.isLoggingHistory === true; // Build dynamic header @@ -9843,6 +9873,8 @@ function addOverrideRow(quotaKey, provider, warning, critical, isAbsolute, disab + + diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 9d99db7..3ca9749 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -114,6 +114,12 @@

Dashboard

{{else if eq .CurrentProvider "openrouter"}}
+ {{else if eq .CurrentProvider "moonshot"}} + +
+ {{else if eq .CurrentProvider "deepseek"}} + +
{{else if eq .CurrentProvider "gemini"}}
@@ -527,6 +533,14 @@

Credits Used Session Δ Snapshots + {{else if eq .CurrentProvider "moonshot"}} + Balance Drop + Session Δ + Snapshots + {{else if eq .CurrentProvider "deepseek"}} + Balance Drop + Session Δ + Snapshots {{else}} Sub Usage Search Usage @@ -548,6 +562,10 @@

No sessions recorded yet. {{else if eq .CurrentProvider "openrouter"}} No sessions recorded yet. + {{else if eq .CurrentProvider "moonshot"}} + No sessions recorded yet. + {{else if eq .CurrentProvider "deepseek"}} + No sessions recorded yet. {{else}} No sessions recorded yet. {{end}} diff --git a/main.go b/main.go index 73c2dd5..2bb1f81 100644 --- a/main.go +++ b/main.go @@ -789,6 +789,18 @@ func run() error { logger.Info("OpenRouter API client configured") } + var moonshotClient *api.MoonshotClient + if cfg.HasProvider("moonshot") { + moonshotClient = api.NewMoonshotClient(cfg.MoonshotAPIKey, logger) + logger.Info("Moonshot API client configured") + } + + var deepseekClient *api.DeepSeekClient + if cfg.HasProvider("deepseek") { + deepseekClient = api.NewDeepSeekClient(cfg.DeepSeekAPIKey, logger) + logger.Info("DeepSeek API client configured") + } + // Gemini provider - env vars or auto-detect from ~/.gemini/oauth_creds.json var geminiClient *api.GeminiClient var geminiCreds *api.GeminiCredentials @@ -975,6 +987,16 @@ func run() error { openrouterTr = tracker.NewOpenRouterTracker(db, logger) } + var moonshotTr *tracker.MoonshotTracker + if cfg.HasProvider("moonshot") { + moonshotTr = tracker.NewMoonshotTracker(db, logger) + } + + var deepseekTr *tracker.DeepSeekTracker + if cfg.HasProvider("deepseek") { + deepseekTr = tracker.NewDeepSeekTracker(db, logger) + } + var geminiTr *tracker.GeminiTracker if cfg.HasProvider("gemini") { geminiTr = tracker.NewGeminiTracker(db, logger) @@ -1018,6 +1040,18 @@ func run() error { openrouterAg = agent.NewOpenRouterAgent(openrouterClient, db, openrouterTr, cfg.PollInterval, logger, openrouterSm) } + var moonshotAg *agent.MoonshotAgent + if moonshotClient != nil { + moonshotSm := agent.NewSessionManager(db, "moonshot", idleTimeout, logger) + moonshotAg = agent.NewMoonshotAgent(moonshotClient, db, moonshotTr, cfg.PollInterval, logger, moonshotSm) + } + + var deepseekAg *agent.DeepSeekAgent + if deepseekClient != nil { + deepseekSm := agent.NewSessionManager(db, "deepseek", idleTimeout, logger) + deepseekAg = agent.NewDeepSeekAgent(deepseekClient, db, deepseekTr, cfg.PollInterval, logger, deepseekSm) + } + var geminiAg *agent.GeminiAgent if geminiClient != nil { geminiSm := agent.NewSessionManager(db, "gemini", idleTimeout, logger) @@ -1081,6 +1115,12 @@ func run() error { if openrouterAg != nil { openrouterAg.SetNotifier(notifier) } + if moonshotAg != nil { + moonshotAg.SetNotifier(notifier) + } + if deepseekAg != nil { + deepseekAg.SetNotifier(notifier) + } if geminiAg != nil { geminiAg.SetNotifier(notifier) } @@ -1185,6 +1225,12 @@ func run() error { if openrouterAg != nil { openrouterAg.SetPollingCheck(func() bool { return isPollingEnabled("openrouter") }) } + if moonshotAg != nil { + moonshotAg.SetPollingCheck(func() bool { return isPollingEnabled("moonshot") }) + } + if deepseekAg != nil { + deepseekAg.SetPollingCheck(func() bool { return isPollingEnabled("deepseek") }) + } if geminiAg != nil { geminiAg.SetPollingCheck(func() bool { return isPollingEnabled("gemini") }) } @@ -1231,6 +1277,16 @@ func run() error { notifier.Check(notify.QuotaStatus{Provider: "openrouter", QuotaKey: quotaName, ResetOccurred: true}) }) } + if moonshotTr != nil { + moonshotTr.SetOnReset(func(quotaName string) { + notifier.Check(notify.QuotaStatus{Provider: "moonshot", QuotaKey: quotaName, ResetOccurred: true}) + }) + } + if deepseekTr != nil { + deepseekTr.SetOnReset(func(quotaName string) { + notifier.Check(notify.QuotaStatus{Provider: "deepseek", QuotaKey: quotaName, ResetOccurred: true}) + }) + } if geminiTr != nil { geminiTr.SetOnReset(func(modelID string) { notifier.Check(notify.QuotaStatus{Provider: "gemini", QuotaKey: modelID, ResetOccurred: true}) @@ -1263,6 +1319,12 @@ func run() error { if openrouterTr != nil { handler.SetOpenRouterTracker(openrouterTr) } + if moonshotTr != nil { + handler.SetMoonshotTracker(moonshotTr) + } + if deepseekTr != nil { + handler.SetDeepSeekTracker(deepseekTr) + } if geminiTr != nil { handler.SetGeminiTracker(geminiTr) } @@ -1294,6 +1356,12 @@ func run() error { if openrouterAg != nil { agentMgr.RegisterFactory("openrouter", func() (agent.AgentRunner, error) { return openrouterAg, nil }) } + if moonshotAg != nil { + agentMgr.RegisterFactory("moonshot", func() (agent.AgentRunner, error) { return moonshotAg, nil }) + } + if deepseekAg != nil { + agentMgr.RegisterFactory("deepseek", func() (agent.AgentRunner, error) { return deepseekAg, nil }) + } if geminiAg != nil { agentMgr.RegisterFactory("gemini", func() (agent.AgentRunner, error) { return geminiAg, nil }) } @@ -1326,7 +1394,7 @@ func run() error { // Start configured agents through the manager. startedAny := false - for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "gemini", "cursor"} { + for _, providerKey := range []string{"synthetic", "zai", "anthropic", "copilot", "codex", "antigravity", "minimax", "openrouter", "moonshot", "deepseek", "gemini", "cursor"} { if !isPollingEnabled(providerKey) { continue }