Skip to content

fix: resolve #56 - 支持 Quota#334

Closed
awsl233777 wants to merge 1 commit intomainfrom
fix/issue-56
Closed

fix: resolve #56 - 支持 Quota#334
awsl233777 wants to merge 1 commit intomainfrom
fix/issue-56

Conversation

@awsl233777
Copy link
Collaborator

@awsl233777 awsl233777 commented Mar 6, 2026

Closes #56

Summary by CodeRabbit

发布说明

  • 新功能
    • 新增Provider级别配额管理功能,支持按请求数、Token和成本设置限制,提供日/周/月周期选项。
    • 配额超限时自动停止将流量路由到该Provider。
    • 前端界面支持配额配置和实时使用状态查看,包括预警阈值和重置时间显示。

@ymkiux
Copy link
Contributor

ymkiux commented Mar 7, 2026

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

📝 Walkthrough

Walkthrough

该PR实现了提供商级别配额功能,包括后端配额评估逻辑、路由器集成、服务层适配和前端UI组件,支持按请求数、Token或成本限额,超额时停止路由。

Changes

Cohort / File(s) Summary
后端依赖注入扩展
cmd/maxx/main.go, internal/core/database.go, internal/router/router.go
Router构造函数新增usageStatsRepo和settingRepo参数,支持配额评估所需依赖的注入。
数据模型定义
internal/domain/model.go
新增ProviderQuotaPeriod、ProviderQuotaConfig、ProviderQuotaMetricStatus、ProviderQuotaStatus类型,扩展Provider和ProviderConfig以支持配额配置和状态字段。
配额评估核心逻辑
internal/quota/provider_quota.go
实现EvaluateProviderQuota函数及其辅助函数,支持基于时区的周期计算、使用统计聚合、指标计算和警告阈值判断。
配额评估测试
internal/quota/provider_quota_test.go
为EvaluateProviderQuota添加单元测试,覆盖禁用配额、超额场景和时区周期计算。
服务层集成
internal/service/admin.go
GetProviders和GetProvider方法新增attachProviderQuotaStatus调用,确保返回的提供商包含实时配额状态。
前端类型定义
web/src/lib/transport/types.ts, web/src/lib/transport/index.ts
新增ProviderQuotaConfig、ProviderQuotaMetricStatus、ProviderQuotaPeriod、ProviderQuotaStatus类型,扩展ProviderConfig和Provider接口。
前端国际化
web/src/locales/en.json, web/src/locales/zh.json
添加配额相关本地化键值,包括启用、周期、警告阈值、限额设置等,调整错误冷冻标号。
前端编辑流程
web/src/pages/providers/components/provider-edit-flow.tsx
在提供商编辑表单中新增配额配置UI,包括启用开关、周期选择、警告阈值和请求/Token/成本限额输入框。
前端行显示
web/src/pages/providers/components/provider-row.tsx
新增getGenericQuotaInfo辅助函数,在提供商行中展示通用配额进度条和重置时间。

Sequence Diagram

sequenceDiagram
    participant Client as 路由客户端
    participant Router as Router.Match()
    participant QuotaPkg as quota.EvaluateProviderQuota
    participant UsageRepo as UsageStatsRepository
    participant SettingRepo as SettingRepository
    participant Domain as 数据模型

    Client->>Router: 路由请求(提供商)
    Router->>Router: 初始化配额缓存
    
    loop 对每个候选提供商
        Router->>QuotaPkg: EvaluateProviderQuota(provider, usageRepo, settingRepo)
        activate QuotaPkg
        
        QuotaPkg->>Domain: 验证配额配置和周期
        alt 配额禁用或无限额
            QuotaPkg-->>Router: 返回禁用状态
        else 配额启用且有限额
            QuotaPkg->>SettingRepo: Get(timezone_key)
            SettingRepo-->>QuotaPkg: 返回时区设置
            
            QuotaPkg->>QuotaPkg: 计算周期边界
            
            QuotaPkg->>UsageRepo: GetSummary(tenantID, filter)
            UsageRepo-->>QuotaPkg: 返回使用统计
            
            QuotaPkg->>Domain: buildMetric(requests, tokens, cost)
            Domain-->>QuotaPkg: 构建指标状态
            
            QuotaPkg->>QuotaPkg: 聚合警告和超额标志
            QuotaPkg-->>Router: 返回ProviderQuotaStatus
        end
        deactivate QuotaPkg
        
        Router->>Router: 检查是否超额<br/>缓存结果
        
        alt 配额超额
            Router->>Router: 跳过该提供商
            Router->>Router: 记录配额评估日志
        else 配额未超额
            Router->>Router: 继续该提供商<br/>用于匹配
        end
    end
    
    Router-->>Client: 返回路由结果
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • Bowl42
  • whhjdi

