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
12 changes: 10 additions & 2 deletions cmd/maxx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"sync/atomic"
"syscall"
"time"

"github.com/awsl-project/maxx/internal/adapter/client"
Expand Down Expand Up @@ -191,7 +191,15 @@ func main() {
log.Printf("[Startup] Caches loaded (%v)", time.Since(startupStep))

// Create router
r := router.NewRouter(cachedRouteRepo, cachedProviderRepo, cachedRoutingStrategyRepo, cachedRetryConfigRepo, cachedProjectRepo)
r := router.NewRouter(
cachedRouteRepo,
cachedProviderRepo,
cachedRoutingStrategyRepo,
cachedRetryConfigRepo,
cachedProjectRepo,
usageStatsRepo,
settingRepo,
)

// Initialize provider adapters
startupStep = time.Now()
Expand Down
4 changes: 3 additions & 1 deletion internal/core/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"time"

"github.com/awsl-project/maxx/internal/adapter/client"
"golang.org/x/crypto/bcrypt"
_ "github.com/awsl-project/maxx/internal/adapter/provider/claude" // Register claude adapter
_ "github.com/awsl-project/maxx/internal/adapter/provider/codex"
_ "github.com/awsl-project/maxx/internal/adapter/provider/custom"
Expand All @@ -26,6 +25,7 @@ import (
"github.com/awsl-project/maxx/internal/service"
"github.com/awsl-project/maxx/internal/stats"
"github.com/awsl-project/maxx/internal/waiter"
"golang.org/x/crypto/bcrypt"
)

// DatabaseConfig 数据库配置
Expand Down Expand Up @@ -254,6 +254,8 @@ func InitializeServerComponents(
repos.CachedRoutingStrategyRepo,
repos.CachedRetryConfigRepo,
repos.CachedProjectRepo,
repos.UsageStatsRepo,
repos.SettingRepo,
)

log.Printf("[Core] Initializing provider adapters")
Expand Down
45 changes: 45 additions & 0 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ type ProviderConfigCLIProxyAPICodex struct {
type ProviderConfig struct {
// 禁用错误自动冷冻(只影响错误触发的冷冻)
DisableErrorCooldown bool `json:"disableErrorCooldown,omitempty"`
Quota *ProviderQuotaConfig `json:"quota,omitempty"`
Custom *ProviderConfigCustom `json:"custom,omitempty"`
Antigravity *ProviderConfigAntigravity `json:"antigravity,omitempty"`
Kiro *ProviderConfigKiro `json:"kiro,omitempty"`
Expand All @@ -195,6 +196,47 @@ type ProviderConfig struct {
CLIProxyAPICodex *ProviderConfigCLIProxyAPICodex `json:"-"`
}

type ProviderQuotaPeriod string

const (
ProviderQuotaPeriodDay ProviderQuotaPeriod = "day"
ProviderQuotaPeriodWeek ProviderQuotaPeriod = "week"
ProviderQuotaPeriodMonth ProviderQuotaPeriod = "month"
)

type ProviderQuotaConfig struct {
Enabled bool `json:"enabled"`
Period ProviderQuotaPeriod `json:"period,omitempty"`
RequestLimit uint64 `json:"requestLimit,omitempty"`
TokenLimit uint64 `json:"tokenLimit,omitempty"`
CostLimit uint64 `json:"costLimit,omitempty"` // 纳美元
WarningThresholdPercent int `json:"warningThresholdPercent,omitempty"`
}

type ProviderQuotaMetricStatus struct {
Limit uint64 `json:"limit"`
Used uint64 `json:"used"`
Remaining uint64 `json:"remaining"`
UsagePercent float64 `json:"usagePercent"`
Warning bool `json:"warning"`
Exceeded bool `json:"exceeded"`
}

type ProviderQuotaStatus struct {
Enabled bool `json:"enabled"`
Period ProviderQuotaPeriod `json:"period"`
Timezone string `json:"timezone,omitempty"`
WarningThresholdPercent int `json:"warningThresholdPercent"`
PeriodStart time.Time `json:"periodStart"`
PeriodEnd time.Time `json:"periodEnd"`
HasAnyLimit bool `json:"hasAnyLimit"`
Requests *ProviderQuotaMetricStatus `json:"requests,omitempty"`
Tokens *ProviderQuotaMetricStatus `json:"tokens,omitempty"`
Cost *ProviderQuotaMetricStatus `json:"cost,omitempty"`
Warning bool `json:"warning"`
Exceeded bool `json:"exceeded"`
}

// Provider 供应商
type Provider struct {
ID uint64 `json:"id"`
Expand Down Expand Up @@ -227,6 +269,9 @@ type Provider struct {
// 如果配置了,在 Route 匹配时会检查前置映射后的模型是否在支持列表中
// 空数组表示支持所有模型
SupportModels []string `json:"supportModels,omitempty"`

// 实时配额状态(根据配置与当前周期用量计算)
QuotaStatus *ProviderQuotaStatus `json:"quotaStatus,omitempty"`
}

type Project struct {
Expand Down
177 changes: 177 additions & 0 deletions internal/quota/provider_quota.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package quota

import (
"fmt"
"strings"
"time"

"github.com/awsl-project/maxx/internal/domain"
"github.com/awsl-project/maxx/internal/repository"
)

const defaultWarningThreshold = 80

type UsageSummaryProvider interface {
GetSummary(tenantID uint64, filter repository.UsageStatsFilter) (*domain.UsageStatsSummary, error)
}

type TimezoneSettingProvider interface {
Get(key string) (string, error)
}

// EvaluateProviderQuota evaluates provider-level quota usage for the current period.
func EvaluateProviderQuota(
provider *domain.Provider,
tenantID uint64,
usageRepo UsageSummaryProvider,
settingRepo TimezoneSettingProvider,
now time.Time,
) (*domain.ProviderQuotaStatus, error) {
if provider == nil || provider.Config == nil || provider.Config.Quota == nil {
return nil, nil
}

cfg := provider.Config.Quota
period := normalizePeriod(cfg.Period)
warningThreshold := normalizeWarningThreshold(cfg.WarningThresholdPercent)
hasAnyLimit := cfg.RequestLimit > 0 || cfg.TokenLimit > 0 || cfg.CostLimit > 0

loc, timezoneName := resolveLocation(settingRepo)
periodStart, periodEnd := periodBounds(now, period, loc)

status := &domain.ProviderQuotaStatus{
Enabled: cfg.Enabled,
Period: period,
Timezone: timezoneName,
WarningThresholdPercent: warningThreshold,
PeriodStart: periodStart.UTC(),
PeriodEnd: periodEnd.UTC(),
HasAnyLimit: hasAnyLimit,
}

if !cfg.Enabled || !hasAnyLimit || usageRepo == nil {
return status, nil
}

providerID := provider.ID
filter := repository.UsageStatsFilter{
Granularity: domain.GranularityMinute,
StartTime: ptrTime(periodStart.UTC()),
EndTime: ptrTime(periodEnd.UTC()),
ProviderID: &providerID,
}

summary, err := usageRepo.GetSummary(tenantID, filter)
if err != nil {
return status, fmt.Errorf("get provider quota summary: %w", err)
}
if summary == nil {
summary = &domain.UsageStatsSummary{}
}

usedTokens := summary.TotalInputTokens + summary.TotalOutputTokens
status.Requests = buildMetric(cfg.RequestLimit, summary.TotalRequests, warningThreshold)
status.Tokens = buildMetric(cfg.TokenLimit, usedTokens, warningThreshold)
status.Cost = buildMetric(cfg.CostLimit, summary.TotalCost, warningThreshold)

if status.Requests != nil {
status.Warning = status.Warning || status.Requests.Warning
status.Exceeded = status.Exceeded || status.Requests.Exceeded
}
if status.Tokens != nil {
status.Warning = status.Warning || status.Tokens.Warning
status.Exceeded = status.Exceeded || status.Tokens.Exceeded
}
if status.Cost != nil {
status.Warning = status.Warning || status.Cost.Warning
status.Exceeded = status.Exceeded || status.Cost.Exceeded
}

return status, nil
}

func buildMetric(limit uint64, used uint64, warningThreshold int) *domain.ProviderQuotaMetricStatus {
if limit == 0 {
return nil
}

remaining := uint64(0)
if used < limit {
remaining = limit - used
}

usagePercent := float64(used) / float64(limit) * 100
warning := usagePercent >= float64(warningThreshold)
exceeded := used >= limit

return &domain.ProviderQuotaMetricStatus{
Limit: limit,
Used: used,
Remaining: remaining,
UsagePercent: usagePercent,
Warning: warning,
Exceeded: exceeded,
}
}

func normalizePeriod(period domain.ProviderQuotaPeriod) domain.ProviderQuotaPeriod {
switch period {
case domain.ProviderQuotaPeriodWeek, domain.ProviderQuotaPeriodMonth:
return period
default:
return domain.ProviderQuotaPeriodDay
}
}

func normalizeWarningThreshold(v int) int {
if v <= 0 || v > 100 {
return defaultWarningThreshold
}
return v
}

func resolveLocation(settingRepo TimezoneSettingProvider) (*time.Location, string) {
if settingRepo == nil {
return time.UTC, "UTC"
}

name, err := settingRepo.Get(domain.SettingKeyTimezone)
if err != nil {
return time.UTC, "UTC"
}
name = strings.TrimSpace(name)
if name == "" {
return time.UTC, "UTC"
}

loc, err := time.LoadLocation(name)
if err != nil {
return time.UTC, "UTC"
}
return loc, name
Comment on lines +133 to +151
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

默认时区回退成 UTC 会把配额周期算偏。

这里在设置缺失、读取失败或时区非法时一律回退到 UTC,但 internal/domain/model.goSettingKeyTimezone 的注释已经声明默认值是 Asia/Shanghai。这会让新实例或设置暂时不可读时的日/周/月窗口整体偏移,进而提前或延后判定 provider 超额,直接影响路由结果。建议这里复用系统默认时区,而不是硬编码成 UTC

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/quota/provider_quota.go` around lines 133 - 151, The resolveLocation
function currently falls back to UTC on missing/invalid timezone, which shifts
quota windows; change the fallback to the system/default timezone used by the
app (per internal/domain/model.go's SettingKeyTimezone default "Asia/Shanghai")
instead of hardcoding UTC: on nil settingRepo, Get error, empty name, or
LoadLocation error return the system/default location (use time.Local or load
"Asia/Shanghai") and the corresponding name rather than time.UTC/"UTC" so quota
cycles align with the declared default timezone.

}

func periodBounds(now time.Time, period domain.ProviderQuotaPeriod, loc *time.Location) (time.Time, time.Time) {
localNow := now.In(loc)
year, month, day := localNow.Date()
startOfDay := time.Date(year, month, day, 0, 0, 0, 0, loc)

switch period {
case domain.ProviderQuotaPeriodWeek:
weekday := int(startOfDay.Weekday())
if weekday == 0 {
weekday = 7
}
start := startOfDay.AddDate(0, 0, -(weekday - 1))
return start, start.AddDate(0, 0, 7)
case domain.ProviderQuotaPeriodMonth:
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
return start, start.AddDate(0, 1, 0)
default:
return startOfDay, startOfDay.AddDate(0, 0, 1)
}
}

func ptrTime(t time.Time) *time.Time {
return &t
}
Loading