Skip to content
Merged
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
131 changes: 70 additions & 61 deletions backend/internal/service/account_usage_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@ const (
)

const (
senseNovaRateWindow = time.Minute
senseNovaRPMLimit int64 = 60
senseNovaTPMLimit int64 = 128000
// SenseNova API key accounts use local successful-usage logs to render the
// documented rolling point windows. The minute window remains separate for
// upstream 429 cooldown handling.
senseNovaRateWindow = time.Minute
senseNovaFiveHourWindow = 5 * time.Hour
senseNovaWeeklyWindow = 7 * 24 * time.Hour
senseNovaFiveHourPointsLimit = int64(60000)
senseNovaWeeklyPointsLimit = int64(600000)
)

const (
Expand Down Expand Up @@ -167,6 +172,8 @@ type UsageProgress struct {
LimitRequests int64 `json:"limit_requests,omitempty"`
UsedTokens int64 `json:"used_tokens,omitempty"`
LimitTokens int64 `json:"limit_tokens,omitempty"`
UsedPoints int64 `json:"used_points,omitempty"`
LimitPoints int64 `json:"limit_points,omitempty"`
}

// AntigravityModelQuota Antigravity 单个模型的配额信息
Expand Down Expand Up @@ -208,8 +215,8 @@ type UsageInfo struct {
GeminiSharedMinute *UsageProgress `json:"gemini_shared_minute,omitempty"` // Gemini shared pool RPM (Google One / Code Assist)
GeminiProMinute *UsageProgress `json:"gemini_pro_minute,omitempty"` // Gemini Pro RPM
GeminiFlashMinute *UsageProgress `json:"gemini_flash_minute,omitempty"` // Gemini Flash RPM
SenseNovaRPM *UsageProgress `json:"sensenova_rpm,omitempty"` // SenseNova documented RPM window
SenseNovaTPM *UsageProgress `json:"sensenova_tpm,omitempty"` // SenseNova documented TPM window
SenseNovaFiveHour *UsageProgress `json:"sensenova_five_hour,omitempty"` // SenseNova rolling 5-hour point window
SenseNovaSevenDay *UsageProgress `json:"sensenova_seven_day,omitempty"` // SenseNova rolling 7-day point window
ChatAnywhereDaily *UsageProgress `json:"chatanywhere_daily,omitempty"` // Local rolling 24h request observation
ChatAnywhereWeekly *UsageProgress `json:"chatanywhere_weekly,omitempty"` // Local rolling 7d token observation
ChatAnywhereQuotaStatus string `json:"chatanywhere_quota_status,omitempty"`
Expand Down Expand Up @@ -411,8 +418,9 @@ func (s *AccountUsageService) getUsageForAccount(ctx context.Context, account *A
return usage, err
}

// SenseNova API key accounts expose documented RPM/TPM limits rather than
// an OAuth quota endpoint. The windows are calculated from local usage logs.
// SenseNova API key accounts expose documented rolling point limits rather
// than an OAuth quota endpoint. The windows are calculated from local usage
// logs while local balance deduction remains in the existing billing path.
if account.IsSenseNova() {
usage, err := s.getSenseNovaUsage(ctx, account)
if err == nil {
Expand Down Expand Up @@ -529,7 +537,7 @@ func (s *AccountUsageService) getUsageForAccount(ctx context.Context, account *A
// GetUsage 获取账号使用量
// OAuth账号: 调用Anthropic API获取真实数据(需要profile scope),API响应缓存10分钟,窗口统计缓存1分钟
// Setup Token账号: 根据session_window推算5h窗口,7d数据不可用(没有profile scope)
// SenseNova API Key账号: 根据本地日志计算文档定义的RPM/TPM窗口
// SenseNova API Key账号: 根据本地日志计算文档定义的滚动积分窗口
// 其他 API Key账号: 不支持usage查询
func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, force ...bool) (*UsageInfo, error) {
forceProbe := len(force) > 0 && force[0]
Expand Down Expand Up @@ -811,28 +819,57 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
}

func (s *AccountUsageService) getSenseNovaUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
now := time.Now()
stats := &usagestats.AccountStats{}
if s.usageLogRepo != nil {
windowStart := now.Truncate(senseNovaRateWindow)
if queried, err := s.usageLogRepo.GetAccountWindowStats(ctx, account.ID, windowStart); err == nil {
if queried != nil {
stats = queried
}
} else {
slog.Warn("sensenova_local_usage_query_failed", "account_id", account.ID, "error", err)
}
}
now := time.Now().UTC()
fiveHourStats := querySenseNovaWindowStats(
ctx,
s.usageLogRepo,
account.ID,
now.Add(-senseNovaFiveHourWindow),
"5h",
)
weeklyStats := querySenseNovaWindowStats(
ctx,
s.usageLogRepo,
account.ID,
now.Add(-senseNovaWeeklyWindow),
"7d",
)

rpm, tpm := buildSenseNovaUsageProgress(account, stats, now)
return &UsageInfo{
Source: "active",
UpdatedAt: &now,
SenseNovaRPM: rpm,
SenseNovaTPM: tpm,
Source: "local",
UpdatedAt: &now,
SenseNovaFiveHour: buildSenseNovaUsageProgress(fiveHourStats, senseNovaFiveHourPointsLimit),
SenseNovaSevenDay: buildSenseNovaUsageProgress(weeklyStats, senseNovaWeeklyPointsLimit),
}, nil
}

func querySenseNovaWindowStats(
ctx context.Context,
repo UsageLogRepository,
accountID int64,
startTime time.Time,
windowName string,
) *usagestats.AccountStats {
if repo == nil {
return &usagestats.AccountStats{}
}

stats, err := repo.GetAccountWindowStats(ctx, accountID, startTime)
if err != nil {
slog.Warn(
"sensenova_local_usage_query_failed",
"account_id", accountID,
"window", windowName,
"error", err,
)
return &usagestats.AccountStats{}
}
if stats == nil {
return &usagestats.AccountStats{}
}
return stats
}

func (s *AccountUsageService) getChatAnywhereUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
now := time.Now().UTC()
dailyStats := &usagestats.AccountStats{}
Expand Down Expand Up @@ -926,48 +963,20 @@ func maxInt64(value, floor int64) int64 {
return value
}

func buildSenseNovaUsageProgress(account *Account, stats *usagestats.AccountStats, now time.Time) (*UsageProgress, *UsageProgress) {
func buildSenseNovaUsageProgress(stats *usagestats.AccountStats, pointsLimit int64) *UsageProgress {
if stats == nil {
stats = &usagestats.AccountStats{}
}

resetAt := now.Truncate(senseNovaRateWindow).Add(senseNovaRateWindow)
rateLimited := false
if account != nil && account.RateLimitResetAt != nil {
if now.Before(*account.RateLimitResetAt) {
rateLimited = true
}
if account.RateLimitResetAt.After(resetAt) {
resetAt = *account.RateLimitResetAt
}
}

remainingSeconds := int(resetAt.Sub(now).Seconds())
if remainingSeconds < 0 {
remainingSeconds = 0
}
resetCopy := resetAt
windowStats := windowStatsFromAccountStats(stats)
rpmUtilization := float64(stats.Requests) / float64(senseNovaRPMLimit) * 100
tpmUtilization := float64(stats.Tokens) / float64(senseNovaTPMLimit) * 100
if rateLimited {
rpmUtilization = 100
tpmUtilization = 100
usedPoints := maxInt64(stats.Tokens, 0)
progress := &UsageProgress{
Utilization: float64(usedPoints) / float64(pointsLimit) * 100,
WindowStats: windowStatsFromAccountStats(stats),
UsedPoints: usedPoints,
LimitPoints: pointsLimit,
}

return &UsageProgress{
Utilization: rpmUtilization,
ResetsAt: &resetCopy,
RemainingSeconds: remainingSeconds,
WindowStats: windowStats,
UsedRequests: stats.Requests,
LimitRequests: senseNovaRPMLimit,
}, &UsageProgress{
Utilization: tpmUtilization,
ResetsAt: &resetCopy,
RemainingSeconds: remainingSeconds,
WindowStats: windowStats,
}
return progress
}

func shouldRefreshOpenAICodexSnapshot(account *Account, usage *UsageInfo, now time.Time) bool {
Expand Down
70 changes: 40 additions & 30 deletions backend/internal/service/sensenova_usage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import (

type senseNovaUsageRepo struct {
UsageLogRepository
stats *usagestats.AccountStats
startTime time.Time
stats []*usagestats.AccountStats
startTimes []time.Time
}

func (r *senseNovaUsageRepo) GetAccountWindowStats(_ context.Context, _ int64, startTime time.Time) (*usagestats.AccountStats, error) {
r.startTime = startTime
return r.stats, nil
r.startTimes = append(r.startTimes, startTime)
if index := len(r.startTimes) - 1; index < len(r.stats) {
return r.stats[index], nil
}
return &usagestats.AccountStats{}, nil
}

type senseNovaRateLimitRepo struct {
Expand All @@ -32,50 +35,57 @@ func (r *senseNovaRateLimitRepo) SetRateLimited(_ context.Context, _ int64, rese
return nil
}

func TestGetUsageForAccount_SenseNovaUsesLocalRPMAndTPMWindows(t *testing.T) {
repo := &senseNovaUsageRepo{stats: &usagestats.AccountStats{Requests: 30, Tokens: 64000}}
func TestGetUsageForAccount_SenseNovaUsesRollingPointWindows(t *testing.T) {
repo := &senseNovaUsageRepo{stats: []*usagestats.AccountStats{
{Requests: 30, Tokens: 30000},
{Requests: 30, Tokens: 300000},
}}
svc := &AccountUsageService{usageLogRepo: repo}
account := &Account{ID: 42, Platform: PlatformSenseNova, Type: AccountTypeAPIKey}

usage, err := svc.getUsageForAccount(context.Background(), account, false)
if err != nil {
t.Fatalf("getUsageForAccount() error = %v", err)
}
if usage == nil || usage.SenseNovaRPM == nil || usage.SenseNovaTPM == nil {
t.Fatalf("expected SenseNova RPM/TPM usage, got %#v", usage)
if usage == nil || usage.SenseNovaFiveHour == nil || usage.SenseNovaSevenDay == nil {
t.Fatalf("expected SenseNova rolling point usage, got %#v", usage)
}
if usage.SenseNovaFiveHour.Utilization != 50 {
t.Fatalf("5-hour utilization = %v, want 50", usage.SenseNovaFiveHour.Utilization)
}
if usage.SenseNovaSevenDay.Utilization != 50 {
t.Fatalf("7-day utilization = %v, want 50", usage.SenseNovaSevenDay.Utilization)
}
if usage.SenseNovaRPM.Utilization != 50 {
t.Fatalf("RPM utilization = %v, want 50", usage.SenseNovaRPM.Utilization)
if usage.SenseNovaFiveHour.LimitPoints != senseNovaFiveHourPointsLimit {
t.Fatalf("5-hour points limit = %d, want %d", usage.SenseNovaFiveHour.LimitPoints, senseNovaFiveHourPointsLimit)
}
if usage.SenseNovaTPM.Utilization != 50 {
t.Fatalf("TPM utilization = %v, want 50", usage.SenseNovaTPM.Utilization)
if usage.SenseNovaSevenDay.LimitPoints != senseNovaWeeklyPointsLimit {
t.Fatalf("7-day points limit = %d, want %d", usage.SenseNovaSevenDay.LimitPoints, senseNovaWeeklyPointsLimit)
}
if usage.SenseNovaRPM.LimitRequests != senseNovaRPMLimit {
t.Fatalf("RPM limit = %d, want %d", usage.SenseNovaRPM.LimitRequests, senseNovaRPMLimit)
if usage.SenseNovaFiveHour.UsedPoints != 30000 || usage.SenseNovaSevenDay.UsedPoints != 300000 {
t.Fatalf("unexpected point usage: (%d, %d)", usage.SenseNovaFiveHour.UsedPoints, usage.SenseNovaSevenDay.UsedPoints)
}
if usage.SenseNovaRPM.WindowStats == nil || usage.SenseNovaRPM.WindowStats.Tokens != 64000 {
t.Fatalf("unexpected local window stats: %#v", usage.SenseNovaRPM.WindowStats)
if len(repo.startTimes) != 2 {
t.Fatalf("window queries = %d, want 2", len(repo.startTimes))
}
if repo.startTime.IsZero() {
t.Fatal("expected local minute window query")
queryNow := time.Now().UTC()
if got := repo.startTimes[0]; got.Before(queryNow.Add(-senseNovaFiveHourWindow-2*time.Second)) || got.After(queryNow.Add(-senseNovaFiveHourWindow+2*time.Second)) {
t.Fatalf("5-hour window start = %v, want roughly now - 5h", got)
}
if got := repo.startTimes[1]; got.Before(queryNow.Add(-senseNovaWeeklyWindow-2*time.Second)) || got.After(queryNow.Add(-senseNovaWeeklyWindow+2*time.Second)) {
t.Fatalf("7-day window start = %v, want roughly now - 7d", got)
}
}

func TestBuildSenseNovaUsageProgress_RateLimitFillsBothWindows(t *testing.T) {
now := time.Date(2026, 9, 12, 12, 34, 45, 0, time.UTC)
rateLimitReset := now.Add(90 * time.Second)
account := &Account{RateLimitResetAt: &rateLimitReset}
func TestBuildSenseNovaUsageProgress_UsesPointUsage(t *testing.T) {
stats := &usagestats.AccountStats{Requests: 1, Tokens: 2}

rpm, tpm := buildSenseNovaUsageProgress(account, stats, now)
if rpm.Utilization != 100 || tpm.Utilization != 100 {
t.Fatalf("rate-limited utilization = (%v, %v), want (100, 100)", rpm.Utilization, tpm.Utilization)
}
if rpm.ResetsAt == nil || !rpm.ResetsAt.Equal(rateLimitReset) {
t.Fatalf("RPM reset = %v, want %v", rpm.ResetsAt, rateLimitReset)
progress := buildSenseNovaUsageProgress(stats, senseNovaFiveHourPointsLimit)
if progress.Utilization <= 0 || progress.Utilization >= 0.01 {
t.Fatalf("point utilization = %v, want roughly 0.0033", progress.Utilization)
}
if tpm.ResetsAt == nil || !tpm.ResetsAt.Equal(rateLimitReset) {
t.Fatalf("TPM reset = %v, want %v", tpm.ResetsAt, rateLimitReset)
if progress.ResetsAt != nil || progress.RemainingSeconds != 0 {
t.Fatalf("rolling point reset state = (%v, %d), want (nil, 0)", progress.ResetsAt, progress.RemainingSeconds)
}
}

Expand Down
37 changes: 25 additions & 12 deletions frontend/src/components/account/AccountUsageCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
</div>
</template>

<!-- SenseNova API key accounts: local documented RPM/TPM windows -->
<!-- SenseNova API key accounts: local rolling point windows -->
<template v-else-if="account.platform === 'sensenova'">
<div v-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1">
Expand All @@ -211,19 +211,23 @@
</div>
<div v-else-if="usageInfo" class="space-y-1">
<UsageProgressBar
v-if="usageInfo.sensenova_rpm"
label="RPM"
:utilization="usageInfo.sensenova_rpm.utilization"
:resets-at="usageInfo.sensenova_rpm.resets_at"
:window-stats="usageInfo.sensenova_rpm.window_stats"
v-if="usageInfo.sensenova_five_hour"
label="5h"
:utilization="usageInfo.sensenova_five_hour.utilization"
:resets-at="usageInfo.sensenova_five_hour.resets_at"
:window-stats="usageInfo.sensenova_five_hour.window_stats"
:usage-display="formatSenseNovaPointUsage(usageInfo.sensenova_five_hour)"
:usage-display-title="t('admin.accounts.usageWindow.senseNovaFiveHourHint')"
color="indigo"
/>
<UsageProgressBar
v-if="usageInfo.sensenova_tpm"
label="TPM"
:utilization="usageInfo.sensenova_tpm.utilization"
:resets-at="usageInfo.sensenova_tpm.resets_at"
:window-stats="usageInfo.sensenova_tpm.window_stats"
v-if="usageInfo.sensenova_seven_day"
label="7d"
:utilization="usageInfo.sensenova_seven_day.utilization"
:resets-at="usageInfo.sensenova_seven_day.resets_at"
:window-stats="usageInfo.sensenova_seven_day.window_stats"
:usage-display="formatSenseNovaPointUsage(usageInfo.sensenova_seven_day)"
:usage-display-title="t('admin.accounts.usageWindow.senseNovaWeeklyHint')"
color="emerald"
/>
</div>
Expand Down Expand Up @@ -720,7 +724,7 @@
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import type { Account, AccountUsageInfo, GeminiCredentials, UsageProgress, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { enqueueUsageRequest } from '@/utils/usageLoadQueue'
import { formatCompactNumber } from '@/utils/format'
Expand Down Expand Up @@ -782,6 +786,15 @@ const loading = ref(false)
const activeQueryLoading = ref(false)
const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
const formatSenseNovaPointUsage = (progress: UsageProgress | null | undefined) => {
if (!progress) return null
const used = Math.max(
0,
Math.round(progress.used_points ?? progress.used_tokens ?? progress.window_stats?.tokens ?? 0)
)
const limit = Math.max(0, Math.round(progress.limit_points ?? progress.limit_tokens ?? 0))
return `${used.toLocaleString('en-US')} / ${limit.toLocaleString('en-US')} ${t('admin.accounts.usageWindow.senseNovaPoints')}`
}
watch(usageInfo, (usage) => {
if (usage) emit('usage-loaded', usage)
})
Expand Down
Loading