Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ ZAI_BASE_URL=https://api.z.ai/api
# If not set, onWatch will attempt auto-detection from Claude Code credentials
ANTHROPIC_TOKEN=

# MiniMax API key (optional - leave empty to disable MiniMax provider)
MINIMAX_API_KEY=

# -----------------------------------------------------------------------------
# Web UI Configuration
# -----------------------------------------------------------------------------
Expand Down
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ ANTHROPIC_TOKEN=
# Generate at: https://github.com/settings/tokens (classic token, select `copilot` scope)
COPILOT_TOKEN=

# --- MiniMax Configuration ---
# MiniMax API key for coding plan usage tracking
# Get it from: https://www.minimax.io
MINIMAX_API_KEY=

# --- Polling Configuration ---
# Interval in seconds between API polls (default: 60)
# Min: 10, Max: 3600
Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,16 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
# Release event tags (from git tag ref)
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}},enable=${{ github.event_name == 'release' }}

# Manual dispatch tag (uses required workflow input)
type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}

# latest only on default branch pushes/releases
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}

- name: Build and push Docker image
uses: docker/build-push-action@v6
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.10.1
2.10.2
132 changes: 132 additions & 0 deletions internal/agent/minimax_agent.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package agent

import (
"context"
"log/slog"
"time"

"github.com/onllm-dev/onwatch/internal/api"
"github.com/onllm-dev/onwatch/internal/notify"
"github.com/onllm-dev/onwatch/internal/store"
"github.com/onllm-dev/onwatch/internal/tracker"
)

// MiniMaxAgent manages the background polling loop for MiniMax quota tracking.
type MiniMaxAgent struct {
client *api.MiniMaxClient
store *store.Store
tracker *tracker.MiniMaxTracker
interval time.Duration
logger *slog.Logger
sm *SessionManager
notifier *notify.NotificationEngine
pollingCheck func() bool
}

// SetPollingCheck sets a function called before each poll.
func (a *MiniMaxAgent) SetPollingCheck(fn func() bool) {
a.pollingCheck = fn
}

// SetNotifier sets the notification engine for sending alerts.
func (a *MiniMaxAgent) SetNotifier(n *notify.NotificationEngine) {
a.notifier = n
}

// NewMiniMaxAgent creates a new MiniMaxAgent.
func NewMiniMaxAgent(client *api.MiniMaxClient, store *store.Store, tracker *tracker.MiniMaxTracker, interval time.Duration, logger *slog.Logger, sm *SessionManager) *MiniMaxAgent {
if logger == nil {
logger = slog.Default()
}
return &MiniMaxAgent{
client: client,
store: store,
tracker: tracker,
interval: interval,
logger: logger,
sm: sm,
}
}

// Run starts the agent polling loop.
func (a *MiniMaxAgent) Run(ctx context.Context) error {
a.logger.Info("MiniMax agent started", "interval", a.interval)

defer func() {
if a.sm != nil {
a.sm.Close()
}
a.logger.Info("MiniMax 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
}
}
}

func (a *MiniMaxAgent) poll(ctx context.Context) {
if a.pollingCheck != nil && !a.pollingCheck() {
return
}

resp, err := a.client.FetchRemains(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
a.logger.Error("Failed to fetch MiniMax remains", "error", err)
return
}

now := time.Now().UTC()
snapshot := resp.ToSnapshot(now)

if _, err := a.store.InsertMiniMaxSnapshot(snapshot); err != nil {
a.logger.Error("Failed to insert MiniMax snapshot", "error", err)
}

if err := a.tracker.Process(snapshot); err != nil {
a.logger.Error("MiniMax tracker processing failed", "error", err)
}

if a.notifier != nil {
for _, m := range snapshot.Models {
if m.Total == 0 {
continue
}
a.notifier.Check(notify.QuotaStatus{
Provider: "minimax",
QuotaKey: m.ModelName,
Utilization: m.UsedPercent,
Limit: float64(m.Total),
})
}
}

if a.sm != nil {
values := make([]float64, 0, len(snapshot.Models))
for _, m := range snapshot.Models {
values = append(values, float64(m.Used))
}
a.sm.ReportPoll(values)
}

for _, m := range snapshot.Models {
a.logger.Info("MiniMax poll complete",
"model", m.ModelName,
"total", m.Total,
"remain", m.Remain,
"used", m.Used,
)
}
}
138 changes: 138 additions & 0 deletions internal/agent/minimax_agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package agent

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"