Poem

🐰✨ 配额系统悄然生长,
请求、Token、金额都有了边界,
Router把关巡逻者,
超额时优雅停驻,
一只兔子为此欢呼,
限制之美,永恒守护! 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.53% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题明确概括了主要变更:实现Quota(配额)功能支持,与提供的代码摘要完全一致。
Linked Issues check ✅ Passed PR实现了完整的Provider Quota功能,包括模型定义、业务逻辑、API集成和UI实现,与#56需求相符。
Out of Scope Changes check ✅ Passed 所有变更均围绕Quota功能实现,包括后端模型、业务逻辑、路由集成、API暴露和前端UI,无明显超出范围的改动。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/issue-56

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
web/src/pages/providers/components/provider-edit-flow.tsx (1)

386-442: 建议:handleSavehandleClone 中构建 provider 数据的逻辑高度重复。

两个函数中 clientBaseURLclientMultipliercloakquota 的构建方式完全相同。可考虑提取为共享函数以减少维护成本。

♻️ 可选的重构建议
// 提取共享的 config 构建逻辑
const buildProviderConfig = () => {
  const supportedClientTypes = formData.clients.filter((c) => c.enabled).map((c) => c.id);
  const clientBaseURL: Partial<Record<ClientType, string>> = {};
  const clientMultiplier: Partial<Record<ClientType, number>> = {};
  formData.clients.forEach((c) => {
    if (c.enabled && c.urlOverride) {
      clientBaseURL[c.id] = c.urlOverride;
    }
    if (c.enabled && c.multiplier !== 10000) {
      clientMultiplier[c.id] = c.multiplier;
    }
  });

  return {
    supportedClientTypes,
    config: {
      disableErrorCooldown: !!formData.disableErrorCooldown,
      quota: buildQuotaConfig(),
      custom: {
        baseURL: formData.baseURL,
        apiKey: formData.apiKey || provider.config?.custom?.apiKey || '',
        clientBaseURL: Object.keys(clientBaseURL).length > 0 ? clientBaseURL : undefined,
        clientMultiplier: Object.keys(clientMultiplier).length > 0 ? clientMultiplier : undefined,
        cloak: /* ... cloak config ... */,
      },
    },
  };
};

Also applies to: 444-526

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

