diff --git a/cmd/maxx/main.go b/cmd/maxx/main.go index 0a014880..8717727c 100644 --- a/cmd/maxx/main.go +++ b/cmd/maxx/main.go @@ -10,8 +10,8 @@ import ( "os/exec" "os/signal" "path/filepath" - "syscall" "sync/atomic" + "syscall" "time" "github.com/awsl-project/maxx/internal/adapter/client" @@ -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() diff --git a/internal/core/database.go b/internal/core/database.go index 62f38ea2..b1f02743 100644 --- a/internal/core/database.go +++ b/internal/core/database.go @@ -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" @@ -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 数据库配置 @@ -254,6 +254,8 @@ func InitializeServerComponents( repos.CachedRoutingStrategyRepo, repos.CachedRetryConfigRepo, repos.CachedProjectRepo, + repos.UsageStatsRepo, + repos.SettingRepo, ) log.Printf("[Core] Initializing provider adapters") diff --git a/internal/domain/model.go b/internal/domain/model.go index b68bf657..fbf6225e 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -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"` @@ -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"` @@ -227,6 +269,9 @@ type Provider struct { // 如果配置了,在 Route 匹配时会检查前置映射后的模型是否在支持列表中 // 空数组表示支持所有模型 SupportModels []string `json:"supportModels,omitempty"` + + // 实时配额状态(根据配置与当前周期用量计算) + QuotaStatus *ProviderQuotaStatus `json:"quotaStatus,omitempty"` } type Project struct { diff --git a/internal/quota/provider_quota.go b/internal/quota/provider_quota.go new file mode 100644 index 00000000..5675d0b7 --- /dev/null +++ b/internal/quota/provider_quota.go @@ -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 +} + +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 +} diff --git a/internal/quota/provider_quota_test.go b/internal/quota/provider_quota_test.go new file mode 100644 index 00000000..af3472c2 --- /dev/null +++ b/internal/quota/provider_quota_test.go @@ -0,0 +1,174 @@ +package quota + +import ( + "testing" + "time" + + "github.com/awsl-project/maxx/internal/domain" + "github.com/awsl-project/maxx/internal/repository" +) + +type fakeUsageSummaryRepo struct { + summary *domain.UsageStatsSummary + err error + called bool + lastTenant uint64 + lastFilter repository.UsageStatsFilter +} + +func (f *fakeUsageSummaryRepo) GetSummary(tenantID uint64, filter repository.UsageStatsFilter) (*domain.UsageStatsSummary, error) { + f.called = true + f.lastTenant = tenantID + f.lastFilter = filter + if f.err != nil { + return nil, f.err + } + return f.summary, nil +} + +type fakeSettingRepo struct { + value string + err error +} + +func (f *fakeSettingRepo) Get(key string) (string, error) { + if f.err != nil { + return "", f.err + } + return f.value, nil +} + +func TestEvaluateProviderQuota_DisabledSkipsUsageQuery(t *testing.T) { + provider := &domain.Provider{ + ID: 12, + Config: &domain.ProviderConfig{ + Quota: &domain.ProviderQuotaConfig{ + Enabled: false, + Period: domain.ProviderQuotaPeriodWeek, + RequestLimit: 100, + }, + }, + } + usageRepo := &fakeUsageSummaryRepo{} + settingRepo := &fakeSettingRepo{value: "Asia/Singapore"} + + now := time.Date(2026, 3, 6, 16, 0, 0, 0, time.UTC) + status, err := EvaluateProviderQuota(provider, 1, usageRepo, settingRepo, now) + if err != nil { + t.Fatalf("EvaluateProviderQuota() error = %v", err) + } + if status == nil { + t.Fatalf("status is nil") + } + if usageRepo.called { + t.Fatalf("usage repo should not be called when quota is disabled") + } + if status.Period != domain.ProviderQuotaPeriodWeek { + t.Fatalf("period = %s, want %s", status.Period, domain.ProviderQuotaPeriodWeek) + } + if !status.HasAnyLimit { + t.Fatalf("expected HasAnyLimit=true") + } +} + +func TestEvaluateProviderQuota_EnforcedAndExceeded(t *testing.T) { + provider := &domain.Provider{ + ID: 99, + Config: &domain.ProviderConfig{ + Quota: &domain.ProviderQuotaConfig{ + Enabled: true, + Period: domain.ProviderQuotaPeriodDay, + RequestLimit: 100, + TokenLimit: 1000, + CostLimit: 5000, + WarningThresholdPercent: 70, + }, + }, + } + usageRepo := &fakeUsageSummaryRepo{ + summary: &domain.UsageStatsSummary{ + TotalRequests: 80, + TotalInputTokens: 500, + TotalOutputTokens: 700, + TotalCost: 5100, + }, + } + settingRepo := &fakeSettingRepo{value: "UTC"} + + now := time.Date(2026, 3, 6, 10, 30, 0, 0, time.UTC) + status, err := EvaluateProviderQuota(provider, 7, usageRepo, settingRepo, now) + if err != nil { + t.Fatalf("EvaluateProviderQuota() error = %v", err) + } + if status == nil { + t.Fatalf("status is nil") + } + if !usageRepo.called { + t.Fatalf("usage repo should be called when quota is enabled") + } + if usageRepo.lastTenant != 7 { + t.Fatalf("tenantID = %d, want 7", usageRepo.lastTenant) + } + if usageRepo.lastFilter.ProviderID == nil || *usageRepo.lastFilter.ProviderID != 99 { + t.Fatalf("filter.ProviderID mismatch") + } + if status.Requests == nil || status.Tokens == nil || status.Cost == nil { + t.Fatalf("expected all metric statuses to be present") + } + if !status.Requests.Warning || status.Requests.Exceeded { + t.Fatalf("requests metric expected warning without exceeded") + } + if !status.Tokens.Exceeded { + t.Fatalf("tokens metric should be exceeded") + } + if !status.Cost.Exceeded { + t.Fatalf("cost metric should be exceeded") + } + if !status.Warning || !status.Exceeded { + t.Fatalf("status warning/exceeded not set correctly") + } +} + +func TestEvaluateProviderQuota_WeekWindowByTimezone(t *testing.T) { + provider := &domain.Provider{ + ID: 1, + Config: &domain.ProviderConfig{ + Quota: &domain.ProviderQuotaConfig{ + Enabled: true, + Period: domain.ProviderQuotaPeriodWeek, + RequestLimit: 10, + }, + }, + } + usageRepo := &fakeUsageSummaryRepo{summary: &domain.UsageStatsSummary{}} + settingRepo := &fakeSettingRepo{value: "Asia/Singapore"} + + // Friday, 2026-03-06 12:00 UTC => 20:00 Singapore + now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC) + status, err := EvaluateProviderQuota(provider, 1, usageRepo, settingRepo, now) + if err != nil { + t.Fatalf("EvaluateProviderQuota() error = %v", err) + } + if status == nil { + t.Fatalf("status is nil") + } + if status.Timezone != "Asia/Singapore" { + t.Fatalf("timezone = %s, want Asia/Singapore", status.Timezone) + } + + // Week starts Monday 00:00 in Singapore => 2026-03-01 16:00:00 UTC + wantStart := time.Date(2026, 3, 1, 16, 0, 0, 0, time.UTC) + wantEnd := time.Date(2026, 3, 8, 16, 0, 0, 0, time.UTC) + if !status.PeriodStart.Equal(wantStart) { + t.Fatalf("periodStart = %s, want %s", status.PeriodStart, wantStart) + } + if !status.PeriodEnd.Equal(wantEnd) { + t.Fatalf("periodEnd = %s, want %s", status.PeriodEnd, wantEnd) + } + if usageRepo.lastFilter.StartTime == nil || !usageRepo.lastFilter.StartTime.Equal(wantStart) { + t.Fatalf("filter.StartTime mismatch") + } + if usageRepo.lastFilter.EndTime == nil || !usageRepo.lastFilter.EndTime.Equal(wantEnd) { + t.Fatalf("filter.EndTime mismatch") + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 9542e76d..48c844d5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1,13 +1,17 @@ package router import ( + "log" "math/rand" "sort" "sync" + "time" "github.com/awsl-project/maxx/internal/adapter/provider" "github.com/awsl-project/maxx/internal/cooldown" "github.com/awsl-project/maxx/internal/domain" + "github.com/awsl-project/maxx/internal/quota" + "github.com/awsl-project/maxx/internal/repository" "github.com/awsl-project/maxx/internal/repository/cached" ) @@ -35,6 +39,8 @@ type Router struct { routingStrategyRepo *cached.RoutingStrategyRepository retryConfigRepo *cached.RetryConfigRepository projectRepo *cached.ProjectRepository + usageStatsRepo quota.UsageSummaryProvider + settingRepo quota.TimezoneSettingProvider // Adapter cache adapters map[uint64]provider.ProviderAdapter @@ -51,6 +57,8 @@ func NewRouter( routingStrategyRepo *cached.RoutingStrategyRepository, retryConfigRepo *cached.RetryConfigRepository, projectRepo *cached.ProjectRepository, + usageStatsRepo repository.UsageStatsRepository, + settingRepo repository.SystemSettingRepository, ) *Router { return &Router{ routeRepo: routeRepo, @@ -58,6 +66,8 @@ func NewRouter( routingStrategyRepo: routingStrategyRepo, retryConfigRepo: retryConfigRepo, projectRepo: projectRepo, + usageStatsRepo: usageStatsRepo, + settingRepo: settingRepo, adapters: make(map[uint64]provider.ProviderAdapter), cooldownManager: cooldown.Default(), } @@ -195,6 +205,8 @@ func (r *Router) Match(ctx *MatchContext) ([]*MatchedRoute, error) { var matched []*MatchedRoute providers := r.providerRepo.GetAll() + quotaExceededCache := make(map[uint64]bool) + now := time.Now() for _, route := range filtered { prov, ok := providers[route.ProviderID] @@ -212,6 +224,28 @@ func (r *Router) Match(ctx *MatchContext) ([]*MatchedRoute, error) { continue } + if exceeded, ok := quotaExceededCache[route.ProviderID]; ok { + if exceeded { + continue + } + } else { + quotaStatus, err := quota.EvaluateProviderQuota( + prov, + tenantID, + r.usageStatsRepo, + r.settingRepo, + now, + ) + exceeded := quotaStatus != nil && quotaStatus.Exceeded + quotaExceededCache[route.ProviderID] = exceeded + if err != nil { + log.Printf("[Router] provider quota check failed providerID=%d: %v", route.ProviderID, err) + } + if exceeded { + continue + } + } + // Check if provider supports the request model // SupportModels check is done BEFORE mapping // If SupportModels is configured, check if the request model is supported @@ -308,4 +342,3 @@ func (r *Router) injectProviderUpdate(a provider.ProviderAdapter) { }) } } - diff --git a/internal/service/admin.go b/internal/service/admin.go index 1f4bd118..42bf5d36 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -14,6 +14,7 @@ import ( "github.com/awsl-project/maxx/internal/domain" "github.com/awsl-project/maxx/internal/event" "github.com/awsl-project/maxx/internal/pricing" + "github.com/awsl-project/maxx/internal/quota" "github.com/awsl-project/maxx/internal/repository" "github.com/awsl-project/maxx/internal/usage" "github.com/awsl-project/maxx/internal/version" @@ -100,11 +101,23 @@ func NewAdminService( // ===== Provider API ===== func (s *AdminService) GetProviders(tenantID uint64) ([]*domain.Provider, error) { - return s.providerRepo.List(tenantID) + providers, err := s.providerRepo.List(tenantID) + if err != nil { + return nil, err + } + for _, p := range providers { + s.attachProviderQuotaStatus(tenantID, p) + } + return providers, nil } func (s *AdminService) GetProvider(tenantID uint64, id uint64) (*domain.Provider, error) { - return s.providerRepo.GetByID(tenantID, id) + provider, err := s.providerRepo.GetByID(tenantID, id) + if err != nil { + return nil, err + } + s.attachProviderQuotaStatus(tenantID, provider) + return provider, nil } func (s *AdminService) CreateProvider(tenantID uint64, provider *domain.Provider) error { @@ -123,6 +136,18 @@ func (s *AdminService) CreateProvider(tenantID uint64, provider *domain.Provider return nil } +func (s *AdminService) attachProviderQuotaStatus(tenantID uint64, provider *domain.Provider) { + if provider == nil || provider.Config == nil || provider.Config.Quota == nil || s.usageStatsRepo == nil { + return + } + status, err := quota.EvaluateProviderQuota(provider, tenantID, s.usageStatsRepo, s.settingRepo, time.Now()) + if err != nil { + log.Printf("[AdminService] failed to evaluate provider quota status providerID=%d: %v", provider.ID, err) + return + } + provider.QuotaStatus = status +} + func (s *AdminService) UpdateProvider(tenantID uint64, provider *domain.Provider) error { // Auto-set SupportedClientTypes based on provider type s.autoSetSupportedClientTypes(provider) @@ -708,7 +733,7 @@ func (s *AdminService) ResetModelMappingsToDefaults(tenantID uint64) error { // GetAvailableClientTypes returns all available client types for model mapping func (s *AdminService) GetAvailableClientTypes() []domain.ClientType { return []domain.ClientType{ - "", // Empty means applies to all + "", // Empty means applies to all domain.ClientTypeClaude, domain.ClientTypeOpenAI, domain.ClientTypeGemini, @@ -776,11 +801,11 @@ type RecalculateCostsResult struct { // RecalculateCostsProgress represents progress update for cost recalculation type RecalculateCostsProgress struct { - Phase string `json:"phase"` // "calculating", "updating_attempts", "updating_requests", "aggregating_stats", "completed" - Current int `json:"current"` // Current item being processed - Total int `json:"total"` // Total items to process - Percentage int `json:"percentage"` // 0-100 - Message string `json:"message"` // Human-readable message + Phase string `json:"phase"` // "calculating", "updating_attempts", "updating_requests", "aggregating_stats", "completed" + Current int `json:"current"` // Current item being processed + Total int `json:"total"` // Total items to process + Percentage int `json:"percentage"` // 0-100 + Message string `json:"message"` // Human-readable message } // RecalculateCosts recalculates cost for all attempts using the current price table diff --git a/web/src/lib/transport/index.ts b/web/src/lib/transport/index.ts index 7be74f7a..5c5d7cd8 100644 --- a/web/src/lib/transport/index.ts +++ b/web/src/lib/transport/index.ts @@ -8,6 +8,10 @@ export type { ClientType, Provider, ProviderConfig, + ProviderQuotaConfig, + ProviderQuotaMetricStatus, + ProviderQuotaPeriod, + ProviderQuotaStatus, ProviderConfigCustom, ProviderConfigAntigravity, CreateProviderData, diff --git a/web/src/lib/transport/types.ts b/web/src/lib/transport/types.ts index 9a4a24e3..c952653c 100644 --- a/web/src/lib/transport/types.ts +++ b/web/src/lib/transport/types.ts @@ -6,6 +6,40 @@ // ===== 基础类型 ===== export type ClientType = 'claude' | 'codex' | 'gemini' | 'openai'; +export type ProviderQuotaPeriod = 'day' | 'week' | 'month'; + +export interface ProviderQuotaConfig { + enabled: boolean; + period?: ProviderQuotaPeriod; + requestLimit?: number; + tokenLimit?: number; + costLimit?: number; // nano USD + warningThresholdPercent?: number; +} + +export interface ProviderQuotaMetricStatus { + limit: number; + used: number; + remaining: number; + usagePercent: number; + warning: boolean; + exceeded: boolean; +} + +export interface ProviderQuotaStatus { + enabled: boolean; + period: ProviderQuotaPeriod; + timezone?: string; + warningThresholdPercent: number; + periodStart: string; + periodEnd: string; + hasAnyLimit: boolean; + requests?: ProviderQuotaMetricStatus; + tokens?: ProviderQuotaMetricStatus; + cost?: ProviderQuotaMetricStatus; + warning: boolean; + exceeded: boolean; +} // ===== Provider 相关 ===== @@ -68,6 +102,7 @@ export interface ProviderConfigClaude { export interface ProviderConfig { disableErrorCooldown?: boolean; + quota?: ProviderQuotaConfig; custom?: ProviderConfigCustom; antigravity?: ProviderConfigAntigravity; kiro?: ProviderConfigKiro; @@ -85,6 +120,7 @@ export interface Provider { config: ProviderConfig | null; supportedClientTypes: ClientType[]; supportModels?: string[]; // 支持的模型列表(通配符模式),空数组表示支持所有模型 + quotaStatus?: ProviderQuotaStatus; } // supportedClientTypes 可选,后端会根据 provider type 自动设置 diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 6095b300..d5734e5b 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -1100,7 +1100,23 @@ "multiplier": "Price Multiplier", "multiplierHint": "(1.00 = 100%)", "endpointOverride": "Endpoint Override", - "errorCooldownTitle": "3. Error Cooldown", + "quotaTitle": "3. Provider Quota", + "enableQuota": "Enable Provider Quota", + "enableQuotaDesc": "Limit requests, tokens, or cost per provider. Exceeded providers are skipped until the period resets.", + "quotaPeriod": "Reset Period", + "quotaPeriodDay": "Daily", + "quotaPeriodWeek": "Weekly", + "quotaPeriodMonth": "Monthly", + "quotaWarningThreshold": "Warning Threshold (%)", + "quotaRequestLimit": "Request Limit", + "quotaTokenLimit": "Token Limit", + "quotaCostLimit": "Cost Limit (nano USD)", + "quotaCostLimitHint": "Cost uses nano USD (1 USD = 1,000,000,000 nano USD). Leave empty for unlimited.", + "quotaUnlimitedPlaceholder": "Empty = Unlimited", + "quotaRequestShort": "REQ", + "quotaTokenShort": "TKN", + "quotaCostShort": "COST", + "errorCooldownTitle": "4. Error Cooldown", "disableErrorCooldown": "Disable Error Cooldown", "disableErrorCooldownDesc": "When enabled, errors won't trigger automatic cooldown. Manual freezes and explicit cooldown times still apply." }, diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index a865f1f4..fb8dfd91 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -1100,7 +1100,23 @@ "multiplier": "价格倍率", "multiplierHint": "(1.00 = 100%)", "endpointOverride": "端点覆盖", - "errorCooldownTitle": "3. 错误冷冻", + "quotaTitle": "3. Provider 配额", + "enableQuota": "启用 Provider 配额", + "enableQuotaDesc": "按提供商维度限制请求数、Token 或金额,超额后该 provider 会在本周期内停止被路由。", + "quotaPeriod": "重置周期", + "quotaPeriodDay": "每日", + "quotaPeriodWeek": "每周", + "quotaPeriodMonth": "每月", + "quotaWarningThreshold": "预警阈值 (%)", + "quotaRequestLimit": "请求数上限", + "quotaTokenLimit": "Token 上限", + "quotaCostLimit": "金额上限 (nano USD)", + "quotaCostLimitHint": "成本单位为 nano USD(1 USD = 1,000,000,000 nano USD)。留空表示不限制。", + "quotaUnlimitedPlaceholder": "留空 = 不限制", + "quotaRequestShort": "REQ", + "quotaTokenShort": "TKN", + "quotaCostShort": "COST", + "errorCooldownTitle": "4. 错误冷冻", "disableErrorCooldown": "禁用错误冷冻", "disableErrorCooldownDesc": "开启后,错误将不再触发自动冷冻;手动冷冻与上游明确冷冻时间仍会生效。" }, diff --git a/web/src/pages/providers/components/provider-edit-flow.tsx b/web/src/pages/providers/components/provider-edit-flow.tsx index 5531557f..fb4a1262 100644 --- a/web/src/pages/providers/components/provider-edit-flow.tsx +++ b/web/src/pages/providers/components/provider-edit-flow.tsx @@ -274,6 +274,12 @@ type EditFormData = { apiKey: string; clients: ClientConfig[]; supportModels: string[]; + quotaEnabled: boolean; + quotaPeriod: 'day' | 'week' | 'month'; + quotaRequestLimit: string; + quotaTokenLimit: string; + quotaCostLimit: string; + quotaWarningThreshold: string; cloakMode?: 'auto' | 'always' | 'never'; cloakStrictMode?: boolean; cloakSensitiveWords?: string; @@ -310,6 +316,16 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) { apiKey: provider.config?.custom?.apiKey || '', clients: initClients(), supportModels: provider.supportModels || [], + quotaEnabled: provider.config?.quota?.enabled ?? false, + quotaPeriod: provider.config?.quota?.period || 'day', + quotaRequestLimit: provider.config?.quota?.requestLimit + ? String(provider.config.quota.requestLimit) + : '', + quotaTokenLimit: provider.config?.quota?.tokenLimit ? String(provider.config.quota.tokenLimit) : '', + quotaCostLimit: provider.config?.quota?.costLimit ? String(provider.config.quota.costLimit) : '', + quotaWarningThreshold: provider.config?.quota?.warningThresholdPercent + ? String(provider.config.quota.warningThresholdPercent) + : '80', cloakMode: provider.config?.custom?.cloak?.mode || 'auto', cloakStrictMode: provider.config?.custom?.cloak?.strictMode || false, cloakSensitiveWords: (provider.config?.custom?.cloak?.sensitiveWords || []).join('\n'), @@ -338,6 +354,35 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) { .filter(Boolean); }; + const parsePositiveInt = (value: string): number | undefined => { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const num = Number(trimmed); + if (!Number.isFinite(num) || num <= 0) return undefined; + return Math.floor(num); + }; + + const buildQuotaConfig = () => { + const requestLimit = parsePositiveInt(formData.quotaRequestLimit); + const tokenLimit = parsePositiveInt(formData.quotaTokenLimit); + const costLimit = parsePositiveInt(formData.quotaCostLimit); + const warningThreshold = parsePositiveInt(formData.quotaWarningThreshold) ?? 80; + const hasAnyLimit = !!requestLimit || !!tokenLimit || !!costLimit; + + if (!formData.quotaEnabled && !hasAnyLimit) { + return undefined; + } + + return { + enabled: formData.quotaEnabled, + period: formData.quotaPeriod, + requestLimit, + tokenLimit, + costLimit, + warningThresholdPercent: Math.min(Math.max(warningThreshold, 1), 100), + }; + }; + const handleSave = async () => { if (!isValid()) return; @@ -362,6 +407,7 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) { type: provider.type || 'custom', // Preserve the provider type config: { disableErrorCooldown: !!formData.disableErrorCooldown, + quota: buildQuotaConfig(), custom: { baseURL: formData.baseURL, apiKey: formData.apiKey || provider.config?.custom?.apiKey || '', @@ -423,6 +469,7 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) { logo: provider.logo, config: { disableErrorCooldown: !!formData.disableErrorCooldown, + quota: buildQuotaConfig(), custom: { baseURL: formData.baseURL, apiKey: formData.apiKey || provider.config?.custom?.apiKey || '', @@ -695,6 +742,113 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) { /> +
+

+ {t('provider.quotaTitle')} +

+
+
+
+
+ {t('provider.enableQuota')} +
+

+ {t('provider.enableQuotaDesc')} +

+
+ + setFormData((prev) => ({ ...prev, quotaEnabled: checked })) + } + /> +
+ +
+
+ + +
+
+ + + setFormData((prev) => ({ ...prev, quotaWarningThreshold: e.target.value })) + } + placeholder="80" + /> +
+
+ +
+
+ + + setFormData((prev) => ({ ...prev, quotaRequestLimit: e.target.value })) + } + placeholder={t('provider.quotaUnlimitedPlaceholder')} + /> +
+
+ + + setFormData((prev) => ({ ...prev, quotaTokenLimit: e.target.value })) + } + placeholder={t('provider.quotaUnlimitedPlaceholder')} + /> +
+
+ + + setFormData((prev) => ({ ...prev, quotaCostLimit: e.target.value })) + } + placeholder={t('provider.quotaUnlimitedPlaceholder')} + /> +
+
+