"github.com/onllm-dev/onwatch/internal/api"
"github.com/onllm-dev/onwatch/internal/store"
"github.com/onllm-dev/onwatch/internal/tracker"
)

func miniMaxTestResponse() api.MiniMaxRemainsResponse {
return api.MiniMaxRemainsResponse{
BaseResp: api.MiniMaxBaseResp{StatusCode: 0, StatusMsg: ""},
ModelRemains: []api.MiniMaxModelRemain{
{
ModelName: "MiniMax-M2",
StartTime: "2026-02-15T11:00:00Z",
EndTime: "2026-02-15T13:00:00Z",
RemainsTime: 7200000,
CurrentIntervalTotalCount: 200,
CurrentIntervalUsageCount: 42,
},
{
ModelName: "MiniMax-Text-01",
StartTime: "2026-02-15T11:00:00Z",
EndTime: "2026-02-15T13:00:00Z",
RemainsTime: 7200000,
CurrentIntervalTotalCount: 100,
CurrentIntervalUsageCount: 20,
},
},
}
}

func setupMiniMaxTest(t *testing.T) (*MiniMaxAgent, *store.Store, *httptest.Server) {
var callCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount.Add(1)
if r.Header.Get("Authorization") != "Bearer minimax_test_token" {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"base_resp":{"status_code":1004,"status_msg":"unauthorized"}}`)
return
}
resp := miniMaxTestResponse()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(server.Close)

str, err := store.New(":memory:")
if err != nil {
t.Fatalf("Failed to create store: %v", err)
}
t.Cleanup(func() { str.Close() })

logger := slog.Default()
client := api.NewMiniMaxClient("minimax_test_token", logger, api.WithMiniMaxBaseURL(server.URL))
tr := tracker.NewMiniMaxTracker(str, logger)
sm := NewSessionManager(str, "minimax", 600*time.Second, logger)

ag := NewMiniMaxAgent(client, str, tr, 100*time.Millisecond, logger, sm)

return ag, str, server
}

func TestMiniMaxAgent_SinglePoll(t *testing.T) {
ag, str, _ := setupMiniMaxTest(t)

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

go ag.Run(ctx)

time.Sleep(250 * time.Millisecond)
cancel()

latest, err := str.QueryLatestMiniMax()
if err != nil {
t.Fatalf("QueryLatestMiniMax: %v", err)
}
if latest == nil {
t.Fatal("Expected snapshot after poll")
}
if len(latest.Models) < 2 {
t.Errorf("Expected at least 2 models, got %d", len(latest.Models))
}
}

func TestMiniMaxAgent_PollingCheck(t *testing.T) {
ag, str, _ := setupMiniMaxTest(t)

ag.SetPollingCheck(func() bool { return false })

ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()

go ag.Run(ctx)
time.Sleep(200 * time.Millisecond)
cancel()

latest, err := str.QueryLatestMiniMax()
if err != nil {
t.Fatalf("QueryLatestMiniMax: %v", err)
}
if latest != nil {
t.Error("Expected no snapshot when polling disabled")
}
}

func TestMiniMaxAgent_ContextCancellation(t *testing.T) {
ag, _, _ := setupMiniMaxTest(t)

ctx, cancel := context.WithCancel(context.Background())

done := make(chan error, 1)
go func() {
done <- ag.Run(ctx)
}()

cancel()

select {
case err := <-done:
if err != nil {
t.Errorf("Expected nil error on cancel, got: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Agent did not stop within timeout")
}
}
Loading