In `@web/src/pages/providers/components/provider-edit-flow.tsx` around lines 386 -
442, handleSave and handleClone duplicate the logic for building provider
payloads (clientBaseURL, clientMultiplier, cloak, quota, supportedClientTypes);
extract that shared logic into a helper (e.g., buildProviderConfig or
buildProviderPayload) that reads formData and calls buildQuotaConfig and
parseSensitiveWords, returns { supportedClientTypes, config } (with
custom.baseURL, apiKey fallback, clientBaseURL/clientMultiplier only when
non-empty, and cloak only when needed). Replace the duplicated blocks in
handleSave and handleClone to call this helper and use its return value when
calling updateProvider.mutateAsync / createProvider.mutateAsync so behavior
remains identical but maintenance is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/quota/provider_quota.go`:
- Around line 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.

In `@web/src/locales/en.json`:
- Around line 1103-1119: The "quotaTitle" string currently reads "3. Provider
Quota" which conflicts with "provider.cloakTitle" ("3. Claude Cloaking"); update
the numbering to avoid duplicate "3." by either removing hardcoded numeric
prefixes across locale keys or renumbering the affected keys (change
"quotaTitle" -> "4. Provider Quota" and update subsequent keys like
"errorCooldownTitle" -> "5. Error Cooldown"); locate and adjust the locale keys
"quotaTitle" and "errorCooldownTitle" (and any other nearby numbered titles) or
remove numbering and have the component render section numbers instead.

In `@web/src/pages/providers/components/provider-row.tsx`:
- Around line 200-221: getGenericQuotaInfo currently ignores the
backend-calculated warning threshold and always assumes an 80% warning; update
getGenericQuotaInfo (and its return shape) to include and forward the backend's
computed warning flag by returning warning: !!mostUsed.metric?.warning (in
addition to percentage/label/resetTime/exceeded) so the UI can reuse the
backend's metric.warning instead of hardcoding 80% when deciding colors/alerts;
ensure references to Provider['quotaStatus'] and mostUsed.metric are used to
populate this new warning field.

---

Nitpick comments:
In `@web/src/pages/providers/components/provider-edit-flow.tsx`:
- Around line 386-442: handleSave and handleClone duplicate the logic for
building provider payloads (clientBaseURL, clientMultiplier, cloak, quota,
supportedClientTypes); extract that shared logic into a helper (e.g.,
buildProviderConfig or buildProviderPayload) that reads formData and calls
buildQuotaConfig and parseSensitiveWords, returns { supportedClientTypes, config
} (with custom.baseURL, apiKey fallback, clientBaseURL/clientMultiplier only
when non-empty, and cloak only when needed). Replace the duplicated blocks in
handleSave and handleClone to call this helper and use its return value when
calling updateProvider.mutateAsync / createProvider.mutateAsync so behavior
remains identical but maintenance is centralized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8e0979c1-d33c-461b-8702-cfb0d249fbed

📥 Commits

Reviewing files that changed from the base of the PR and between 8526d26 and 77df01b.

📒 Files selected for processing (13)
  • cmd/maxx/main.go
  • internal/core/database.go
  • internal/domain/model.go
  • internal/quota/provider_quota.go
  • internal/quota/provider_quota_test.go
  • internal/router/router.go
  • internal/service/admin.go
  • web/src/lib/transport/index.ts
  • web/src/lib/transport/types.ts
  • web/src/locales/en.json
  • web/src/locales/zh.json
  • web/src/pages/providers/components/provider-edit-flow.tsx
  • web/src/pages/providers/components/provider-row.tsx
📜 Review details
🔇 Additional comments (14)
internal/service/admin.go (4)

103-111: LGTM!

GetProviders 现在正确地为每个 provider 附加配额状态。循环遍历并调用 attachProviderQuotaStatus 的实现清晰简洁。


114-121: LGTM!

GetProvider 同样正确地附加了配额状态,与 GetProviders 保持一致的处理模式。


139-149: LGTM!

attachProviderQuotaStatus 实现合理:

  • 完善的 nil 检查(provider、config、quota、usageStatsRepo)
  • 错误仅记录日志而不阻断流程,符合状态增强的非关键性质
  • quota.EvaluateProviderQuota 的接口契约一致

736-736: 格式调整,无功能变更。

Also applies to: 804-808

web/src/pages/providers/components/provider-edit-flow.tsx (4)

277-287: LGTM!

类型定义清晰完整,涵盖了配额功能所需的所有字段。默认值的选择合理(quotaPeriod 为 'day',warningThreshold 为 80%)。


319-333: LGTM!

初始化逻辑正确处理了可选值,使用 ?? 和条件表达式为未配置的字段提供合理默认值。


357-384: LGTM!

辅助函数实现良好:

  • parsePositiveInt 正确处理空值、NaN、Infinity 和非正数
  • buildQuotaConfig 在未启用且无限制时返回 undefined,避免发送空配置
  • warningThresholdPercent 钳制到 1-100 范围是合理的边界校验

745-850: LGTM!

配额配置 UI 实现完善:

  • 启用开关控制整体功能
  • 周期选择器提供日/周/月选项
  • 数值输入正确设置了 type="number"min 约束
  • 使用 i18n 进行国际化
  • 提供了成本单位说明(nano USD)
internal/router/router.go (3)

42-43: LGTM!

使用接口类型 (quota.UsageSummaryProviderquota.TimezoneSettingProvider) 而非具体仓库类型,便于测试和解耦。


54-74: LGTM!

NewRouter 签名扩展正确。接受具体的 repository 类型并赋值给接口字段,利用了 Go 的隐式接口实现。根据 context snippet 2-4,repository.UsageStatsRepositoryrepository.SystemSettingRepository 均满足相应接口契约。


208-247: LGTM!

配额评估逻辑实现合理:

  • quotaExceededCache 避免同一 provider 重复评估
  • 先缓存结果再记录错误,顺序正确
  • exceeded := quotaStatus != nil && quotaStatus.Exceeded 处理了 quotaStatus 可能为 nil 的情况
  • 采用 fail-open 策略:评估失败时不阻断路由,仅记录日志
web/src/lib/transport/types.ts (2)

9-42: LGTM!

新增的配额相关类型与 Go 后端 internal/domain/model.go 中的结构体定义一致:

  • 字段名称和 JSON 标签匹配
  • periodStart/periodEnd 使用 string 类型正确(Go 的 time.Time 序列化为 ISO 字符串)
  • 可选字段标记合理

符合文件头注释"与 Go internal/domain/model.go 保持同步"的要求。


103-111: LGTM!

ProviderConfigProvider 接口正确扩展了配额相关字段,与后端数据结构保持一致。

Also applies to: 113-124

web/src/locales/zh.json (1)

1103-1119: LGTM!

配额相关的本地化字符串完整且翻译准确:

  • 章节编号从 "3. 错误冷冻" 更新为 "4. 错误冷冻",与新增的 "3. Provider 配额" 保持一致
  • quotaCostLimitHint 清晰解释了 nano USD 单位
  • 所有 UI 元素都有对应的翻译

Comment on lines +133 to +151
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
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.

Comment on lines +1103 to +1119
"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",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

步骤编号和现有标题冲突。

Line 1103 把配额段落写成了 3. Provider Quota,但同一命名空间里 provider.cloakTitle 仍然是 3. Claude Cloaking。如果这两个区块会同时显示,界面上会出现两个“3.”。建议把这里和后续标题一起顺延,或者把编号移到组件里统一生成。

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

In `@web/src/locales/en.json` around lines 1103 - 1119, The "quotaTitle" string
currently reads "3. Provider Quota" which conflicts with "provider.cloakTitle"
("3. Claude Cloaking"); update the numbering to avoid duplicate "3." by either
removing hardcoded numeric prefixes across locale keys or renumbering the
affected keys (change "quotaTitle" -> "4. Provider Quota" and update subsequent
keys like "errorCooldownTitle" -> "5. Error Cooldown"); locate and adjust the
locale keys "quotaTitle" and "errorCooldownTitle" (and any other nearby numbered
titles) or remove numbering and have the component render section numbers
instead.

Comment on lines +200 to +221
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,
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

通用配额条忽略了可配置的预警阈值。

这里把预警颜色写死成了 80%,但后端的 warningThresholdPercent 是可配置的,而且 metric.warning 已经算好了。阈值不是 80 时,UI 会把已经进入 warning 的 provider 继续显示成绿色,和实际状态不一致。

💡 建议改成直接复用后端计算结果
 function getGenericQuotaInfo(
   status: Provider['quotaStatus'] | undefined,
-): { percentage: number; label: 'requests' | 'tokens' | 'cost'; resetTime: string; exceeded: boolean } | null {
+): {
+  percentage: number;
+  label: 'requests' | 'tokens' | 'cost';
+  resetTime: string;
+  warning: boolean;
+  exceeded: boolean;
+} | null {
   if (!status || !status.enabled || !status.hasAnyLimit) return null;
@@
   return {
     percentage: Math.min(Math.round(mostUsed.metric?.usagePercent || 0), 100),
     label: mostUsed.label,
     resetTime: status.periodEnd,
+    warning: !!mostUsed.metric?.warning,
     exceeded: !!mostUsed.metric?.exceeded,
   };
 }
@@
                     'h-full rounded-full transition-all duration-1000',
                     genericQuotaInfo.exceeded
                       ? 'bg-red-500'
-                      : genericQuotaInfo.percentage >= 80
+                      : genericQuotaInfo.warning
                         ? 'bg-amber-500'
                         : 'bg-emerald-500',
                   )}

Also applies to: 441-465

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

In `@web/src/pages/providers/components/provider-row.tsx` around lines 200 - 221,
getGenericQuotaInfo currently ignores the backend-calculated warning threshold
and always assumes an 80% warning; update getGenericQuotaInfo (and its return
shape) to include and forward the backend's computed warning flag by returning
warning: !!mostUsed.metric?.warning (in addition to
percentage/label/resetTime/exceeded) so the UI can reuse the backend's
metric.warning instead of hardcoding 80% when deciding colors/alerts; ensure
references to Provider['quotaStatus'] and mostUsed.metric are used to populate
this new warning field.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

支持 Quota

3 participants