{t('provider.quotaCostLimitHint')}

+
+
+

{t('provider.errorCooldownTitle')} diff --git a/web/src/pages/providers/components/provider-row.tsx b/web/src/pages/providers/components/provider-row.tsx index 1b73182b..7406bd80 100644 --- a/web/src/pages/providers/components/provider-row.tsx +++ b/web/src/pages/providers/components/provider-row.tsx @@ -197,6 +197,30 @@ function getCodexWeekQuotaInfo( }; } +function getGenericQuotaInfo( + status: Provider['quotaStatus'] | undefined, +): { percentage: number; label: 'requests' | 'tokens' | 'cost'; resetTime: string; exceeded: boolean } | null { + if (!status || !status.enabled || !status.hasAnyLimit) return null; + + const candidates = [ + { label: 'requests' as const, metric: status.requests }, + { label: 'tokens' as const, metric: status.tokens }, + { label: 'cost' as const, metric: status.cost }, + ].filter((item) => item.metric); + + if (candidates.length === 0) return null; + const mostUsed = candidates.reduce((max, cur) => + (cur.metric?.usagePercent || 0) > (max.metric?.usagePercent || 0) ? cur : max, + ); + + return { + percentage: Math.min(Math.round(mostUsed.metric?.usagePercent || 0), 100), + label: mostUsed.label, + resetTime: status.periodEnd, + exceeded: !!mostUsed.metric?.exceeded, + }; +} + export function ProviderRow({ provider, stats, streamingCount, onClick }: ProviderRowProps) { const { t } = useTranslation(); // 使用通用配置系统 @@ -221,6 +245,7 @@ export function ProviderRow({ provider, stats, streamingCount, onClick }: Provid const codexQuota = useCodexQuotaFromContext(provider.id); const codex5HInfo = isCodex ? getCodex5HQuotaInfo(codexQuota) : null; const codexWeekInfo = isCodex ? getCodexWeekQuotaInfo(codexQuota) : null; + const genericQuotaInfo = getGenericQuotaInfo(provider.quotaStatus); return (
)}
+ ) : genericQuotaInfo ? ( +
+ + {genericQuotaInfo.label === 'requests' + ? t('provider.quotaRequestShort') + : genericQuotaInfo.label === 'tokens' + ? t('provider.quotaTokenShort') + : t('provider.quotaCostShort')} + +
+
= 80 + ? 'bg-amber-500' + : 'bg-emerald-500', + )} + style={{ width: `${genericQuotaInfo.percentage}%` }} + /> +
+ + {formatResetTime(genericQuotaInfo.resetTime, t)} + +
) : (