diff --git a/controller/audit.go b/controller/audit.go index d6974b900806..31a481279a3f 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -16,19 +16,20 @@ import ( // action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的 // 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。 var auditContentTemplates = map[string]string{ - "user.create": "Created user ${username} (role ${role})", - "user.update": "Updated user ${username} (ID: ${id})", - "user.delete": "Deleted user ${username} (ID: ${id})", - "user.manage": "Performed ${action} on user ${username} (ID: ${id})", - "user.quota_add": "Increased user quota by ${quota}", - "user.quota_subtract": "Decreased user quota by ${quota}", - "user.quota_override": "Overrode user quota from ${from} to ${to}", - "user.binding_clear": "Cleared ${bindingType} binding for user ${username}", - "user.2fa_disable": "Force-disabled two-factor authentication for the user", - "user.passkey_register": "Registered a passkey", - "user.passkey_delete": "Deleted a passkey", - "user.reset_passkey": "Reset the user passkey", - "option.update": "Updated system setting ${key}", + "user.create": "Created user ${username} (role ${role})", + "user.update": "Updated user ${username} (ID: ${id})", + "user.delete": "Deleted user ${username} (ID: ${id})", + "user.manage": "Performed ${action} on user ${username} (ID: ${id})", + "user.quota_add": "Increased user quota by ${quota}", + "user.quota_subtract": "Decreased user quota by ${quota}", + "user.quota_override": "Overrode user quota from ${from} to ${to}", + "user.binding_clear": "Cleared ${bindingType} binding for user ${username}", + "user.2fa_disable": "Force-disabled two-factor authentication for the user", + "user.passkey_register": "Registered a passkey", + "user.passkey_delete": "Deleted a passkey", + "user.reset_passkey": "Reset the user passkey", + "option.update": "Updated system setting ${key}", + "savings.official_price_update": "Updated savings official price setting", "channel.create": "Created channel ${name} (type ${type}, count ${count})", "channel.update": "Updated channel ${name} (ID: ${id})", diff --git a/controller/option.go b/controller/option.go index 940bb3069023..1379652bd8ae 100644 --- a/controller/option.go +++ b/controller/option.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/setting/savings_setting" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" @@ -299,6 +300,15 @@ func UpdateOption(c *gin.Context) { }) return } + case savings_setting.OptionKey: + err = savings_setting.ValidateSettingJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "节省估算设置失败: " + err.Error(), + }) + return + } case "ModelRequestRateLimitGroup": err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) if err != nil { @@ -369,7 +379,11 @@ func UpdateOption(c *gin.Context) { return } // 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。 - recordManageAudit(c, "option.update", map[string]interface{}{ + auditAction := "option.update" + if option.Key == savings_setting.OptionKey { + auditAction = "savings.official_price_update" + } + recordManageAudit(c, auditAction, map[string]interface{}{ "key": option.Key, }) c.JSON(http.StatusOK, gin.H{ diff --git a/controller/savings.go b/controller/savings.go new file mode 100644 index 000000000000..62619afeac9d --- /dev/null +++ b/controller/savings.go @@ -0,0 +1,108 @@ +package controller + +import ( + "errors" + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" +) + +func GetUserSavingsSummary(c *gin.Context) { + startTimestamp, endTimestamp, err := parseSavingsTimeRange(c) + if err != nil { + respondSavingsBadRequest(c, err) + return + } + effectiveEndTimestamp, err := service.NormalizeSavingsSummaryWindow(startTimestamp, endTimestamp) + if err != nil { + respondSavingsBadRequest(c, err) + return + } + + summary, err := service.GetUserSavingsSummary(c.GetInt("id"), startTimestamp, effectiveEndTimestamp) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, summary) +} + +func GetUserSavingsLifetime(c *gin.Context) { + summary, err := service.GetUserSavingsLifetimeSummary(c.GetInt("id")) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, summary) +} + +func GetUserSavingsTrend(c *gin.Context) { + startTimestamp, endTimestamp, err := parseSavingsTimeRange(c) + if err != nil { + respondSavingsBadRequest(c, err) + return + } + granularity := c.Query("granularity") + utcOffsetMinutes, err := strconv.Atoi(c.Query("utc_offset_minutes")) + if err != nil { + respondSavingsBadRequest(c, service.ErrSavingsUTCOffsetRequired) + return + } + effectiveEndTimestamp, err := service.NormalizeSavingsTrendWindow(startTimestamp, endTimestamp, granularity, utcOffsetMinutes) + if err != nil { + respondSavingsBadRequest(c, err) + return + } + + trend, err := service.GetUserSavingsTrend(c.GetInt("id"), startTimestamp, effectiveEndTimestamp, granularity, utcOffsetMinutes) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, trend) +} + +func parseSavingsTimeRange(c *gin.Context) (int64, int64, error) { + startTimestamp, err := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + if err != nil { + return 0, 0, service.ErrSavingsTimeRangeRequired + } + endTimestamp, err := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + if err != nil { + return 0, 0, service.ErrSavingsTimeRangeRequired + } + return startTimestamp, endTimestamp, nil +} + +func respondSavingsBadRequest(c *gin.Context, err error) { + messageKey := i18n.MsgInvalidParams + switch { + case errors.Is(err, service.ErrSavingsTimeRangeRequired): + messageKey = i18n.MsgSavingsTimeRangeRequired + case errors.Is(err, service.ErrSavingsUTCOffsetRequired): + messageKey = i18n.MsgSavingsUTCOffsetRequired + case errors.Is(err, service.ErrSavingsUTCOffsetInvalid): + messageKey = i18n.MsgSavingsUTCOffsetInvalid + case errors.Is(err, service.ErrSavingsEndAfterNow): + messageKey = i18n.MsgSavingsEndAfterNow + case errors.Is(err, service.ErrSavingsTimeRangeInvalid): + messageKey = i18n.MsgSavingsTimeRangeInvalid + case errors.Is(err, service.ErrSavingsTimeRangeTooLarge): + messageKey = i18n.MsgSavingsTimeRangeTooLarge + case errors.Is(err, service.ErrSavingsHourRangeTooLarge): + messageKey = i18n.MsgSavingsHourRangeTooLarge + case errors.Is(err, service.ErrSavingsGranularity): + messageKey = i18n.MsgSavingsGranularityInvalid + case errors.Is(err, service.ErrSavingsTooManyBuckets): + messageKey = i18n.MsgSavingsTooManyBuckets + } + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": i18n.T(c, messageKey), + }) +} diff --git a/controller/savings_test.go b/controller/savings_test.go new file mode 100644 index 000000000000..fd46dbd921e6 --- /dev/null +++ b/controller/savings_test.go @@ -0,0 +1,68 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetUserSavingsSummaryLocalizesInvalidTimeRange(t *testing.T) { + require.NoError(t, i18n.Init()) + tests := []struct { + name string + language string + expected string + }{ + {name: "English", language: "en", expected: "Start and end times are required"}, + {name: "Chinese", language: "zh-CN", expected: "必须传入开始和结束时间"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/user/savings/summary", nil) + ctx.Request.Header.Set("Accept-Language", tt.language) + + GetUserSavingsSummary(ctx) + + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + } + require.NoError(t, common.DecodeJson(recorder.Body, &response)) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.False(t, response.Success) + assert.Equal(t, tt.expected, response.Message) + }) + } +} + +func TestGetUserSavingsTrendRejectsMissingUTCOffset(t *testing.T) { + require.NoError(t, i18n.Init()) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest( + http.MethodGet, + "/api/user/savings/trend?start_timestamp=1&end_timestamp=2&granularity=day", + nil, + ) + + GetUserSavingsTrend(ctx) + + var response struct { + Success bool `json:"success"` + Message string `json:"message"` + } + require.NoError(t, common.DecodeJson(recorder.Body, &response)) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.False(t, response.Success) + assert.Equal(t, "UTC offset is required", response.Message) +} diff --git a/controller/system_task.go b/controller/system_task.go index 884a45330c62..7b35eba5021c 100644 --- a/controller/system_task.go +++ b/controller/system_task.go @@ -34,6 +34,62 @@ func CreateLogCleanupSystemTask(c *gin.Context) { }) } +func CreateSavingsLifetimeBackfillTask(c *gin.Context) { + task, created, err := service.StartSavingsLifetimeBackfill() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "created": created, + "task": task.ToResponse(), + }, + }) +} + +func GetSavingsLifetimeBackfillTask(c *gin.Context) { + task, err := model.GetLatestSystemTask(model.SystemTaskTypeSavingsBackfill) + if err != nil { + common.ApiError(c, err) + return + } + if task == nil { + common.ApiSuccess(c, nil) + return + } + common.ApiSuccess(c, task.ToResponse()) +} + +func PauseSavingsLifetimeBackfillTask(c *gin.Context) { + task, err := service.PauseSavingsLifetimeBackfill(c.Query("task_id")) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, task.ToResponse()) +} + +func ResumeSavingsLifetimeBackfillTask(c *gin.Context) { + task, err := service.ResumeSavingsLifetimeBackfill(c.Query("task_id")) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, task.ToResponse()) +} + +func RetrySavingsLifetimeBackfillTask(c *gin.Context) { + task, err := service.RetrySavingsLifetimeBackfill(c.Query("task_id")) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, task.ToResponse()) +} + func GetCurrentSystemTask(c *gin.Context) { taskType := c.Query("type") if taskType == "" { diff --git a/docs/user-savings-estimate-design.md b/docs/user-savings-estimate-design.md new file mode 100644 index 000000000000..bc5992934421 --- /dev/null +++ b/docs/user-savings-estimate-design.md @@ -0,0 +1,1379 @@ +# 用户节省金额展示功能设计文档 + +> 状态:短窗口与历史累计功能已实施(2026-07-29) +> 目标功能:向用户展示“RAPI 已帮你节省约 xx 元” +> 基准日期:2026-07-27 +> 累计扩展确认日期:2026-07-29 +> 相关页面:用户概览、钱包、用量日志、模型广场 +> 相关模块:`model/pricing.go`、`controller/pricing.go`、`service/text_quota.go`、`model/log.go`、`web/src/features/pricing/**`、`web/src/features/dashboard/**`、`web/src/features/usage-logs/**` +> 后续 UI 改造:`docs/user-savings-summary-ui-redesign.md` + +## 1. 背景 + +当前系统已经具备完整的模型价格展示与消费日志能力: + +- 模型广场通过 `/api/pricing` 暴露模型价格、分组倍率、可用分组、供应商与端点信息。 +- 后端计费在请求结算时写入消费日志,日志包含实际扣费 `quota`、模型名、分组、token 数、计费倍率和 `other` 快照。 +- 前端已有统一的额度与货币格式化能力,可以把 quota 展示为 USD、CNY 或自定义货币。 +- 价格同步已支持官方倍率预设、`models.dev`、OpenRouter 和其他 new-api 兼容定价接口。 + +因此,“节省金额”不需要从零建立一套独立价格系统。实现直接复用模型广场的本地基础定价与消费日志:新消费在结算时固化官方定价估算,已有消费在查询时按当前本地官方定价进行受限回算。 + +### 1.1 官方定价声明 + +本文确认:当前实例模型广场的本地基础定价来自模型服务商官方公开定价,可以作为节省估算的默认官方基准。这里的“本地”指价格已经存在于当前服务进程及其配置中,不表示渠道采购价,也不需要运行时访问外部官网。 + +实现和运营必须遵守以下约束: + +- 默认从 `model.GetPricing()` 读取模型广场本地基础定价,并把该来源标记为 `local_pricing_snapshot`。 +- 启用本地价格回退即表示管理员确认当前模型广场基础定价来自官方公开定价;该声明必须在节省设置中显式保存并可关闭。 +- `official_prices` 不是必须完整维护的第二份价格表,只用于覆盖个别模型、补充来源 URL、纠正本地价格或冻结特殊版本。 +- `models.dev`、OpenRouter、兼容 `/api/pricing` 或 `/api/ratio_config` 只是本地价格的导入通道;导入后的本地基础价是否仍可声明为官方价,由管理员对当前实例负责。 +- 管理员自定义折扣价、渠道采购价、站内促销价、充值优惠价和分组优惠价不得作为官方定价。 +- 每条新消费估算必须记录价格来源和快照时间;有官方来源更新时间时一并记录,但不得用缓存刷新时间冒充官方更新时间。 +- 官方定价来源应尽量记录服务商官方价格页、官方文档或官方公告 URL,便于管理员审计。 +- 无法确认官方来源的模型必须跳过节省估算,不得用默认倍率猜测。 + +## 2. 目标 + +- 用户概览默认展示滚动近 24 小时估算节省;汇总接口支持不超过 31 天的自定义短窗口。 +- 复用现有模型广场价格体系,避免重复维护模型价格。 +- 保证功能上线后的新消费采用日志快照,不因后续价格表调整而漂移。 +- 让功能上线前已有的普通文本消费记录也能参与估算,并明确标识为按当前官方价回算。 +- 在独立聚合阶段尽可能回算历史消费并冻结结果,为用户提供稳定的累计估算节省、累计覆盖率和统计起始时间。 +- 文案明确为“估算”和“官方定价”,不作为严格财务账单。 +- 对管理员可解释:每条日志能追溯官方定价、实际扣费和差额。 +- 不影响现有计费、预扣费、结算、退款和订阅扣费语义。 + +## 3. 非目标 + +- 不承诺节省金额与任何官方账单逐分一致。 +- 不抓取或实时校验所有上游官网价格。 +- 不把“官方定价”用于实际扣费。 +- 不在第一阶段支持所有复杂任务类、图片、音频、视频和依赖请求上下文的特殊表达式完整官方价对齐。 +- 不在第一阶段展示累计节省金额;累计值在独立聚合阶段实现,不允许通过用户请求实时扫描全部消费日志。 +- 不在第一阶段统计任务类、固定按次类、无法仅凭日志 token 确定性复算的动态表达式和会产生后续退款/重算的异步请求。 +- 不新增生产依赖,不引入外部价格 SaaS。 +- 不修改受保护的项目品牌、许可、归属、包名和元数据。 + +## 4. 核心口径 + +### 4.1 展示口径 + +用户可见文案统一使用估算表达: + +- `已为你节省约 {{amount}}` +- `按官方定价估算` +- `定价快照时间:{{time}}` +- `含按当前官方定价回算的历史消费` +- `部分特殊请求暂不计入节省估算` + +避免使用以下绝对表述: + +- `真实节省` +- `官方账单价` +- `保证比官方便宜` +- `精确节省` + +### 4.2 计算口径 + +每次请求结算时计算并记录: + +```text +estimated_official_quota = 按官方定价估算的 quota +actual_quota = 当前请求实际扣费 quota +savings_quota = max(estimated_official_quota - actual_quota, 0) +savings_ratio = savings_quota / estimated_official_quota +``` + +只展示非负节省。若实际价格高于官方定价估算,`savings_quota` 记为 `0`,不向用户展示“亏损”或负节省。该请求仍可写入 `savings_estimate`,用于统计覆盖率和管理员解释,但前端不展示“节省 0”。 + +官方定价估算不叠加站内分组倍率、用户特殊倍率、充值折扣、渠道采购成本或管理员促销策略;实际扣费则直接使用本次请求最终写入日志的 `quota`。因此该差额表达的是“用户实际扣费相比官方公开定价少消耗的额度”,而不是上游采购成本差额。 + +### 4.3 金额换算 + +后端统一存储 quota,不存储展示货币金额。前端继续使用现有额度/货币格式化逻辑把 `savings_quota` 转换为站点配置的展示货币。 + +这样可以保持: + +- 账务基础单位一致。 +- 多货币展示随站点配置自动变化。 +- 历史数据不受汇率展示配置影响。 +- 展示金额是“按官方定价与站内实际扣费折算的额度差额”,不是用户真实付款差额。 + +上述规则适用于近 24 小时和不超过 31 天的短窗口查询。历史累计扩展为了保证人民币累计值不会随站点汇率配置变化而漂移,需要在聚合事件中额外冻结 `quota_per_unit` 和 USD/CNY 汇率,并存储按微元计算的人民币金额;累计接口不得再使用查询时当前汇率重新换算历史总额。 + +## 5. 数据来源设计 + +### 5.1 官方定价来源 + +匹配同一个模型时按以下优先级取价: + +1. `official_prices[model]` 中已确认的人工覆盖项。 +2. `model.GetPricing()` 返回的模型广场本地基础定价快照。 +3. 无匹配价格时跳过,不再回退到模糊名称、默认倍率或渠道成本。 + +`model.GetPricing()` 中的 `ModelRatio`、`ModelPrice`、`CompletionRatio`、缓存倍率、图片倍率、音频倍率和计费模式来自本地基础定价。节省计算只取这些基础字段,不使用模型广场前端叠加分组后的最终展示金额,也不把用户分组倍率带入官方价。 + +### 5.2 官方定价维护流程 + +官方定价继续在现有模型定价设置和模型广场维护,不要求管理员为节省功能另行录入完整价格表: + +1. 管理员通过现有能力维护模型广场本地基础定价。 +2. 节省设置保存 `local_pricing_official_confirmed=true` 的实例级声明;该字段同时表示允许使用本地官方价格,不再增加语义重复的开关。 +3. 个别模型需要纠偏时,仅在 `official_prices` 添加覆盖项;覆盖项优先于本地快照。 +4. 本地定价或覆盖项更新后刷新模型广场缓存;新消费使用新快照,已有 `savings_estimate` 不回写。 +5. 短窗口历史回算结果不落库,每次汇总按查询时的当前本地官方价计算,并在接口中单独统计数量;历史累计任务只对每条旧日志成功回算一次,并将价格、换算口径和结果冻结到累计事件中。 + +如果覆盖项记录了官方来源更新时间且超过 90 天,管理员端提示“官方定价可能已过期”。本地模型广场数据目前没有可靠的官方更新时间,因此只记录 `price_snapshot_at`,不能把一分钟缓存刷新时间展示成“官方定价更新时间”。 + +### 5.2.1 模型广场复用边界 + +模型广场是默认官方定价快照的读取入口,但节省估算只读取基础定价字段: + +- 模型广场展示层可以继续展示站内可用分组、分组价格和动态价格说明。 +- 节省估算从 `model.GetPricing()` 读取 `QuotaType`、`ModelRatio`、`ModelPrice`、`CompletionRatio` 和各类 token 倍率。 +- 本地来源的官方确认是实例级声明,不要求给模型广场每个模型新增 `official_confirmed` 字段。 +- 如果同一个模型同时存在官方基准价和站内分组价,节省估算使用官方基准价,实际扣费使用消费日志最终 `quota`。 +- 如果管理员把模型广场基础价改成促销价、采购价或其他非官方价,必须关闭 `local_pricing_official_confirmed`,或用已确认的 `official_prices` 覆盖后再开启展示。 +- 如果后续在模型广场展示“约省 xx%”,也必须使用同一份官方定价快照,避免用户概览、日志详情和模型广场口径不一致。 + +因此,`official_prices` 只是覆盖层,不是启动节省估算的前置数据录入任务。 + +### 5.2.2 官方来源 URL 校验 + +官方定价来源 URL 用于审计和解释,不能成为安全风险或隐私泄露点。MVP 建议: + +- 只允许 `http` 和 `https` URL。 +- 保存前去除明显敏感的 query 参数,例如 `token`、`key`、`secret`、`signature`。 +- 普通用户侧优先展示来源域名和更新时间,完整 URL 可放在管理员端。 +- 管理员端允许查看完整 URL,但不得包含内部渠道控制台、带签名的临时链接或私有价格单。 +- URL 为空时仍可确认官方定价,但必须记录 `source` 和 `price_snapshot_at`;`source_updated_at` 仅在确实已知官方更新时间时填写。 + +### 5.3 实际价来源 + +实际价以现有消费日志为准: + +- 文本类:`service/text_quota.go` 结算后的 `summary.Quota`,作为 MVP 唯一统计来源。 +- 任务类:现有任务结算路径中的最终 `Quota`,后续阶段接入。 +- 固定按次类:现有 `ModelPriceHelperPerCall` 后的实际扣费,后续阶段接入。 +- 订阅扣费:仍按实际消耗记录,展示层可说明“节省估算按请求实际消耗统计,不区分钱包或订阅来源”。 + +任务类、固定按次类和异步请求必须等失败退款、超时退款、实际用量重算和差额结算路径全部能同步修正节省估算后再纳入统计。MVP 不统计这些路径,避免退款后仍展示过高节省金额。 + +### 5.4 新旧消费记录策略 + +消费日志分为两类处理: + +| 日志类型 | 数据来源 | 价格口径 | 稳定性 | 展示标识 | +| ---- | ---- | ---- | ---- | ---- | +| 新日志 | `other.savings_estimate` | 请求结算时的官方价格快照 | 后续价格变化不影响 | `snapshot` | +| 旧日志 | `model_name`、token、`quota`、`other` | 查询时的当前本地官方价 | 价格变化会改变回算结果 | `historical_rebuild` | + +查询汇总时必须先读日志快照;只有日志没有 `savings_estimate` 时才尝试历史回算,不能覆盖或重复计算已有快照。 + +历史累计阶段不直接把短窗口查询结果相加,而是为每条消费日志生成唯一聚合事件: + +- 已有合法 `savings_estimate` 的日志直接使用日志内 quota 快照,人民币金额按首次聚合时可用的换算参数冻结。 +- 没有快照的旧日志使用累计回算任务启动时的官方价格和换算参数回算一次,成功后冻结,后续价格和汇率变化不再重算。 +- 无法安全回算的日志写入已处理但未覆盖的事件及稳定 `skip_reason`,计入累计请求分母,不计入金额。 +- 同一日志由 `log_id` 唯一约束保证只能产生一个基础聚合事件;任务重试不得重复累计。 + +旧日志可直接复用的字段包括:`created_at`、`model_name`、`prompt_tokens`、`completion_tokens`、`quota` 和 `other`。其中 `quota` 是当时真实最终扣费。分组信息不参与官方价计算,MVP 汇总查询不读取数据库保留字 `group`。 + +历史回算第一阶段只支持满足以下确定性白名单的普通文本 token 计费和阶梯表达式计费: + +- `prompt_tokens + completion_tokens > 0`。 +- 本地官方价是 `QuotaType=0`,且 `BillingMode` 为空、`ratio`、`per_token` 或可确定性执行的 `tiered_expr`。 +- `model.GetPricing()` 只包含当前可用模型;已下架或不再启用的历史模型没有本地匹配时,只能通过 `official_prices` 覆盖,否则跳过。 +- `other` 必须是合法 JSON 对象,并至少包含 `group_ratio`、`cache_tokens`。普通倍率日志还必须包含 `model_ratio`、`completion_ratio`、`model_price`、`cache_ratio`;`model_price` 只接受项目历史上表示倍率计费的 `0` 或 `-1`。 +- `billing_mode=tiered_expr` 的历史日志必须包含有效 `expr_b64`。系统使用日志内冻结表达式、token 明细和原 `group_ratio` 复算实际 quota;表达式包含 request rules、header、param、时间条件或日志未保存的输出图片/音频维度时跳过。 +- 命中 `audio`、`ws`、`web_search`、`file_search`、`audio_input_seperate_price` 或 `image_generation_call` 标记时跳过。 +- 缓存写入从 `cache_creation_tokens`、`cache_creation_tokens_5m`、`cache_creation_tokens_1h` 读取;字段不存在按 `0` 处理,但存在时必须是非负整数。 +- 图片 token 只有在 `image=true`、`image_output` 为非负整数且官方价格存在合法 `ImageRatio` 时参与回算,否则跳过。 +- Claude/OpenAI token 语义只依据 `usage_semantic` 和 `claude` 明确字段判断,不根据模型名猜测。 +- 普通倍率日志使用日志内实际倍率和 token 明细复算当时 `quota`;阶梯日志复用 `pkg/billingexpr` 执行冻结表达式。两条路径都必须复用项目统一舍入与饱和规则,且复算值必须与日志 `quota` 完全一致。该校验可以识别未记录的附加倍率、工具费或旧日志语义差异;不一致时返回 `legacy_actual_quota_mismatch` 并跳过。 +- 任何字段非法、quota 饱和或模型匹配不确定时跳过,不用简化公式放大节省金额。 + +历史回算不写回日志或数据库,避免制造伪历史快照。接口必须返回 `reconstructed_request_count`,前端在该值大于 0 时显示“含按当前官方定价回算的历史消费”。 + +功能上线后的新日志继续写入快照,这是长期主路径。若消费日志被关闭、清理或归档,系统只统计仍可查询的记录,前端不得把缺失日志解释为 0 消费或 0 节省。 + +## 6. 后端设计 + +### 6.1 配置结构 + +新增一个运营配置,建议命名为 `savings_estimate_setting`: + +```json +{ + "enabled": false, + "show_on_dashboard": true, + "show_on_usage_logs": true, + "local_pricing_official_confirmed": true, + "rebuild_legacy_logs": true, + "require_official_confirmation": true, + "official_price_stale_days": 90, + "max_summary_days": 31, + "max_summary_log_rows": 50000, + "updated_at": 1764230400, + "official_prices": {} +} +``` + +配置原则: + +- 默认关闭,只有官方定价快照和展示文案确认后再开启。 +- `enabled=true` 时必须同时满足 `require_official_confirmation=true`;服务端拒绝保存其他组合,管理设置需先关闭整个节省功能才能关闭官方确认要求。 +- 关闭时不计算、不展示;已写入的历史 `savings_estimate` 仍保留在日志中,但前端不展示。 +- `local_pricing_official_confirmed=true` 同时表示允许读取 `model.GetPricing()`,也是管理员对当前实例本地基础定价来源的明确声明;默认开启。关闭后本地价格不得参与估算或“官方定价”文案。 +- `rebuild_legacy_logs=true` 时回算缺少快照的已有消费日志;关闭后只聚合新日志快照。 +- `official_prices` 可以为空;存在同模型覆盖项时覆盖本地快照,并继续受 `require_official_confirmation` 约束。 +- 本地和覆盖项都未匹配时跳过,不猜价格。 +- `max_summary_days` 限制用户侧查询窗口,避免把日志查询做成长期统计。 +- `max_summary_log_rows` 是 MVP 的保护阈值;超过后应返回部分结果标识或提示用户缩小时间范围。 + +不保留 `reference_price_source` 和 `include_unpriced_models`:价格来源已经由固定优先级决定,而未定价模型必须跳过,两个配置项都没有合法的第二种行为。 + +兼容已有配置 JSON:升级时允许输入中继续存在已废弃字段,`common.UnmarshalJsonStr` 按未知字段忽略;下一次保存设置时输出规范化后的新结构,不需要数据库迁移或批量重写 option。 + +### 6.2 官方定价快照 + +新增运行时价格快照结构,字段尽量与 `model.Pricing` 对齐: + +```go +type SavingsOfficialPrice struct { + ModelName string + QuotaType int + ModelRatio float64 + CompletionRatio float64 + ModelPrice float64 + CacheRatio *float64 + CreateCacheRatio *float64 + ImageRatio *float64 + AudioRatio *float64 + AudioCompletionRatio *float64 + BillingMode string + BillingExpr string + Source string + SourceURL string + SourceUpdatedAt int64 + PriceSnapshotAt int64 + PriceFingerprint string + OfficialConfirmed bool + ConfirmedAt int64 + ConfirmedBy string +} +``` + +该结构不需要第一阶段建表。匹配逻辑先读取 `options` 中的可选覆盖项,再把 `model.GetPricing()` 的匹配项转换成同一运行时结构。`PriceSnapshotAt` 是本次读取本地价格的时间;`SourceUpdatedAt` 仅在确实知道官方来源更新时间时填写。`PriceFingerprint` 用于证明实际使用的是哪一组价格字段。`ConfirmedBy` 只用于管理员审计,不向普通用户展示。 + +`PriceFingerprint` 使用固定字段 struct 依次写入 `ModelName`、`QuotaType`、`ModelRatio`、`CompletionRatio`、`ModelPrice`、缓存倍率、图片倍率、音频倍率、`BillingMode` 和 `BillingExpr`,通过 `common.Marshal` 序列化后计算 SHA-256。不得直接对 map、展示金额、来源 URL 或时间戳计算哈希,避免无业务变化时指纹漂移。 + +后续当价格源、审计和版本管理复杂化后,再考虑独立表。 + +### 6.2.1 缓存与失效策略 + +官方定价快照可以复用现有模型广场的一分钟缓存思路,但需要独立失效语义: + +- 管理员确认、取消确认或更新官方定价后,立即刷新官方定价快照缓存。 +- 站内分组倍率、用户特殊倍率、渠道成本、充值倍率变化时,不应刷新历史日志中的节省估算。 +- 快照缓存影响新请求写入的 `savings_estimate`,也影响缺少快照的旧日志回算;已有日志快照始终优先且不变化。 +- 如果缓存刷新失败,应停止写入新的节省估算并记录系统错误,不能使用过期或半更新快照继续生成用户可见金额。 +- 多实例部署下复用现有 option/settings 广播和 `InvalidatePricingCache` 失效机制;没有跨实例通知时,允许一分钟缓存窗口内使用旧本地快照,并通过 `price_snapshot_at` 保持可解释。 + +### 6.3 日志字段 + +不建议第一阶段修改 `logs` 表结构。应复用 `logs.other`,新增普通用户可见字段: + +```json +{ + "savings_estimate": { + "schema_version": 1, + "calculator": "text_token_v1", + "official_quota": 1200, + "actual_quota": 800, + "savings_quota": 400, + "source": "local_pricing_snapshot", + "source_url": "https://provider.example/pricing", + "source_updated_at": 1764230400, + "price_snapshot_at": 1764230500, + "price_fingerprint": "sha256:8fd9...", + "official_confirmed": true, + "matched_model": "gpt-4o", + "pricing_mode": "per_token", + "calculation_mode": "snapshot", + "estimated": true + } +} +``` + +字段说明: + +| 字段 | 说明 | +| ---- | ---- | +| `schema_version` | 日志快照结构版本,MVP 固定为 `1` | +| `calculator` | 估算计算器标识,例如 `text_token_v1` | +| `official_quota` | 按官方定价估算的 quota | +| `actual_quota` | 本次实际扣费 quota | +| `savings_quota` | 非负节省 quota | +| `source` | 官方定价来源 | +| `source_url` | 官方定价来源 URL,可为空;写入前必须确认可公开或完成脱敏 | +| `source_updated_at` | 官方定价源更新时间,可未知且为 `0` | +| `price_snapshot_at` | 本次结算读取本地定价快照的时间,不等同于官方更新时间 | +| `price_fingerprint` | 官方价格关键字段的稳定 SHA-256 指纹,用于复核使用的价格版本 | +| `official_confirmed` | 是否已确认来源于官方定价 | +| `matched_model` | 本次估算匹配到的官方定价模型名 | +| `pricing_mode` | `per_token`、`per_request`、`tiered_expr` 等 | +| `calculation_mode` | 新日志固定为 `snapshot`;历史回算结果不写入日志 | +| `estimated` | 固定为 `true`,向前端明确这是估算数据 | + +不放入 `admin_info`,因为节省金额是用户可见价值信息;但可额外在 `admin_info` 放调试字段,例如匹配到的原始模型名、跳过原因或价格版本。 + +日志解析必须向前兼容: + +- 未知 `schema_version` 默认跳过聚合,不报错。 +- 缺少 `savings_estimate` 时进入历史回算分支,而不是直接丢弃。 +- 已有 `savings_estimate` 但结构或字段非法时跳过,不降级回算,避免损坏快照被静默替换。 +- `Other` 为空、非 JSON 或缺少历史回算基础字段时保守跳过。 +- 已存在的 `schema_version=1` 快照可能没有新增字段:缺少 `calculation_mode` 时按 `snapshot` 处理,缺少 `price_snapshot_at` 或 `price_fingerprint` 时仍允许聚合,只表示该旧快照的价格版本不可完整审计。 +- 新版本可以增加字段,但不能改变 `official_quota`、`actual_quota`、`savings_quota` 的含义。 + +### 6.4 模型名匹配 + +官方定价快照匹配必须使用稳定、可追溯的顺序: + +1. 优先使用 `relayInfo.OriginModelName`。 +2. 若请求发生模型映射,记录并尝试匹配映射前模型名和映射后模型名。 +3. 对 compact、thinking budget 等项目已有模型名规则,复用 `ratio_setting.FormatMatchingModelName` 的归一化结果。 +4. 最后才允许使用已有通配符规则,例如 compact wildcard 或明确配置的模型通配符。 +5. 匹配失败时跳过节省估算,不使用相似名称、供应商名称或前缀猜测。 + +写入日志时必须保存 `matched_model`,用于后续解释历史估算为什么使用某个官方定价。 + +历史回算没有 `relayInfo`,候选模型仅来自 `log.model_name`、`ratio_setting.FormatMatchingModelName(log.model_name)` 和项目已有的明确通配符规则。不得用渠道、供应商或字符串相似度推测模型。 + +### 6.5 计算入口 + +文本类请求在 `service/text_quota.go` 中完成实际扣费后,写日志前注入 `savings_estimate`。 + +推荐流程: + +```text +calculateTextQuotaSummary + -> 得到 actual quota + -> TryTieredSettle 如适用 + -> build other + -> AttachSavingsEstimate(ctx, relayInfo, usage, actualQuota, other) + -> attachQuotaSaturation + -> RecordConsumeLog +``` + +MVP 只在文本类成功消费日志写入前注入。任务类和固定按次类走对应日志写入前的同名注入函数,但必须在第二阶段完成退款/重算闭环后再开启,保持“只影响日志,不影响扣费”。 + +### 6.6 计算规则 + +#### 普通 token 模型 + +按现有实际计费公式的结构计算官方定价估算,但使用官方定价快照字段: + +```text +official_quota = + ( + prompt_tokens + + completion_tokens * official_completion_ratio + + cache_tokens * official_cache_ratio + + cache_create_tokens * official_create_cache_ratio + + image_tokens * official_image_ratio + + audio_tokens * official_audio_ratio + ) + * official_model_ratio +``` + +再通过 `common.QuotaFromDecimalChecked` 转换为 quota,遵守现有溢出保护。 + +注意事项: + +- 必须复用现有 token 归一化语义,避免缓存、图片、音频重复计价。 +- Claude 语义与 OpenAI 语义要与实际扣费路径保持一致。 +- 没有足够 usage 数据时跳过,不估算。 + +#### 固定按次模型 + +```text +official_quota = official_model_price * common.QuotaPerUnit +``` + +如果实际扣费使用了分组倍率、请求倍率或任务倍率,官方定价默认不叠加站内分组优惠,只代表官方公开基准价。任务时长、分辨率、数量等官方也收费的参数,第二阶段只在已有可靠参数和边界校验时纳入。 + +#### 动态表达式模型 + +MVP 支持仅依赖已保存 token 维度的确定性 `tiered_expr`,复用 `pkg/billingexpr` 并以 `groupRatio=1` 计算官方基准: + +```text +official_quota = official_expr_result / 1_000_000 * common.QuotaPerUnit +``` + +新请求可直接使用模型广场官方表达式生成快照。历史请求还必须使用日志 `expr_b64` 复算并验证实际 `quota`。依赖 request body、headers、时间条件、request rules 或日志未保存 token 维度的表达式仍跳过;没有官方定价表达式时也跳过,不用简单 input/output 比率猜测复杂表达式。 + +### 6.7 服务边界 + +独立的 `service/savings_estimate.go` 负责四件事: + +1. 按“人工覆盖 -> 本地模型广场”的优先级读取官方定价快照。 +2. 根据本次 usage 和实际扣费生成 `savings_estimate`。 +3. 解析已有日志快照,或对无快照旧日志执行受限回算。 +4. 聚合用户时间窗口,并区分快照数量与回算数量。 + +它不应该负责: + +- 修改用户余额。 +- 改变实际扣费 quota。 +- 处理退款、补扣或订阅权益。 +- 同步第三方价格源。 +- 渲染前端金额和货币。 + +推荐内部返回结构: + +```go +type SavingsEstimateResult struct { + Estimate *SavingsEstimate + SkipReason string +} +``` + +`Estimate == nil` 表示不写用户可见字段。`SkipReason` 只进入管理员调试或日志,不向普通用户暴露。 + +### 6.8 跳过原因枚举 + +为便于调试和测试,跳过原因建议使用稳定枚举字符串: + +| 原因 | 说明 | +| ---- | ---- | +| `disabled` | 功能关闭 | +| `missing_official_price` | 没有官方定价快照 | +| `unconfirmed_official_price` | 价格源未确认来自官方 | +| `missing_usage` | 缺少 usage,无法估算 | +| `unsupported_billing_mode` | MVP 不支持该计费模式 | +| `unsupported_async_task` | MVP 不支持异步任务路径 | +| `unknown_extra_ratio` | 命中未知附加倍率 | +| `quota_saturated` | 官方定价估算触发 quota 饱和 | +| `invalid_snapshot` | 官方定价快照字段非法 | +| `legacy_log_insufficient_detail` | 旧日志缺少特殊 token 或附加计费明细,不能安全回算 | +| `legacy_log_invalid_snapshot` | 旧日志包含损坏的 `savings_estimate`,禁止降级回算 | +| `legacy_log_missing_base_fields` | 旧日志缺少稳定文本计费基础字段 | +| `legacy_actual_quota_mismatch` | 使用日志内倍率无法复算出原始实际 quota,可能存在未记录附加计费 | + +这些枚举可用于后端测试、管理员日志和运营覆盖率分析,但不应出现在普通用户界面。 + +## 7. 聚合接口设计 + +### 7.1 用户接口 + +新增: + +```http +GET /api/user/savings/summary?start_timestamp=...&end_timestamp=... +``` + +返回: + +```json +{ + "success": true, + "data": { + "enabled": true, + "savings_quota": 123456, + "official_quota": 345678, + "actual_quota": 222222, + "request_count": 128, + "estimated_request_count": 112, + "snapshot_request_count": 72, + "reconstructed_request_count": 40, + "coverage_ratio": 0.875, + "source": "mixed", + "official_confirmed": true, + "source_updated_at": 1764230400, + "rebuild_price_snapshot_at": 1764230500, + "official_price_stale": false, + "is_partial": false, + "window_days": 30 + } +} +``` + +说明: + +- `enabled=false` 表示功能关闭,前端应隐藏节省入口和金额。 +- `request_count` 是范围内总消费请求数。 +- `estimated_request_count` 是成功估算的请求总数,等于快照数量与历史回算数量之和。 +- `snapshot_request_count` 是直接使用日志内 `savings_estimate` 的请求数。 +- `reconstructed_request_count` 是缺少日志快照、按查询时当前本地官方价回算的请求数。 +- `coverage_ratio` 用于提示估算覆盖率。 +- `source` 是汇总分类:只有本地来源时为 `local_pricing_snapshot`,只有覆盖项时为 `official_override`,存在多种来源或旧版 `official_snapshot` 与新来源并存时为 `mixed`。单条日志保留原始 source,不强行改写历史数据。 +- 只查询当前登录用户。 +- MVP 查询窗口建议限制在 31 天以内,只承诺本月和近 24 小时等短窗口。 +- 本短窗口接口不提供 lifetime/cumulative 字段;累计值由独立聚合接口返回,避免日志清理、查询范围和短窗口实时回算影响长期口径。 +- 为兼容现有 API 和前端,保留 `source_updated_at` 字段;汇总语义固定为所有已知官方来源更新时间中的最早值,完全未知时为 `0`。 +- `official_price_stale` 只根据已知的 `source_updated_at` 判断;本地来源更新时间未知时不伪造过期结论。 +- `rebuild_price_snapshot_at` 是本次查询为历史回算构建价格 Map 的时间;没有历史回算时为 `0`。 +- `official_confirmed=true` 表示所有被纳入金额的价格都通过人工覆盖确认或本地实例级官方声明,不能使用“任意一条已确认”的宽松逻辑。 +- `is_partial` 表示日志扫描超过保护阈值,前端应弱化或隐藏金额并提示缩小范围。 +- 时间范围使用 `[start_timestamp, end_timestamp)` 左闭右开区间,避免相邻窗口重复统计。 +- `end_timestamp` 必须大于 `start_timestamp`。为容忍浏览器与服务器时钟偏差,后端接受不超过服务器当前时间 5 分钟的值,并将实际查询结束时间截断为服务器当前时间;超过 5 分钟才返回“结束时间不能晚于当前时间”。窗口天数和日志查询都使用截断后的时间。 +- 时间校验服务应返回规范化后的 `effective_end_timestamp`,controller 必须把它传给汇总查询,不能只完成校验后继续使用原始未来时间。 + +### 7.2 管理员接口 + +可后续增加: + +```http +GET /api/savings/summary?username=...&start_timestamp=...&end_timestamp=... +``` + +用于运营分析,不作为 MVP 必需项。 + +### 7.3 查询实现 + +MVP 为了兼容 SQLite、MySQL、PostgreSQL 和 ClickHouse,不依赖数据库 JSON 查询函数。推荐两阶段: + +1. 时间范围较短的用户侧汇总,按 `user_id`、`type=consume`、时间范围查询日志后在 Go 中优先解析快照,再回算旧日志。 +2. 长期累计使用独立事件表与按日聚合表,不扩展本接口为无边界日志扫描。 + +不建议第一阶段使用 MySQL JSON_EXTRACT 或 PostgreSQL JSONB 操作符,因为会增加跨数据库分支。 + +MVP 查询实现约束: + +- 只查询必要字段:`id`、`created_at`、`model_name`、`prompt_tokens`、`completion_tokens`、`quota`、`other`。`group` 不参与计算且是数据库保留字,MVP 不查询。 +- 查询前先按相同条件 `COUNT`,超过 `max_summary_log_rows` 时返回 `is_partial=true`,并建议前端提示缩小时间范围。 +- 后端不要为部分结果补推测值;部分结果只用于保守展示或隐藏金额。 +- 查询条件必须包含 `user_id` 和时间范围,不能允许无边界扫描。 +- 时间范围参数无效时返回 400,不使用“默认全部时间”。 +- 每次汇总只调用一次 `model.GetPricing()`,构建 `map[string]SavingsOfficialPrice`;人工覆盖同步构建为 Map。单条日志只执行有限模型候选查找,整体复杂度为 O(价格模型数 + 日志数),禁止逐日志线性扫描完整价格列表。 +- 单条日志 quota 仍使用现有 `int`,但 `official_quota`、`actual_quota`、`savings_quota` 的汇总字段使用 `int64` 并做受检累加。按单条 quota 的 int32 安全上限和 50,000 行保护阈值,前端数值仍低于 JavaScript `Number.MAX_SAFE_INTEGER`。 + +### 7.3.1 单条日志聚合流程 + +```text +读取 consume log + -> other 中存在合法 savings_estimate:聚合快照,snapshot_request_count + 1 + -> other 中存在但损坏的 savings_estimate:跳过,不回算 + -> 不存在 savings_estimate 且 rebuild_legacy_logs=false:跳过 + -> 不存在 savings_estimate 且允许回算:校验基础字段和禁止标记 + -> 使用日志内倍率复算实际 quota;不一致则跳过 + -> 匹配 official_prices 覆盖项,否则匹配 model.GetPricing() + -> 仅普通文本 token 计费可安全计算:聚合,reconstructed_request_count + 1 + -> 其他情况:按稳定 skip reason 跳过 +``` + +快照计算和历史回算必须复用同一个纯计算函数,输入为标准化 token 摘要、实际 `quota` 与官方价格。功能开关、模型匹配和日志解析放在调用方,避免构造伪造 `gin.Context` 或 `RelayInfo`。 + +### 7.4 失败行为与错误码 + +用户汇总接口建议采用稳定的失败口径: + +| 场景 | 建议响应 | +| ---- | ---- | +| 功能关闭 | `200`,返回 `enabled=false` 和空汇总,前端隐藏入口 | +| 时间范围缺失 | `400`,提示必须传入开始和结束时间 | +| 时间范围非法 | `400`,提示时间范围无效 | +| 结束时间领先服务器不超过 5 分钟 | `200`,内部截断到服务器当前时间后查询 | +| 结束时间领先服务器超过 5 分钟 | `400`,提示结束时间不能晚于当前时间 | +| 超过 `max_summary_days` | `400`,提示缩小时间范围 | +| 超过 `max_summary_log_rows` | `200`,返回 `is_partial=true`,不返回推测金额 | +| 无可估算日志 | `200`,返回 `savings_quota=0`、`estimated_request_count=0`,前端隐藏金额 | +| 官方定价全部过期 | `200`,返回 `official_price_stale=true`,前端展示更新时间 | +| 本地定价未声明为官方 | `200`,本地回退不可用;仅统计已确认覆盖项,否则返回无可估算日志 | +| 旧日志细节不足 | `200`,跳过对应日志并降低 `coverage_ratio` | + +这里不建议用 `404` 表示功能关闭或无数据,因为这不是资源不存在,而是产品展示条件不足。 + +### 7.5 前端类型建议 + +前端 API 类型应保持和后端响应一致,避免在组件里拼装业务语义: + +```ts +type SavingsSummary = { + enabled: boolean + savings_quota: number + official_quota: number + actual_quota: number + request_count: number + estimated_request_count: number + snapshot_request_count: number + reconstructed_request_count: number + coverage_ratio: number + source: string + official_confirmed: boolean + source_updated_at: number + rebuild_price_snapshot_at: number + official_price_stale: boolean + is_partial: boolean + window_days: number +} +``` + +组件根据 `enabled`、`is_partial`、`savings_quota`、`coverage_ratio` 和 `reconstructed_request_count` 决定展示状态;金额格式化继续复用现有 quota/currency 工具。滚动升级期间若旧后端尚未返回新增字段,前端将 `snapshot_request_count` 回退为 `estimated_request_count`,将 `reconstructed_request_count` 和 `rebuild_price_snapshot_at` 回退为 `0`。 + +## 8. 前端设计 + +### 8.1 用户概览 + +在用户概览余额摘要区域增加一个轻量指标: + +```text +已为你节省约 ¥32.18 +按官方定价估算,近 24 小时覆盖 87.5% 请求 +其中 40 条历史消费按当前官方定价回算 +``` + +主文案必须明确带上“近 24 小时”,避免用户把滚动窗口误解为今日金额或历史累计: + +```text +近 24 小时 RAPI 已帮你节省约 ¥32.18 +``` + +位置建议: + +- 放在 `SummaryCards` 右侧余额区域的次级信息位。 +- 不新增大面积营销卡片。 +- 功能已开启但没有可估算数据时保留轻量状态位,展示“暂无可估算的消费记录”,便于用户确认功能已生效;不展示“已节省 ¥0”。 +- 当 `is_partial=true` 时不展示金额,只展示“数据量较大,请缩小时间范围查看估算节省”。 +- 当 `official_price_stale=true` 时仍可展示金额,但必须同时展示已知的官方定价更新时间。 +- 当 `reconstructed_request_count>0` 时展示历史回算说明,不能把回算结果描述为请求当时已固化的价格。 +- 当 `savings_quota=0` 时隐藏节省金额,避免出现没有价值感的“节省 0”。 +- 功能关闭时隐藏入口;覆盖率为 0 或日志窗口不完整时展示保守状态,不把空数据渲染成“已节省 ¥0”。 +- 概览窗口固定为滚动 24 小时。请求执行时重新计算 `end=当前 Unix 秒`、`start=end-24h`,不能把组件首次挂载时的时间范围永久缓存。 +- 查询 key 使用稳定的“rolling-24h”语义,查询函数内生成当前时间;保持现有 60 秒 stale time,并在窗口重新聚焦或定时刷新时获得新的时间窗口,避免页面长时间打开后数据停留在旧区间。 + +### 8.2 钱包页 + +钱包页可以在累计聚合期间展示已处理的部分金额,但必须明确标记为“累计统计中”或“已统计节省”。只有当后台回算状态为 `completed` 且长期累计值可靠时,才使用最终累计口径: + +```text +累计估算节省:¥1,284.90 +自 2024-03-12 开始统计 · 覆盖率 91% +``` + +用途是增强用户充值前的价值感知,但不能遮挡充值金额、到账金额和支付方式。累计回算未完成时,部分金额必须同时展示未完成状态或进度,不能标成最终累计值;金额仍需明确为估算,不得表述为现金返还或付款差额。 + +### 8.3 用量日志 + +日志详情弹窗展示单次请求: + +```text +官方定价估算:¥0.0120 +实际扣费:¥0.0080 +估算节省:¥0.0040 +来源:官方定价快照,更新时间:2026-07-27 +``` + +列表列不建议默认新增,避免请求日志过宽。可在列设置中作为可选列。 + +新日志直接展示持久化快照。已有旧日志第一阶段只参与概览汇总,不在日志详情中即时拼装一个未持久化对象;如后续要展示单条旧日志回算,后端必须返回显式的 `calculation_mode=historical_rebuild`,前端显示“按当前官方定价回算”。 + +### 8.4 节省设置 + +设置页应直接说明:默认使用模型广场本地官方定价,`official_prices` 仅用于可选覆盖。默认 JSON 必须包含: + +```json +{ + "local_pricing_official_confirmed": true, + "rebuild_legacy_logs": true, + "official_prices": {} +} +``` + +管理员关闭 `local_pricing_official_confirmed` 后,界面不得继续声称本地价格是官方价。 + +### 8.5 模型广场 + +模型广场可以展示“站内价 vs 官方定价”的弱提示: + +```text +约省 24% +``` + +但这应作为后续阶段。MVP 优先完成基于真实请求日志的用户节省金额,因为它更贴近实际价值。 + +### 8.6 国际化 + +新增所有可见文本必须进入 `web/src/i18n/locales/{lang}.json`: + +- en +- zh +- zh-TW +- fr +- ru +- ja +- vi + +组件中使用 `useTranslation()` 和 `t('English key')`。长语言下金额、百分比和说明允许换行,不固定高度。 + +### 8.7 节省趋势图扩展 + +用量分析中的“官方价估算 vs 已覆盖请求实际消费”趋势图采用独立设计,详见 `docs/user-savings-trend-design.md`。该扩展继续复用本设计的价格来源、快照优先、历史回算、覆盖率和受检 quota 汇总口径。 + +## 9. 边界与跳过策略 + +以下情况不计算节省金额: + +- 没有官方定价。 +- 价格源未确认来自官方定价。 +- 上游没有返回 usage,实际扣费为 0。 +- 任务类、固定按次类或异步请求尚未接入退款/重算闭环。 +- 模型使用复杂动态表达式,但缺少官方定价表达式。 +- 请求命中未知附加倍率,无法安全映射到官方价。 +- 官方定价估算触发 quota 饱和。 +- 旧日志缺少可判断特殊 token、工具附加费或计费模式所需的明细。 +- 旧日志已经包含损坏的 `savings_estimate`;此时不得降级到当前价格回算。 + +跳过时不写用户可见 `savings_estimate`。管理员调试可选写入: + +```json +{ + "admin_info": { + "savings_skip_reason": "missing_reference_price" + } +} +``` + +实际扣费大于或等于官方定价估算时,不属于估算失败。后端可以写入 `savings_quota=0` 的 `savings_estimate` 以保留覆盖率和解释性,但前端不展示“节省 0”,也不展示负节省。 + +## 10. 安全与合规 + +- 节省金额仅用于展示,不参与扣费、退款、发票、充值到账或订阅权益。 +- 不向前端暴露内部渠道成本、供应商密钥、渠道余额或管理员采购价。 +- 不向普通用户暴露 `ConfirmedBy`、内部审计备注、导入任务 ID 或管理员账号。 +- 用户只能查看自己的节省汇总。 +- 管理员聚合接口必须沿用现有管理权限中间件。 +- 官方定价确认动作建议写入审计事件 `savings.official_price_confirm`,取消确认写入 `savings.official_price_unconfirm`,批量更新写入 `savings.official_price_update`。 +- `local_pricing_official_confirmed` 的开启和关闭同样属于来源声明变更,应写入管理审计日志。 +- 所有 JSON 编解码继续使用 `common.Marshal`、`common.Unmarshal`、`common.DecodeJson` 等包装函数。 +- 所有 quota 转换使用 `common.QuotaFromDecimalChecked` 等现有安全函数,禁止裸 `int(...)` 转换。 +- 跨数据库实现避免数据库专属 JSON 查询语法,除非为每个受支持数据库提供分支与回退。 +- 普通用户可见的 `source_url` 必须经过脱敏或只展示域名,避免把带 token 的导入地址写进日志后长期暴露。 + +### 10.1 可观测性 + +MVP 只保留以下三个轻量指标或结构化日志,避免为展示功能先建设完整指标体系: + +- `savings_estimate_attached_total`:成功写入估算的请求数。 +- `savings_estimate_skipped_total{reason}`:新日志和历史回算统一按跳过原因统计。 +- `savings_legacy_rebuild_total`:成功回算的旧日志数。 + +这些指标只用于内部观测,不向普通用户展示。灰度期重点关注覆盖率、跳过原因分布和用户侧误解反馈,不以总节省金额作为唯一成功指标。 + +## 11. 实施阶段 + +### 11.1 阶段一:本地价格与新旧日志 MVP + +后端: + +1. 新增 savings estimate 配置。 +2. 构建“人工覆盖优先、本地模型广场回退”的官方定价读取函数。 +3. 抽取新日志快照与旧日志回算共用的纯 quota 计算函数。 +4. 在文本类消费日志写入前注入 `other.savings_estimate`。 +5. 扩展汇总日志查询字段,并对缺少快照的已有普通文本消费执行受限回算。 +6. 为价格快照增加稳定 `price_fingerprint`,并在汇总请求内预构建价格 Map。 +7. 扩展用户节省汇总接口,返回快照数量、回算数量、覆盖率和历史回算价格快照时间。 +8. 增加后端单元测试,覆盖来源优先级、普通倍率与阶梯表达式历史回算、实际 quota 复算校验、价格指纹、动态请求表达式跳过、负节省和受检汇总。 + +前端: + +1. 新增 savings summary API 与类型。 +2. 在用户概览展示滚动近 24 小时估算节省、覆盖率与历史回算说明。 +3. 在日志详情展示单次估算节省。 +4. 设置页默认提供可视化开关与统计限制表单,并保留 JSON 高级模式;明确本地模型广场为默认来源、覆盖 JSON 为可选,模式切换时保留未知字段和高级价格字段。 +5. 补齐七语言。 + +### 11.2 阶段二:任务类与固定按次 + +- 完成失败退款、超时退款、实际用量重算和差额结算对节省估算的修正策略。 +- 接入 Midjourney、Sora、Veo、图片生成等固定按次或任务类路径。 +- 对时长、分辨率、数量等附加倍率建立官方价映射。 +- 在任务日志详情中展示估算节省。 + +### 11.3 阶段三:运营分析与模型广场对比 + +- 管理员按用户、分组、模型查看总节省。 +- 模型广场展示官方定价与站内价差异。 +- 可选增加价格源更新时间、来源说明和覆盖率报表。 + +### 11.4 阶段四:历史累计聚合 + +在不改变短窗口接口的前提下,增加累计聚合持久化: + +- 新增日志级幂等事件表、用户按 UTC 日聚合表和用户总计表,不复用 `quota_data` 的现有短窗口语义。 +- 尽可能回算已有普通文本消费;无法安全回算的日志保留跳过原因并计入覆盖率分母。 +- 新消费在日志落库后异步写入聚合事件,失败不影响计费请求,由补偿任务按日志游标修复。 +- 价格、`quota_per_unit` 和人民币汇率按事件冻结;累计人民币金额使用整数微元存储。 +- 基于聚合数据开放累计节省接口,并在概览或钱包页展示累计金额、统计起始时间、覆盖率和回算状态。 +- 日志清理后保留累计事件和日聚合;用户删除时按现有账号数据删除策略同步清理。 +- 保持 SQLite、MySQL、PostgreSQL 迁移兼容。 + +### 11.5 灰度与回滚 + +上线建议按以下顺序: + +1. 发布后端配置、官方定价快照和日志注入能力,但保持 `enabled=false`。 +2. 确认当前实例模型广场基础定价为官方价,并仅为例外模型配置覆盖项。 +3. 在测试环境或内部账号开启,检查日志详情中的单次估算。 +4. 开启用户汇总接口,但前端入口保持隐藏。 +5. 对小范围用户展示用户概览指标,观察跳过率、覆盖率和反馈。 +6. 全量展示用户概览,日志详情保持可解释。 + +回滚策略: + +- 关闭 `enabled` 后,停止写入新的 `savings_estimate`,前端隐藏汇总入口。 +- 已写入日志无需清理;它们只是历史展示快照,不影响账务。 +- 如果发现本地定价来源声明错误,应关闭 `local_pricing_official_confirmed` 并刷新缓存;已写入快照不自动回写,历史回算会在下次查询时停止或改用覆盖项。 +- 如果前端文案引发误解,可只关闭 `show_on_dashboard`,保留日志详情供管理员和用户解释。 + +## 12. 测试要求 + +### 12.1 后端测试 + +建议测试点: + +- token 模型按官方定价计算 `official_quota` 和 `savings_quota`。 +- 未配置 `official_prices` 时可从 `model.GetPricing()` 匹配本地官方价。 +- 同一模型同时存在覆盖项和本地价时,已确认覆盖项优先。 +- `local_pricing_official_confirmed=false` 时不使用本地价,但仍可使用已确认覆盖项。 +- 阶段二接入后,固定按次模型计算正确。 +- 无官方定价时不写 `savings_estimate`。 +- 价格源未确认官方来源时不写 `savings_estimate`。 +- 实际扣费高于官方定价估算时 `savings_quota=0`,前端不展示节省金额。 +- 动态表达式缺少官方定价表达式时跳过。 +- 任务类和固定按次类在 MVP 中跳过。 +- 模型映射、归一化和通配符匹配会写入正确的 `matched_model`。 +- 写入 `schema_version=1`、`calculator=text_token_v1` 和稳定 `price_fingerprint`;价格字段不变时指纹不变,任一计费字段变化时指纹变化。 +- 已有 schema v1 快照缺少 `calculation_mode`、`price_snapshot_at` 或 `price_fingerprint` 时仍能聚合。 +- `Other` 中已有字段不被覆盖。 +- 无 `savings_estimate` 的旧日志可根据模型、基础 token 和实际 `quota` 回算。 +- 旧日志基础倍率字段完整,且缓存、缓存写入、图片等明细有效时正确回算。 +- `Other` 为空、非 JSON 或缺少任一稳定基础字段时返回 `legacy_log_missing_base_fields` 并跳过。 +- 使用日志内倍率复算结果与实际 `quota` 不一致时返回 `legacy_actual_quota_mismatch` 并跳过。 +- 旧日志命中音频、WSS、搜索、独立音频价或图片生成调用费标记时跳过。 +- 已有损坏快照或未知 `schema_version` 时跳过且不降级回算。 +- 非管理员日志格式化不剥离 `savings_estimate`,但继续剥离 `admin_info`。 +- 汇总接口只返回当前用户的数据。 +- 汇总接口使用 `[start_timestamp, end_timestamp)`,相邻窗口不重复统计。 +- 结束时间领先服务器不超过 5 分钟时截断到当前时间;超过 5 分钟时返回 400,避免浏览器时钟偏差再次触发误报。 +- 查询窗口超过 `max_summary_days` 时返回 400。 +- 超过 `max_summary_log_rows` 时返回 `is_partial=true`,且不补推测金额。 +- 功能关闭时返回空汇总并由前端隐藏入口。 +- 时间范围缺失、非法或超过上限时返回 400。 +- 官方来源 URL 只接受 `http`/`https`,敏感 query 参数被剔除或拒绝。 +- 官方定价确认、取消确认、批量更新会写入审计动作。 +- 官方定价缓存刷新失败时不写入新的用户可见节省估算。 +- 官方定价超过过期阈值时返回 `official_price_stale=true`。 +- 本地快照只记录 `price_snapshot_at`,不会把缓存刷新时间伪装成 `source_updated_at`。 +- 汇总返回的 `estimated_request_count` 等于 `snapshot_request_count + reconstructed_request_count`。 +- 汇总 quota 超过 int32 时仍以正确的 `int64` 值返回,不发生回绕或负数。 +- 混合来源汇总继续返回兼容字段 `source_updated_at`,其值为最早的已知官方来源更新时间;无历史回算时 `rebuild_price_snapshot_at=0`。 +- 当前本地价格变化不影响已有快照,但会影响下一次旧日志回算结果。 +- 跳过原因枚举稳定,可被测试断言和管理员调试使用。 +- 大数、NaN、Inf 和饱和路径不产生负节省。 + +新增或大幅重写 Go 测试使用 `require` 做前置和致命断言,使用 `assert` 做值断言。 + +### 12.2 前端测试 + +建议测试点: + +- 有节省数据时概览展示金额和估算说明。 +- 功能已开启但无节省数据时展示“暂无可估算的消费记录”,不展示“节省 0”。 +- 覆盖率低于 100% 时展示覆盖率说明。 +- `reconstructed_request_count>0` 时展示“按当前官方定价回算”的历史数据说明。 +- 旧后端缺少新增计数字段时,前端使用兼容默认值且不报错。 +- 页面持续打开或重新聚焦时,滚动 24 小时查询会使用新的当前时间,不复用首次挂载时的固定结束时间。 +- `is_partial=true` 时不展示金额并提示缩小时间范围。 +- `official_price_stale=true` 时展示官方定价更新时间。 +- `savings_quota=0` 时隐藏节省金额。 +- 功能关闭、覆盖率为 0 或 `is_partial=true` 时不把金额展示成“节省 0”。 +- 日志详情正确展示官方定价估算、实际扣费、节省和来源。 +- 长翻译不导致按钮或卡片文本溢出。 +- 货币展示跟随现有 quota/currency 配置。 + +## 13. 验收标准 + +- 关闭配置后,用户界面完全不出现节省金额。 +- 开启配置后,文本类请求能在日志详情看到单次估算节省。 +- 用户概览能展示滚动近 24 小时估算节省与覆盖率,页面持续打开时窗口仍会向前滚动。 +- `official_prices` 为空时,只要本地价格官方声明有效,模型广场已有模型即可参与估算。 +- 功能上线前已有的普通文本消费记录可进入汇总,并单独返回回算数量。 +- 历史日志只有在基础字段完整且日志内倍率或冻结阶梯表达式能精确复算实际 `quota` 时才纳入,无法证明安全时跳过。 +- MVP 不展示累计节省,不在钱包页展示节省金额。 +- 累计扩展完成后,累计接口不扫描原始日志,能返回冻结人民币总额、累计覆盖率、统计起始时间和回算状态。 +- 历史回算任务重复执行、崩溃恢复或批次重试不会重复累计同一 `log_id`。 +- 官方价格、`quota_per_unit` 或人民币汇率变化不修改已有累计事件和累计人民币金额。 +- 无法安全回算的旧日志不计入累计金额,但计入请求分母并降低累计覆盖率。 +- 官方定价变更后,已有 `savings_estimate` 的日志金额保持不变;无快照旧日志按当前价格重新回算并明确标识。 +- 无官方定价或不可安全估算的请求不会产生误导展示。 +- 未确认官方来源的价格不会参与节省估算。 +- 官方定价确认动作可审计,包含操作者、来源和模型范围。 +- 官方来源 URL 不向普通用户泄露敏感 query 或内部地址。 +- 官方定价缓存更新后影响新日志和后续旧日志回算,但不回写或改变已有日志快照。 +- 新日志记录稳定价格指纹;汇总在 50,000 行保护范围内使用 `int64` 安全累加,并且不会逐日志扫描完整模型价格列表。 +- 关闭 `enabled` 后停止写入新的节省估算,前端隐藏入口,历史日志不影响账务。 +- 超过日志扫描阈值时前端不展示不完整金额。 +- 实际扣费、用户余额、订阅扣费、充值到账和退款逻辑无变化。 +- SQLite、MySQL、PostgreSQL 下后端测试通过。 +- 前端 i18n、类型检查、相关测试和构建通过。 + +## 14. 设计原则应用 + +- KISS:短窗口继续复用 `model.GetPricing()`、日志基础列和 `logs.other`;累计阶段使用事件、按日汇总和用户总计三个职责明确的数据层。 +- YAGNI:累计阶段仍只覆盖当前可确定性估算的文本类,不借机接入全部任务模型或运营排行榜。 +- DRY:新日志快照与旧日志回算共用同一 quota 计算函数,官方价格运行时结构与模型广场字段对齐。 +- SOLID:节省估算和累计聚合作为独立服务处理,聚合失败不得侵入实际计费和扣费路径。 + +## 15. 推荐结论 + +建议继续按“估算节省”定位。短窗口默认读取模型广场本地官方价,`official_prices` 仅作为可选覆盖;累计阶段尽可能回算历史日志并冻结结果,通过幂等聚合事件提供稳定累计金额。该方案同时保留近 24 小时实时价值感知和长期累计价值感知,并通过统计起始时间、覆盖率与回算状态保持可解释性。 + +## 16. 遗漏与过当检查 + +当前方案建议保留的必要项: + +- 本地模型广场官方来源声明、可选覆盖项和价格快照时间,解决“钱从哪里来”的可信度问题。 +- 新日志写入时固化快照,解决上线后价格漂移问题。 +- 旧日志受限回算并单独计数,解决已有消费无法展示的问题。 +- 用户侧短窗口聚合,解决 MVP 性能和日志保留不确定性问题。 +- 未确认、无 usage、依赖请求上下文的动态表达式、异步退款链路先跳过,避免误导用户。 +- 前端按估算展示并在不完整数据时隐藏金额,降低财务误解风险。 + +当前方案刻意不做的项: + +- 不做自动官网抓价。官方价格页面格式不稳定,维护成本高,MVP 没必要。 +- 不通过实时全表扫描做累计节省;累计值必须来自幂等持久化聚合。 +- 钱包页累计展示只在回算完成后开放,并明确为估算,避免被理解成现金返还或付款差额。 +- 不在运行时抓取官网或第三方接口;价格只从当前实例本地快照读取。 +- 不把负节省展示给用户。该功能目标是价值感知,不是价格争议提示。 + +总体判断:短窗口核心闭环已经覆盖本地官方价、新日志快照、已有消费回算、确定性阶梯表达式、来源声明、查询边界和 UI 解释。短窗口历史回算结果随当前价格变化仍是有意接受的限制;累计阶段则通过一次性回算与冻结消除长期金额漂移。累计扩展应保持独立阶段,不与任务类、动态表达式或运营排行榜同时施工。 + +## 17. 历史累计节省扩展设计 + +### 17.1 已确认产品口径 + +本扩展采用以下已确认决策: + +1. 尽可能回算当前日志库中已有的普通文本消费,不只从功能上线日开始统计。 +2. 无法确定性回算的日志跳过金额计算,但计入累计请求分母并展示覆盖率。 +3. 历史日志只回算一次;成功后的官方价、换算参数和人民币结果永久冻结。 +4. 新请求使用请求结算时的官方价格快照;累计人民币金额使用请求聚合时冻结的换算参数。 +5. 第一版只统计当前已支持的文本类消费日志,任务、图片、视频、音频和异步结算继续按既有跳过策略处理。 +6. 后台历史任务必须分批、可暂停、可恢复、可重试,不能阻塞服务启动或用户请求。 +7. 聚合过程必须幂等,同一消费日志无论重试多少次都只能贡献一次基础累计值。 +8. 后续退款、差额结算或人工修正使用调整事件,不能静默覆盖已经展示过的基础事件。 + +累计值是估算统计,不参与余额、扣费、退款、充值到账、发票或财务对账。 + +### 17.2 展示语义 + +概览区分两个不同指标: + +```text +近 24 小时节省约 ¥203.08 +累计已节省约 ¥4,821.36 +自 2024-03-12 开始统计 · 覆盖率 91% +``` + +- “近 24 小时”继续使用现有滚动窗口,可以随请求进入和移出而增减。 +- “累计已节省”来自冻结聚合,只允许因新事件或明确调整事件变化,不因滚动时间、当前官方价或当前汇率变化而重算。 +- `backfill_status != completed` 时不得显示最终口径的“累计已节省”,应显示“累计统计中”或“已统计节省”,并展示进度。 +- 累计金额为 `0` 且已有已处理请求时,不使用价值营销文案,只展示保守状态。 +- 累计覆盖率必须与金额同时可见,避免把部分模型统计描述成全部消费。 +- 统计起始时间取最早已处理消费日志的发生时间,不取任务创建时间。 + +### 17.3 冻结换算口径 + +短窗口继续返回 quota 并按当前站点配置展示。累计人民币金额必须在事件生成时冻结: + +```text +savings_cny_micros = + savings_quota / quota_per_unit_snapshot + * usd_cny_rate_snapshot + * 1_000_000 +``` + +约束: + +- 使用 decimal 或整数安全运算,不使用 `float64 -> int64` 裸转换。 +- `quota_per_unit_snapshot` 必须大于 `0`。 +- `usd_cny_rate_micros` 使用 `1 USD = x CNY` 的六位小数整数快照。 +- `savings_cny_micros` 使用人民币微元,`1 CNY = 1_000_000 micros`。 +- 新日志如果已有换算快照则直接使用;旧快照缺少换算字段时,使用首次累计聚合时的站点换算参数并冻结。 +- 历史无快照日志统一使用该次回算任务固定的换算参数,任务运行过程中管理员修改汇率不能造成同一批历史数据使用不同口径。 +- 累计接口返回微元字符串和格式化所需的货币代码,避免 JavaScript `Number` 对超大 `int64` 失真。 + +为保证实时聚合失败后仍能按请求发生时的口径补偿,新文本消费需要在现有 `other.savings_estimate` 中增加以下可选字段,并继续保持 `schema_version=1` 向后兼容: + +```json +{ + "quota_per_unit_snapshot": 500000, + "usd_cny_rate_micros": 7300000, + "savings_cny_micros": "153848610" +} +``` + +- 新代码写入这三个字段;旧后端和旧日志缺少字段时仍按原 schema v1 解析。 +- 新日志的累计事件必须优先复制日志中的冻结微元值,不能在补偿执行时使用新的汇率重算。 +- 历史任务处理旧快照和无快照日志时,统一使用任务级冻结参数补齐这三个字段对应的事件值,但不强制回写原日志 JSON。 +- 微元转换使用 decimal 半远离零取整;示例对应 `10,537,576 quota / 500,000 * 7.3 = 153.8486096 CNY`,冻结为 `153,848,610 micros`。 + +### 17.4 数据模型 + +累计事件、日聚合、用户总计和任务状态统一存放在主数据库,原始消费日志继续从 `LOG_DB` 读取。原因是日志库可能使用 ClickHouse,而 ClickHouse 不适合承担本功能所需的唯一约束、行级更新和事务式聚合。主库仍只需支持项目既有的 SQLite、MySQL 和 PostgreSQL。 + +跨库读取不追求“日志写入与累计写入”原子提交:累计链路依靠稳定来源键、唯一事件和补偿扫描实现最终一致性。聚合失败不得影响消费日志和实际扣费。 + +#### 17.4.1 日志聚合事件 `user_savings_events` + +每条消费日志至少对应一个基础事件,成功估算和跳过事件都持久化: + +| 字段 | 类型建议 | 说明 | +| ---- | ---- | ---- | +| `id` | GORM 主键 | 由 GORM 生成 | +| `event_key` | string unique | 基础事件使用 `log:{source_key}:base`;调整事件包含稳定修订号 | +| `source_key` | string index | 消费日志稳定来源键 | +| `log_id` | int64 index | 关系型日志 ID;ClickHouse 或旧日志无法提供时为 `0` | +| `user_id` | int index | 当前用户 | +| `occurred_at` | int64 index | 原消费发生时间 | +| `day_start_utc` | int64 index | UTC 自然日起点 | +| `event_type` | string | `base`、`refund_adjustment`、`settlement_adjustment`、`admin_adjustment` | +| `coverage_state` | string | `estimated` 或 `skipped` | +| `skip_reason` | string | 未覆盖原因;成功时为空 | +| `calculation_mode` | string | `snapshot` 或 `historical_rebuild_frozen` | +| `official_quota` | int64 | 官方价估算 quota | +| `actual_quota` | int64 | 实际消费 quota | +| `savings_quota` | int64 | 非负节省;调整事件允许有符号增量 | +| `savings_cny_micros` | int64 | 冻结人民币微元;调整事件允许有符号增量 | +| `quota_per_unit_snapshot` | int64 | 换算单位快照 | +| `usd_cny_rate_micros` | int64 | 汇率六位小数快照 | +| `price_snapshot_at` | int64 | 官方价格快照时间 | +| `price_fingerprint` | string | 官方价格指纹 | +| `aggregate_version` | int | 聚合算法版本,第一版为 `1` | +| `aggregated_at` | int64 index | `0` 表示尚未进入日聚合和用户总计 | +| `created_at` | int64 | 事件写入时间 | + +基础事件的 `event_key` 唯一约束是幂等边界。不得只依赖内存游标或“查询后再插入”,因为并发任务和崩溃重试会产生重复累计。 + +`event_key` 和 `source_key` 建议限制为 ASCII `varchar(128)`,避免 MySQL 5.7 在 `utf8mb4` 长索引上的兼容问题。`log_id`、`user_id`、`occurred_at`、`day_start_utc` 和 `aggregated_at` 使用普通 B-Tree 索引,不使用数据库专属 JSON、部分索引或表达式索引。 + +来源键规则: + +- 新日志在写入前生成随机且全局唯一的 `savings_aggregation_key`,保存在 `other.savings_estimate`,关系型数据库和 ClickHouse 使用相同规则。 +- 已有 SQLite、MySQL、PostgreSQL 日志使用 `db:{id}`。 +- 已有 ClickHouse 日志的 `id` 默认为 `0`,不能用作游标或幂等键;使用稳定字段规范化后的 SHA-256 作为 `legacy-ch:{hash}` 回退键。 +- 如果已有 ClickHouse 中存在所有稳定字段完全相同的重复行,系统无法无损区分其身份。任务必须计入 `ambiguous_source_key` 跳过数并向管理员披露,不能冒险重复累计。 + +#### 17.4.2 用户按日聚合 `user_savings_daily` + +| 字段 | 类型建议 | 说明 | +| ---- | ---- | ---- | +| `id` | GORM 主键 | 由 GORM 生成 | +| `user_id` | int | 与 `day_start_utc` 组成唯一键 | +| `day_start_utc` | int64 | UTC 自然日起点 | +| `request_count` | int64 | 基础消费事件数,不含调整事件 | +| `estimated_request_count` | int64 | 成功估算事件数 | +| `snapshot_request_count` | int64 | 使用原日志快照的事件数 | +| `reconstructed_request_count` | int64 | 历史冻结回算事件数 | +| `official_quota` | int64 | 受检累加 | +| `actual_quota` | int64 | 受检累加 | +| `savings_quota` | int64 | 基础事件与调整事件之和 | +| `savings_cny_micros` | int64 | 冻结人民币微元之和 | +| `first_occurred_at` | int64 | 当日最早消费时间 | +| `last_occurred_at` | int64 | 当日最晚消费时间 | +| `updated_at` | int64 | 最近聚合时间 | + +事件生成与汇总更新拆成两个可恢复阶段:事件生产者只批量插入唯一事件;聚合器再事务式领取 `aggregated_at=0` 的事件,在内存中按用户和日期合并增量,批量更新日聚合及用户总计,最后标记事件完成。这样无需依赖各数据库对 `INSERT ... RETURNING` 的不同支持,也避免事件唯一键并发冲突导致日汇总重复增加。 + +#### 17.4.3 用户总计 `user_savings_totals` + +每个用户最多一行,用于概览和钱包页 `O(1)` 读取: + +| 字段 | 类型建议 | 说明 | +| ---- | ---- | ---- | +| `user_id` | int primary key | 用户唯一总计 | +| `request_count` | int64 | 基础消费事件总数 | +| `estimated_request_count` | int64 | 成功估算事件总数 | +| `snapshot_request_count` | int64 | 日志快照事件总数 | +| `reconstructed_request_count` | int64 | 历史冻结回算事件总数 | +| `official_quota` | int64 | 受检累计官方 quota | +| `actual_quota` | int64 | 受检累计实际 quota | +| `savings_quota` | int64 | 受检累计节省 quota | +| `savings_cny_micros` | int64 | 冻结人民币微元总计 | +| `statistics_started_at` | int64 | 最早基础事件发生时间 | +| `last_aggregated_at` | int64 | 最近聚合提交时间 | + +`user_savings_daily` 用于按日趋势与重建,`user_savings_totals` 用于用户累计卡片。累计接口不得每次把用户全部日记录重新求和;维护总计表可把读取复杂度从 `O(统计天数)` 降为 `O(1)`。 + +#### 17.4.4 回算任务 `user_savings_backfill_jobs` + +任务至少记录: + +- `status`:`pending`、`running`、`paused`、`completed`、`failed`。 +- `cursor_log_id`、`target_max_log_id`:关系型日志库游标和固定上界。 +- `cursor_created_at`、`cursor_request_id`、`target_created_at`、`target_request_id`:ClickHouse 复合游标和固定上界。 +- `target_count`、`processed_count`、`estimated_count`、`skipped_count`,其中 `target_count` 在任务启动时按固定边界计算,用于稳定展示进度。 +- `price_snapshot_at`、`quota_per_unit_snapshot`、`usd_cny_rate_micros`。 +- `pricing_snapshot_json`、`pricing_snapshot_hash`:持久化本次任务使用的规范化官方价格 Map 及其 SHA-256,保证进程重启后继续使用同一价格口径。 +- `started_at`、`updated_at`、`completed_at`、`last_error`。 + +第一版允许全实例只有一个活动历史任务,避免多个任务使用不同价格口径并行回算同一日志范围。 + +`pricing_snapshot_json` 使用跨数据库兼容的 `TEXT`,只保存计算必需的规范化计费字段、确认状态、来源更新时间和价格指纹,不保存管理员账号、内部备注或敏感来源 URL。序列化和恢复必须使用 `common.Marshal`、`common.Unmarshal` 等项目 JSON 包装函数。恢复任务时先校验 hash;损坏或无法解析时把任务标记为 `failed`,不能改用当前价格继续。 + +### 17.5 新消费聚合流程 + +新消费日志成功落库后触发累计聚合: + +```text +消费日志落库 + -> 读取合法 savings_estimate + -> 构造 base 事件与冻结人民币金额 + -> 批量插入唯一事件,aggregated_at=0 + -> 聚合器批量更新 UTC 日聚合和用户总计 + -> 同一事务标记事件 aggregated_at + -> 失败只记录告警,不回滚用户请求或实际扣费 + -> 补偿任务按稳定游标查找未生成 base 事件的 consume 日志并重试 +``` + +累计聚合属于展示分析链路,不得因为聚合表故障导致 API 请求失败、重复扣费或余额回滚。 + +实现时应在消费日志成功后提交聚合工作。进程内工作队列必须有容量上限且不得阻塞请求;队列已满、进程退出或事件写入失败时依赖补偿扫描恢复。补偿扫描按稳定游标读取缺少基础事件的消费日志,并使用日志内冻结换算字段;不能把内存队列当作唯一可靠来源。 + +### 17.6 历史回算流程 + +管理员启动历史任务时冻结任务边界和口径: + +1. 冻结任务扫描上界。关系型日志库记录 `target_max_log_id`;ClickHouse 记录 `(target_created_at, target_request_id)` 复合上界。新日志由实时聚合或补偿任务处理。 +2. 冻结并持久化本次官方价格 Map、价格快照 hash、`price_snapshot_at`、`quota_per_unit_snapshot` 和人民币汇率。 +3. 使用 Keyset 分页从游标开始顺序读取,初始批次为 1,000 条;禁止使用 `OFFSET`。 +4. 每条日志先尝试解析已有快照;无快照时执行现有确定性历史回算。 +5. 成功和跳过结果都写入基础事件,避免失败日志在每次重试时重复消耗计算资源。 +6. 每批事务提交后更新游标和计数;进程退出后从已提交游标继续。 +7. 关系型日志到达 `target_max_log_id`、ClickHouse 到达复合目标上界,并且目标范围不存在遗漏事件后标记 `completed`。 + +任务暂停只停止领取新批次,不中断正在提交的数据库事务。任务失败保留游标、错误和冻结口径,恢复时继续使用原口径,不能悄悄换成新的官方价格或汇率。 + +#### 17.6.1 高性能回算算法 + +历史扫描使用“单次顺序读取、批内并行计算、单写入器批量提交”: + +```text +冻结任务边界与价格 Map + -> Keyset 读取一批必要日志列 + -> 过滤已有 source_key + -> 有界 CPU worker 解析与计算 + -> 批量插入待聚合事件 + -> 事务聚合 pending events + -> 批内按 (user_id, day_start_utc) 合并 + -> 批内按 user_id 合并 + -> 批量 Upsert daily + -> 批量 Upsert totals + -> 批量标记 aggregated_at + -> 提交游标和计数 + -> 下一批 +``` + +关系型日志库分页: + +```sql +WHERE type = consume + AND id > :cursor_id + AND id <= :target_max_id +ORDER BY id ASC +LIMIT :batch_size +``` + +ClickHouse 的历史 `id` 不可用,采用与表排序键一致的复合 Keyset: + +```sql +WHERE type = consume + AND (created_at, request_id) > (:cursor_created_at, :cursor_request_id) + AND (created_at, request_id) <= (:target_created_at, :target_request_id) +ORDER BY created_at ASC, request_id ASC +LIMIT :batch_size +``` + +若 ClickHouse 同一 `(created_at, request_id)` 存在多行,批次读取必须把该键的全部行作为一个边界组处理,不能在组中间推进游标。新日志引入 `savings_aggregation_key` 后不再依赖该兼容路径。 + +计算优化: + +- 任务启动时把规范化官方价格构建为 `map[string]OfficialPrice`,模型匹配平均为 `O(1)`;禁止每条日志遍历完整价格列表。 +- 查询只读取 `id`、`request_id`、`user_id`、`created_at`、`model_name`、token、`quota` 和 `other` 等必要列。 +- 每条 `other` 最多解析一次;解析结果同时用于快照识别、历史回算和来源键构建。 +- CPU worker 建议为 `min(max(runtime.NumCPU()/2, 1), 4)`,输入输出队列均有上限;数据库保持单批次写入器,避免并发事务争抢索引和行锁。 +- 批内使用两个 Map 分别按 `(user_id, day_start_utc)` 和 `user_id` 合并增量,将日表和总计表写操作从“每日志一次”降低为“每批每键一次”。 +- 事件使用批量 `CreateInBatches`;预先批量查询本批已有 `source_key`,减少唯一键冲突日志,但最终正确性仍由唯一约束保证。 +- 聚合器只处理 `aggregated_at=0` 的事件。第一版全实例只运行一个聚合器,使用现有系统任务租约防止多节点重复执行;事务读取使用项目 `lockForUpdate(tx)`,SQLite 自动跳过不支持的锁语法。 + +批次自适应: + +- 默认 `1,000`,允许范围 `500..5,000`。 +- 最近连续三批提交耗时低于目标值且无数据库等待时逐步放大;提交变慢、锁等待或内存压力升高时减半。 +- 每批事务应控制在数秒内,不为了吞吐量开启覆盖整个历史任务的长事务。 +- 每批提交后持久化游标;不得只在任务结束时保存进度。 +- 管理员设置的批大小是初始值和上限约束,不允许配置无限批次。 + +复杂度:设历史日志数为 `N`、官方模型数为 `M`、批大小为 `B`、批内不同用户/日期键数为 `K`: + +- 时间复杂度:`O(M + N)`。 +- 工作内存:`O(M + B + K)`。 +- 原始日志读取:一次顺序扫描,不随页数退化。 +- 用户累计查询:读取 `user_savings_totals` 单行,`O(1)`。 +- 按日趋势查询:读取目标时间范围的日记录,`O(天数)`,与原始日志总量无关。 + +明确禁止: + +- 用户打开页面时回算全部历史日志。 +- 使用深分页 `LIMIT/OFFSET`。 +- 每条日志单独查询价格、检查事件或更新聚合表。 +- 多个无界 worker 同时写数据库。 +- 为累计任务反复更新原始日志 `other`。 +- 每次查询按当前官方价格或汇率重算历史累计金额。 + +### 17.7 调整与修正 + +- 当前文本消费基础事件视为不可变。 +- 后续接入会退款或二次结算的任务类请求时,为同一 `log_id` 写入唯一调整事件。 +- 调整事件存储 quota 和人民币微元的有符号差额,并更新原事件所属 UTC 日聚合。 +- 重复退款通知或结算回调必须使用稳定业务修订号构造唯一 `event_key`。 +- 官方定价后来发生变化不属于调整原因,不修改已有累计事件。 +- 如果管理员确认历史官方价格配置错误,第一版采用显式“新建修正任务”生成调整事件,不允许直接覆盖数据库总额。 +- 同一日志的基础事件加全部调整事件后,净 `official_quota`、`actual_quota` 和 `savings_quota` 必须满足业务约束,用户可见净节省不得小于 `0`;违反约束的调整必须拒绝并告警。 + +### 17.8 累计接口 + +新增: + +```http +GET /api/user/savings/lifetime +``` + +响应建议: + +```json +{ + "success": true, + "data": { + "enabled": true, + "currency": "CNY", + "savings_cny_micros": "4821360000", + "savings_quota": "330230136", + "official_quota": "721003221", + "actual_quota": "390773085", + "request_count": 18240, + "estimated_request_count": 16598, + "snapshot_request_count": 4120, + "reconstructed_request_count": 12478, + "coverage_ratio": 0.90998, + "statistics_started_at": 1709856000, + "last_aggregated_at": 1785254400, + "backfill_status": "completed", + "backfill_progress": 1, + "is_complete": true + } +} +``` + +规则: + +- 只能查询当前登录用户。 +- 只读取当前用户的 `user_savings_totals` 单行,不扫描日聚合、原始日志或解析 `other`。 +- `savings_cny_micros`、`savings_quota`、`official_quota` 和 `actual_quota` 均使用十进制字符串返回,前端使用安全 decimal/BigInt 处理;累计接口不能沿用短窗口受 50,000 行保护后的 JavaScript `number` 假设。 +- `coverage_ratio = estimated_request_count / request_count`,分母为 `0` 时返回 `0`。 +- `is_complete` 只有在历史任务完成且实时补偿没有已知积压时为 `true`。 +- 回算过程中可以返回已处理金额,但前端必须使用“已统计”语义并展示进度。 +- 日聚合累加使用受检 `int64`;溢出时接口失败并记录管理员可见告警,不能返回负数或截断金额。 + +### 17.9 管理设置与状态 + +累计扩展新增独立配置,避免复用 `rebuild_legacy_logs` 的查询时回算语义: + +```json +{ + "lifetime_enabled": false, + "lifetime_backfill_batch_size": 1000, + "lifetime_show_on_dashboard": true, + "lifetime_show_on_wallet": false +} +``` + +- `lifetime_enabled` 控制实时累计和累计接口展示,不影响短窗口估算。 +- 历史任务的启动、暂停、恢复属于显式管理员动作,不因为打开开关就在服务启动时自动扫描全库。 +- 设置页以可视化开关、批次输入和任务状态展示为主,JSON 继续作为高级兼容入口。 +- 管理员界面显示处理进度、成功数、跳过数、最近错误、冻结价格时间和冻结汇率。 + +管理员任务接口已按现有 `system-task` 路由规范落地: + +```http +GET /api/system-task/savings-lifetime-backfill +POST /api/system-task/savings-lifetime-backfill +POST /api/system-task/savings-lifetime-backfill/pause +POST /api/system-task/savings-lifetime-backfill/resume +POST /api/system-task/savings-lifetime-backfill/retry +``` + +- 复用现有管理员鉴权与操作审计中间件。 +- `start` 仅在没有活动任务时成功,并在事务内按日志数据库类型冻结关系型 ID 上界或 ClickHouse 复合上界,同时冻结 `target_count`、规范化官方价格 JSON、价格 hash 和换算参数。 +- `pause`、`resume` 使用任务 ID 或版本做条件更新,防止并发管理员操作覆盖新状态。 +- 每个状态变更记录操作者、旧状态、新状态、任务边界和冻结口径;接口不得返回内部数据库连接或敏感价格来源参数。 +- 第一版不提供“取消并删除结果”;需要重新计算时必须走显式修正任务,避免破坏已展示累计值。 + +实际实现中,运行中任务先进入 `pause_requested`,当前批次事件、聚合结果和游标提交后再转为 `paused`;恢复后回到 `pending` 并继续使用任务载荷中冻结的价格 Map、价格 hash、汇率和 `quota_per_unit`。失败任务可在故障排除后通过 `retry` 从已保存游标继续,原任务的冻结口径和已提交聚合结果保持不变。实时事件异步写入失败时,由主节点每分钟执行一次近 7 天有界补偿扫描,每轮最多 5 个批次;补偿使用 Keyset 游标和事件唯一键,不阻塞用户请求,也不会重复累计。ClickHouse 新日志依赖写入时生成的唯一 `request_id`;对旧日志中跨批次且 `(created_at, request_id)` 完全相同、无法稳定区分的剩余行,任务显式增加 `ambiguous_cursor_count` 和跳过数,管理员界面显示歧义数量,禁止静默视为已估算。 + +### 17.10 缓存与一致性 + +- 累计接口可以按用户 ID 和累计 schema 版本缓存 1 分钟,不应为了构造缓存 key 先读取总计行的 `last_aggregated_at`。 +- 日聚合与用户总计事务提交后尽力失效对应用户累计缓存;失效失败只造成最长一个 TTL 的展示延迟。 +- 回算任务完成前 `is_complete=false` 是权威状态,不能依赖缓存中的金额猜测完成度。 +- 日聚合和用户总计都应支持从事件表重建,用于检测或修复三层数据不一致;重建属于管理员维护操作。 + +### 17.11 保留与删除策略 + +- 原始消费日志按现有策略清理后,累计事件、日聚合和用户总计默认保留,以保证累计值不因日志保留周期下降。 +- 删除用户时必须清理该用户的累计事件、日聚合、用户总计和任务状态,不能留下可关联的历史统计。 +- 如果产品支持用户主动清除消费历史,需要明确该操作是否同时清除累计节省;默认建议同时清除并二次确认。 +- 管理员不得通过删除单个原始日志静默改变累计值;需要显式调整事件或重建操作。 + +### 17.12 测试与验收补充 + +后端必须覆盖: + +- 同一 `source_key` 并发、重复和崩溃重试只产生一个基础事件。 +- 事件可先成功落库但保持 `aggregated_at=0`;日聚合或用户总计更新失败时聚合事务整体回滚,重试后不会重复累计。 +- 历史任务暂停、恢复、进程重启后继续使用原价格和汇率快照。 +- 历史任务价格快照损坏或 hash 不匹配时失败,不会回退到当前价格继续计算。 +- 已有合法日志快照、可回算旧日志和不可回算旧日志分别产生正确事件。 +- 跳过事件计入 `request_count`,不增加金额和 `estimated_request_count`。 +- 官方价格、`quota_per_unit` 和汇率修改后,已有累计金额不变。 +- 调整事件可增加或减少累计金额,但重复业务修订不会重复调整。 +- 日聚合、用户总计与事件表重建结果一致。 +- 累计查询只读取用户总计单行,不访问日表或原始日志表。 +- Keyset 扫描不会漏读或重复读取批次边界;禁止回退为 OFFSET 分页。 +- 有界 worker、批量事件写入和批内聚合在大批量固定输入下保持确定结果。 +- ClickHouse 复合游标边界组和模糊来源键按设计处理,不静默重复累计。 +- SQLite、MySQL、PostgreSQL 下迁移、唯一约束、事务和聚合语义一致。 +- 大数与溢出路径不会产生负金额或静默截断。 + +前端必须覆盖: + +- 近 24 小时和累计指标的时间口径文案不会混淆。 +- 回算未完成时显示进度和“已统计”语义,不显示最终累计文案。 +- 累计完成后显示冻结人民币金额、统计起始时间和覆盖率。 +- 累计金额和累计 quota 字符串使用 BigInt/安全 decimal 格式化,不经过不安全的隐式 `Number` 转换。 +- 功能关闭、无数据、覆盖率为 0、任务失败和缓存延迟均有明确状态。 +- 桌面与移动端长金额、长日期和七语言文本不溢出。 + +完成标准: + +1. 历史普通文本日志被尽可能处理,所有目标范围日志都有成功或跳过基础事件。 +2. 重复执行历史任务不会改变累计结果。 +3. 累计金额在官方价格和汇率调整后保持不变。 +4. 新请求最终能通过实时路径或补偿路径进入累计,且不影响计费成功率。 +5. 用户能够明确区分近 24 小时节省、累计节省、覆盖率和统计起始时间。 diff --git a/docs/user-savings-lifetime-review-remediation.md b/docs/user-savings-lifetime-review-remediation.md new file mode 100644 index 000000000000..7524c1b65c77 --- /dev/null +++ b/docs/user-savings-lifetime-review-remediation.md @@ -0,0 +1,418 @@ +# 用户累计节省自动审查整改方案 + +> 状态:已实施,待维护者复核 +> 关联 PR:`QuantumNous/new-api#6499` +> 评审提交:`a2a65db09c92d5a287cd0f0075a709d7f1c7d517` +> 评审范围:`aa82302c..a2a65db` +> 基准日期:2026-07-29 +> 前置文档:`docs/user-savings-estimate-design.md`、`docs/user-savings-review-remediation.md` + +## 1. 背景 + +本轮改造在短窗口节省估算基础上增加冻结口径的长期累计能力,包括: + +- 幂等累计事件、按日汇总和用户总计。 +- 新消费实时累计与失败补偿。 +- 可暂停、恢复、重试的历史回算系统任务。 +- 关系型数据库和 ClickHouse 的稳定游标分页。 +- 管理端回算控制、系统任务状态、概览和钱包累计节省展示。 + +CodeRabbit 对该增量给出 8 条行内意见、2 条 diff 范围外意见和 7 条低优先级建议。审查状态为成功仅表示自动审查已完成,不表示不存在待整改项。本文逐条判断意见有效性,并给出保持 KISS、跨数据库兼容和计费口径稳定的实施方案。 + +## 2. 改造目标 + +- 消除 SQLite 完整性检查阻塞管理员 HTTP 请求的风险。 +- 保证冻结人民币金额可用时不再执行无关的备用换算。 +- 避免每次用户查询都统计全局待聚合事件数量。 +- 明确大型 `logs` 表新增复合索引的上线方式。 +- 保证暂停任务不显示为正在自动刷新。 +- 保证累计金额和百分比跟随当前界面语言格式化。 +- 在回算状态变化后刷新依赖该状态的前端查询。 +- 修正中、英、俄文案和钱包部分结果展示口径。 +- 对接受、暂缓和不采纳的自动审查意见给出可追溯结论。 + +## 3. 不变约束 + +- 不改变短窗口和长期累计的节省计算公式。 +- 不重新计算已有日志中合法的冻结人民币金额。 +- 不允许任何换算产生负数、溢出值或静默截断值。 +- 不把长期累计值用于扣费、退款、充值到账或发票。 +- SQLite、MySQL 5.7.8+、PostgreSQL 9.6+ 必须同时可用。 +- ClickHouse 继续使用 `(created_at, request_id)` 复合游标,不修改现有表排序键。 +- 所有 JSON 编解码继续使用 `common` 包装函数。 +- 不通过批量添加低价值注释满足外部工具的覆盖率指标。 + +## 4. 审查结论 + +### 4.1 行内和 diff 范围外意见 + +| ID | 优先级 | 评审内容 | 结论 | 整改动作 | +| --- | --- | --- | --- | --- | +| L1 | P0 | `PRAGMA quick_check` 在请求路径扫描数据库 | 有效,但机器人建议不完整 | 将检查移入后台任务;`quick_check(1)` 只能限制返回错误数量,不能保证健康数据库不完整扫描 | +| L2 | P1 | 暂停任务仍显示“自动刷新” | 有效 | 分离活动任务和实际轮询任务状态 | +| L3 | P2 | 新增 TypeScript 函数缺少显式返回类型 | 有效 | 为 5 个 API helper 和 2 个状态 helper 补齐返回类型 | +| L4 | P1 | 钱包累计金额没有使用当前界面语言 | 有效,且存在同类遗漏 | 钱包和概览的金额、覆盖率、进度统一传入 `i18n.resolvedLanguage` | +| L5 | P2 | 钱包部分回算展示规则前后不一致 | 有效 | 明确部分金额只能使用“已统计”语义,完成后才能使用最终累计语义 | +| L6 | P2 | 中文“成功估算”增加了原文没有的成功语义 | 有效 | 改为“已估算:{{count}}” | +| L7 | P2 | 两条俄语累计设置文案语义不准确 | 有效 | 分别修正“聚合到冻结总额”和“保存设置”的语义 | +| L8 | P2 | 俄语把 overrides 翻译成 exceptions | 有效 | 使用 `переопределений` | +| L9 | P2 | 英文历史请求数量在 1 时使用复数 | 有效 | 改为数量后置的中性句式,避免为单个新增 key 引入不完整复数体系 | + +### 4.2 折叠建议 + +| ID | 优先级 | 建议 | 处理决定 | +| --- | --- | --- | --- | +| N1 | P2 | ClickHouse 游标测试实际运行在 SQLite | 接受,增加测试边界说明,不声称覆盖 ClickHouse SQL 兼容性 | +| N2 | P1 | 用户累计汇总每次执行全局 pending count | 接受,改为有条件的存在性查询 | +| N3 | P1 | 冻结金额可用时仍先执行备用换算 | 接受,调整为冻结值优先 | +| N4 | P2 | 回算控制组件需要拆分 | 暂缓,当前先修行为问题;后续独立提取 hook 和状态展示组件 | +| N5 | P1 | mutation 成功后没有失效相关查询 | 接受,统一 query key 并失效累计查询 | +| N6 | P1 | 新复合索引可能在大型日志表上阻塞迁移 | 接受,补充显式上线流程和低峰窗口要求 | +| N7 | P2 | 概览组件需要拆分节省逻辑 | 暂缓,避免本轮正确性修复混入大范围展示重构 | + +### 4.3 不作为阻塞项 + +CodeRabbit 报告的 docstring 覆盖率为 1.88%,外部阈值为 80%。当前项目规范不要求为所有内部函数添加 docstring,批量补充只会增加噪声和维护成本,因此不作为本轮验收条件。 + +组件拆分建议具有长期维护价值,但“超过约 200 行时应考虑拆分”不是必须在同一提交完成的硬性约束。本轮只在拆分能够直接降低行为修复复杂度时执行,否则登记后续任务。 + +## 5. 后端整改 + +### 5.1 SQLite 完整性检查移出请求路径 + +当前 `StartSavingsLifetimeBackfill` 和 `RetrySavingsLifetimeBackfill` 在返回 HTTP 响应前调用 `CheckSavingsLifetimeSQLiteIntegrity`。`PRAGMA quick_check` 需要读取数据库内容,大型 SQLite 主库或日志库会让管理员请求长时间无响应。 + +整改后的链路: + +```text +管理员启动或重试 + -> 校验设置和任务状态 + -> 冻结任务边界、价格和汇率 + -> 快速写入 pending 系统任务 + -> 返回 HTTP 响应 + +后台任务开始执行 + -> SQLite 完整性检查 + -> 失败:任务转为 failed,并保留可见错误 + -> 成功:进入日志分页和事件写入 +``` + +实现要求: + +1. 删除启动和重试 service 中的同步完整性检查。 +2. 在 `savingsLifetimeBackfillHandler.Run` 真正读取批次前执行检查。 +3. 检查错误沿用系统任务失败状态和错误字段,不吞掉数据库错误。 +4. 暂停和恢复即使重新执行检查,也只能影响后台任务,不得再次阻塞 HTTP 请求。 +5. 可使用 `PRAGMA quick_check(1)` 限制最多返回一个错误,但不得把它描述为查询成本上限。 +6. 任务上下文取消时应终止后续日志扫描;数据库驱动支持时通过 `WithContext(ctx)` 传播取消。 + +不新增独立预检任务或复杂状态机。边界查询自身失败时,启动接口仍可立即返回数据库错误;只有完整扫描从请求路径迁出。 + +### 5.2 冻结金额优先 + +构建长期事件时按以下顺序选择人民币微元金额: + +```text +存在 SavingsCNYMicros +且金额可解析为非负 int64 + -> 直接使用冻结金额 + -> 保留冻结换算参数作为快照字段,不要求其为正数 + +否则 + -> 校验任务冻结的 quota_per_unit 和 usd_cny_rate_micros + -> 使用任务冻结的 quota_per_unit 和 usd_cny_rate_micros 换算 + -> 换算失败或溢出时终止当前任务批次 +``` + +冻结值存在时不得先调用 `savingsLifetimeAmountMicros`。这既避免冗余 decimal 运算,也保证合法历史快照不会因为无关的备用参数异常而失败。 + +新增回归测试: + +- 合法冻结金额优先于任务换算参数。 +- 合法冻结金额存在时,构造会使备用换算溢出的参数仍应成功。 +- 冻结金额非法时使用任务快照回退。 +- 冻结金额和回退参数都非法时返回错误。 +- 负数冻结金额不得进入累计表。 + +### 5.3 pending 查询改为存在性检查 + +当前累计汇总只需要判断是否存在未聚合事件,却调用 `COUNT(*)` 获取完整数量。整改为: + +```go +func HasPendingSavingsLifetimeEvents() (bool, error) +``` + +查询语义: + +```sql +SELECT id +FROM savings_lifetime_events +WHERE aggregated_at = 0 +ORDER BY id +LIMIT 1 +``` + +调用规则: + +- 回算状态不是 `completed` 时,直接令 `is_complete=false`,不查询 pending 事件。 +- 只有回算状态为 `completed` 时执行存在性查询。 +- `is_complete = completed && !has_pending`。 + +`idx_savings_events_pending` 调整为 `(aggregated_at, id)`,同时服务后台批量聚合和存在性查询。该表由本功能新建,不涉及已有大型 `logs` 表的在线索引风险。 + +不为一个布尔查询增加内存缓存。索引存在性查询更直接,也不会引入跨实例失效问题。 + +### 5.4 `logs` 复合索引上线 + +保留以下关系型索引设计: + +```text +idx_logs_user_type_created_id(user_id, type, created_at, id) +``` + +它匹配短窗口查询和长期关系型日志游标,但不能只依赖大型生产库启动时的 `AutoMigrate`。 + +上线要求: + +- 新安装和小型数据库可继续由 `AutoMigrate` 创建。 +- 大型现有数据库必须在部署应用前预创建同名索引。 +- MySQL 5.7 优先在验证支持后使用在线 DDL,并安排低峰窗口;不支持无锁创建时必须评估写阻塞。 +- PostgreSQL 使用并发索引创建时不得放在事务中执行。 +- SQLite 创建索引会阻塞写入,应先备份并安排维护窗口。 +- 预创建完成后再启动新版本,GORM 识别同名索引后不应重复创建。 +- ClickHouse 不创建该索引,也不修改 `ORDER BY`。 + +发布前在接近生产规模的数据副本上记录创建时长、额外磁盘空间和写入影响。本文只规定上线边界,不把数据库方言专用 DDL写入通用迁移代码。 + +## 6. 前端整改 + +### 6.1 轮询状态与活动状态分离 + +保留两个明确语义: + +```ts +function isActiveStatus(status: SystemTaskStatus): boolean +function isPollingStatus(status: SystemTaskStatus): boolean +``` + +- `activeTasks` 继续包含 `paused`,确保暂停任务仍显示在活动区域并提供恢复按钮。 +- `hasPollingTasks` 只包含 `pending`、`running`、`pause_requested`。 +- 自动刷新指示灯和“每 N 秒刷新”文案使用 `hasPollingTasks`。 +- 实际 `refetchInterval` 继续使用 `isPollingStatus`。 + +这样不会把暂停任务错误描述为正在轮询,也不会把暂停任务移动到历史列表。 + +### 6.2 显式 TypeScript 返回类型 + +以下函数补齐返回类型: + +```text +startSavingsLifetimeBackfill +getSavingsLifetimeBackfill +pauseSavingsLifetimeBackfill +resumeSavingsLifetimeBackfill +retrySavingsLifetimeBackfill +isActiveStatus +isPollingStatus +``` + +API helper 使用现有 `StartSavingsLifetimeBackfillResponse`、`SystemTaskResponse` 精确标注 `Promise` 返回值,不新增重复 DTO。 + +### 6.3 当前界面语言格式化 + +钱包和概览统一取得: + +```ts +const { t, i18n } = useTranslation() +const locale = i18n.resolvedLanguage ?? i18n.language +``` + +以下格式化必须传入 `locale`: + +- `formatSavingsCNYMicros`。 +- `formatSavingsQuotaAsCNY`。 +- 累计覆盖率百分比。 +- 历史回算进度百分比。 +- 短窗口覆盖率百分比。 + +不能只修钱包调用点;`SummaryCards` 中存在相同问题,应一并修复。数值和币种不变,仅改变分组符、小数符号和货币符号布局。 + +### 6.4 Query Key 与失效范围 + +同一累计接口不应在 dashboard 和 wallet 使用互不相关的 query key。两处复用同一个会话级 key: + +```ts +const savingsQueryKeys = { + lifetime: ['savings', 'lifetime'] as const, +} +``` + +要求: + +- dashboard 和 wallet 在当前认证会话内复用累计 query key。 +- 登录、退出和会话失效继续通过现有认证生命周期调用 `queryClient.clear()`,保证新用户不会复用上一会话的累计结果。 +- 启动、暂停、恢复和重试成功后,先保留当前任务的 `setQueryData`,再使 `['savings', 'lifetime']` 前缀失效。 +- 失效调用失败不影响 mutation 成功提示,但不得产生未处理 Promise rejection。 +- 保留 60 秒 `staleTime` 作为正常读取策略,mutation 后不等待自然过期。 + +### 6.5 组件拆分决定 + +本轮不为满足行数建议强制拆分整个 `SummaryCards` 和 `SavingsLifetimeBackfill`。如果实现共享 query key 和 mutation 失效时出现明显重复,可提取一个稳定的 `useSavingsLifetimeBackfill` hook;展示组件拆分留给独立重构。 + +该决定遵循 YAGNI:先修复已确认的行为问题,避免在同一评审提交中扩大 JSX 重排和视觉回归范围。 + +## 7. 文档与国际化 + +### 7.1 钱包部分结果口径 + +设计文档统一为: + +- 回算未完成时可以展示已经聚合的金额,但必须使用“已统计节省”或“累计统计中”语义。 +- 部分结果必须同时显示进度或未完成状态。 +- 只有 `is_complete=true` 时可以展示“累计为你节省”一类最终口径。 +- 任意状态下都必须保留“估算”限定,不得表述为现金返还。 + +### 7.2 目标文案 + +英文数量文案改为不依赖单复数的形式: + +```text +Historical requests recalculated at current official prices: {{count}} +``` + +简体中文: + +```text +Estimated: {{count}} + -> 已估算:{{count}} +``` + +俄语: + +```text +Aggregate new usage into a frozen lifetime savings total. + -> Учитывать новое использование в зафиксированной общей сумме экономии за всё время. + +Enable and save lifetime savings before starting a backfill. + -> Включите и сохраните настройку накопленной экономии перед запуском пересчёта. + +Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides. + -> По умолчанию используются локальные официальные цены из каталога моделей; official_prices нужен только для переопределений. +``` + +修改英文源 key 时同步更新 7 个 locale 和调用点,保留 `{{count}}` 等插值占位符。执行 `bun run i18n:sync` 后不得留下 missing、extra 或 untranslated 项。 + +### 7.3 测试边界说明 + +`TestGetSavingsLifetimeLogBatchUsesClickHouseCompositeKeyset` 使用 SQLite 连接并强制设置 ClickHouse 数据库类型。测试只能保护复合 keyset 条件、边界和排序结果,不能证明 ClickHouse 方言接受生成 SQL,也不能替代真实 ClickHouse 集成验证。测试前增加这一简短说明,不新增伪造的 ClickHouse 单元测试。 + +## 8. 实施顺序 + +### 阶段 A:请求和累计正确性 + +1. 将 SQLite 完整性检查移入后台任务。 +2. 调整冻结金额优先级并增加溢出回归测试。 +3. 将 pending count 改为条件存在性查询。 +4. 补充 `logs` 索引上线说明。 + +### 阶段 B:前端状态一致性 + +1. 分离轮询状态和活动状态。 +2. 补齐 API 与状态 helper 返回类型。 +3. 统一金额和百分比的界面语言。 +4. 统一累计 query key,并在 mutation 成功后失效。 + +### 阶段 C:文档、翻译和收尾 + +1. 统一钱包部分结果口径。 +2. 修正英文、中文和俄语目标文案。 +3. 增加 ClickHouse 模拟测试边界说明。 +4. 记录组件拆分暂缓原因。 +5. 逐条回复并解决对应评审线程。 + +## 9. 验证方案 + +### 9.1 后端自动化测试 + +```text +go test ./model -run SavingsLifetime -count=1 +go test ./model -run SavingsLog -count=1 +go test ./service -run SavingsLifetime -count=1 +go test ./controller -run SavingsLifetime -count=1 +``` + +必须覆盖: + +- 启动和重试接口不再同步执行完整性扫描。 +- 后台完整性检查失败时任务进入失败状态并保留错误。 +- 冻结金额优先和备用换算错误路径。 +- 非完成任务不查询 pending 事件。 +- 完成任务通过存在性查询决定 `is_complete`。 +- SQLite 迁移创建 `(aggregated_at, id)` 索引。 +- 关系型和 ClickHouse 游标边界保持不变。 + +### 9.2 前端自动化测试 + +```text +bun test src/features/dashboard/lib/__tests__/savings-lifetime.test.ts +bun test src/features/dashboard/lib/__tests__/savings-i18n.test.ts +bun run typecheck +bun run i18n:sync +bun run build +``` + +受影响文件运行 `oxlint` 和 `oxfmt --check`,并增加以下断言: + +- 暂停任务不会显示自动刷新状态。 +- 活动任务仍包含暂停任务。 +- 指定 `zh-CN`、`en` 等界面语言时金额格式稳定。 +- dashboard 和 wallet 使用相同用户累计 query key。 +- 四个 mutation 成功后触发累计查询失效。 +- 新英文 key 在数量为 1 和大于 1 时都符合语法。 +- 7 种语言保留所有插值占位符。 + +### 9.3 数据库和上线验证 + +- SQLite:使用大体量副本确认完整性检查只在后台运行,管理员请求快速返回。 +- MySQL 5.7:在副本验证复合索引创建时间、锁等待和额外磁盘占用。 +- PostgreSQL 9.6:验证并发预创建索引后应用启动不重复建索引。 +- ClickHouse:在真实实例验证复合 keyset SQL、排序和歧义游标计数。 +- 所有数据库验证都使用固定边界数据,不以运行耗时作为单元测试断言。 + +## 10. 验收标准 + +- 启动、重试累计回算的 HTTP 请求不执行 SQLite 全库完整性扫描。 +- 完整性检查失败通过系统任务状态对管理员可见。 +- 合法冻结人民币金额不会触发备用换算。 +- 用户累计查询不再执行全局 pending `COUNT(*)`。 +- 大型 `logs` 表索引具备明确的预创建和维护窗口说明。 +- 暂停任务不显示“自动刷新”,但仍保留在活动任务区域。 +- 累计金额、覆盖率和进度使用当前界面语言。 +- dashboard 与 wallet 不会跨用户复用累计缓存。 +- 回算 mutation 后相关累计查询立即失效。 +- 目标中、英、俄文案通过 i18n 回归测试。 +- 接受项均有测试或可验证证据,暂缓项有明确理由。 +- Go 定向测试、前端测试、类型检查、lint、format、i18n 同步和构建全部通过。 +- `git diff --check` 通过,不包含临时脚本或生成产物。 + +## 11. 风险与回滚 + +### 11.1 后台完整性检查耗时 + +迁出请求路径后,完整性检查仍可能长时间占用 SQLite 读 IO。管理员界面应显示任务处于 pending/running,不重复启动任务。必要时可先关闭长期累计功能,不影响短窗口节省估算。 + +### 11.2 索引迁移 + +大型 `logs` 表索引创建失败时不得自动删除已有索引或重置数据库。保持旧版本运行,完成索引预创建后再部署应用。新累计表索引可随功能表迁移一次创建。 + +### 11.3 前端缓存 + +统一 query key 后必须包含用户 ID。若出现缓存串用户风险,优先关闭共享 key 并恢复独立查询;不得通过延长 stale time 掩盖隔离问题。 + +### 11.4 功能回滚顺序 + +1. 关闭钱包累计展示。 +2. 关闭概览累计展示。 +3. 暂停历史回算任务。 +4. 关闭长期累计开关,保留短窗口估算。 +5. 保留已写入事件和汇总数据,不执行破坏性清理。 diff --git a/docs/user-savings-review-remediation.md b/docs/user-savings-review-remediation.md new file mode 100644 index 000000000000..503388d29ef6 --- /dev/null +++ b/docs/user-savings-review-remediation.md @@ -0,0 +1,438 @@ +# 用户节省金额评审整改与性能加固方案 + +> 状态:已实施,待维护者复核 +> 关联 PR:`QuantumNous/new-api#6499` +> 前置设计:`docs/user-savings-estimate-design.md`、`docs/user-savings-trend-design.md` +> 改造范围:CodeRabbit 首轮 9 条行内评论、10 条折叠建议、二次评审意见及本地复核发现 +> 基准日期:2026-07-28 + +## 1. 背景 + +节省金额功能已经完成计费快照、历史日志受限回算、汇总/趋势 API、概览卡片、趋势图、日志详情和管理设置。PR 评审确认核心计费口径没有负数扣费、快照污染或跨用户数据泄露问题,但暴露了三类上线前风险: + +1. 汇总和趋势接口会在请求线程中读取并解析最多 50000 条日志,当前路由没有搜索限流,也没有服务端结果缓存。 +2. 前端存在货币配置来源不一致和装饰图标语义重复问题。 +3. 俄语、越南语和繁体中文的部分文案虽然可读,但没有准确表达“估算”“覆盖”“已更新”和“本地定价”的业务含义。 + +此外,配置规范化、关系库索引和测试组织还存在可维护性问题。本文给出可直接实施和验收的整改方案,不改变节省金额的计费口径。 + +## 2. 改造目标 + +- 阻止单个用户通过重复请求放大日志扫描和 JSON/decimal 计算成本。 +- 保证管理员无法把单次汇总扫描上限配置到不受控规模。 +- 对相同查询复用短期结果,同时允许新日志在可接受时间内自然可见。 +- 优化 SQLite、MySQL 和 PostgreSQL 的用户时间范围日志查询。 +- 保持 ClickHouse 现有分区和排序结构,不在本 PR 触发表重建。 +- 统一概览卡片和趋势图的货币换算来源。 +- 修复首轮 9 条未解决行内评论、二次评审意见及具有真实行为风险的折叠建议。 +- 用确定性测试保护 API、配置、缓存隔离、国际化和可访问性契约。 + +## 3. 非目标 + +- 不改写节省金额计算公式和逐请求非负钳制语义。 +- 不把 JSON 日志快照改造成数据库专有 JSON 查询。 +- 不新增汇总表、定时任务或外部分析服务。 +- 不为 ClickHouse 修改 `ORDER BY`;此类变更需要独立迁移和历史数据回填方案。 +- 不在本次整改中支持新的计费模式、任务类型或媒体计费。 +- 不修改实际扣费、预扣费、结算、退款和消费日志写入流程。 +- 不引入新的生产依赖;缓存和请求合并复用现有 `pkg/cachex` 与 `golang.org/x/sync/singleflight`。 + +## 4. 评审结论与优先级 + +### 4.1 未解决行内评论 + +| ID | 优先级 | 评审内容 | 结论 | 整改动作 | +| --- | --- | --- | --- | --- | +| R1 | P0 | 汇总/趋势扫描成本高且路由无限流 | 有效 | 增加搜索限流、硬上限、短 TTL 结果缓存和同键请求合并 | +| R2 | P1 | 已命名控件内的装饰图标未隐藏 | 有效 | 两处图标增加 `aria-hidden='true'` | +| R3 | P1 | 概览与趋势使用不同货币配置源 | 有效 | 两处统一读取 `system-config-store.currency` | +| R4 | P1 | 俄语丢失“估算”限定 | 有效 | 使用 `оценочную экономию` | +| R5 | P1 | 俄语 covered 与 coverage 不一致 | 有效 | 使用 `охваченных` / `Охваченные` | +| R6 | P1 | 俄语一次性重算使用未完成体 | 有效 | 使用 `Пересчитать` | +| R7 | P1 | 越南语更新时间像操作命令 | 有效 | 改为明确的完成状态 | +| R8 | P1 | 繁体中文“使用者”与现有术语不一致 | 有效 | 统一为“用戶” | +| R9 | P1 | 繁体中文“本機定价”含义错误 | 有效 | 统一为“本地定价” | + +### 4.2 折叠建议 + +| ID | 优先级 | 建议 | 处理决定 | +| --- | --- | --- | --- | +| N1 | P2 | 测试 helper 参数遮蔽 `model` 包 | 接受,改名为 `modelName` | +| N2 | P2 | 循环内重复读取日志可见性设置 | 接受,循环外读取一次,保证整页一致 | +| N3 | P2 | `localPrices == nil` 分支可读性差 | 接受,显式拆分两个查价分支 | +| N4 | P2 | request-rules guard 拒绝条件缺少说明 | 接受,说明请求规则依赖日志中不存在的请求上下文 | +| N5 | P0 | 用户时间范围查询缺少复合索引 | 接受,仅对关系型日志库新增索引 | +| N6 | P1 | 400 响应重复且硬编码中文 | 接受,统一响应函数并接入后端 i18n | +| N7 | P1 | 去空格后配置键可能随机覆盖 | 接受,改为新 map 并拒绝规范化冲突 | +| N8 | P2 | 独立测试属性全部使用 `require` | 接受,setup 用 `require`,值断言用 `assert` | +| N9 | P1 | 设置解析/格式化缺少测试 | 接受,提取纯逻辑并新增表格测试 | +| N10 | P2 | 重复定义时间粒度联合类型 | 接受,复用 `TimeGranularity` | + +### 4.3 二次评审 + +| ID | 优先级 | 评审内容 | 整改动作 | +| --- | --- | --- | --- | +| S1 | P1 | Markdown 表格中的竖线破坏列数 | 使用不含竖线的 request-rules guard 描述 | +| S2 | P1 | 小于 1 的小数取整后产生零值 | 取整前校验下限并补充 `0.5` 回归测试 | +| S3 | P1 | JSON 模式绕过设置规范化 | 保存前统一调用 `parseSavingsSetting` | +| S4 | P2 | 保存失败产生未处理的 Promise rejection | 本地捕获异常,用户提示继续由 mutation hook 统一处理 | +| S5 | P2 | 设置按钮中的装饰图标未隐藏 | 为 `Code2` 和 `Save` 增加 `aria-hidden='true'` | + +## 5. 后端性能加固 + +### 5.1 防护链路 + +整改后的请求链路如下: + +```text +UserAuth + -> SearchRateLimit + -> 时间窗口和时区校验 + -> 扫描上限硬约束 + -> 结果缓存查询 + -> singleflight 同键合并 + -> 关系库索引查询 / ClickHouse 时间分区查询 + -> 最多 50000 行确定性回算 + -> 结果缓存写入 +``` + +四层防护分别解决不同问题: + +- 限流阻止通过变化查询参数绕过缓存的请求放大。 +- 硬上限保证管理员误配置也不能产生无限扫描。 +- 结果缓存降低正常刷新、页面重载和多副本重复计算。 +- 数据库索引降低 count 和 fetch 的范围扫描成本。 + +### 5.2 路由限流 + +对以下两个已认证路由增加 `middleware.SearchRateLimit()`: + +```go +selfRoute.GET("/savings/summary", middleware.SearchRateLimit(), controller.GetUserSavingsSummary) +selfRoute.GET("/savings/trend", middleware.SearchRateLimit(), controller.GetUserSavingsTrend) +``` + +沿用现有默认策略:每个用户 60 秒最多 10 次搜索请求。使用用户 ID 作为限流维度,不与匿名 IP 共用额度。限流发生在业务查询之前,返回项目现有 429 响应。 + +不使用 `CriticalRateLimit()`:该接口属于高成本只读搜索,语义和 `/api/log/self/search` 更接近。 + +### 5.3 配置硬上限 + +新增常量并在后端规范化与前端输入中保持一致: + +```text +max_summary_days: 1..31 +max_summary_log_rows: 1..50000 +``` + +后端是最终边界。管理员 JSON 超出范围时返回明确校验错误,不静默接受;前端输入的 `max` 仅用于即时反馈,不能替代后端验证。 + +超过行数上限时继续返回当前 `is_partial=true` 语义,不截取前 50000 条后伪装成完整统计。 + +### 5.4 短 TTL 结果缓存 + +使用 `pkg/cachex.HybridCache` 建立 summary 和 trend 两个结果缓存: + +- Redis 可用时跨实例共享。 +- Redis 不可用时回退到进程内 LRU。 +- TTL 固定为 60 秒,与前端当前 `staleTime` 一致。 +- 内存容量初始为 5000 个查询结果,不缓存原始日志行。 +- 缓存读写失败时 fail-open:记录告警并执行真实查询,不影响接口可用性。 + +缓存键必须包含: + +```text +schema_version +response_kind +user_id +start_timestamp +end_timestamp +granularity(trend) +utc_offset_minutes(trend) +savings_setting_fingerprint +``` + +`savings_setting_fingerprint` 从规范化设置的稳定 JSON 计算 SHA-256,覆盖官方价格、确认状态、历史回算开关和时效配置。设置更新后自然产生新键,无需扫描删除旧键;旧键由 TTL 自动淘汰。 + +概览滚动 24 小时窗口在前端按分钟对齐,避免每次刷新都因秒级时间戳变化产生新键: + +```text +end = floor(now / 60 seconds) * 60 seconds +start = end - 24 hours +``` + +自定义时间范围保持精确时间戳,不在后端静默改写用户边界。变化参数绕过缓存的问题由搜索限流处理。 + +使用 `singleflight.Group` 按完整缓存键合并同一进程内的并发 miss,避免缓存过期瞬间重复扫描。缓存值构造完成后视为不可变,调用方不得修改其中的 slice。 + +### 5.5 关系型日志索引 + +为 `Log` 增加复合索引: + +```text +idx_logs_user_type_created_id(user_id, type, created_at, id) +``` + +字段顺序对应现有查询: + +```sql +WHERE user_id = ? + AND type = ? + AND created_at >= ? + AND created_at < ? +ORDER BY created_at ASC, id ASC +``` + +要求: + +- 使用 GORM index tag,让 SQLite、MySQL 和 PostgreSQL 通过现有 `AutoMigrate` 创建索引。 +- 不写数据库方言专用 SQL。 +- 不修改 ClickHouse `ORDER BY (created_at, request_id)`;ClickHouse 继续使用月份分区和时间主排序缩小范围,再过滤用户与类型。 +- 大型关系库创建索引可能产生 IO 和锁等待,发布说明必须提示在低峰期完成迁移。 + +验收时分别执行查询计划检查,确认关系型数据库选择新索引;查询计划不写成依赖具体优化器输出的单元测试。 + +## 6. 后端正确性与可维护性 + +### 6.1 配置键规范化 + +`OfficialPrices` 不再在 `range` 期间原地删除和插入。改为构建新 map: + +1. 对原始模型名执行 `TrimSpace`。 +2. 空模型名返回校验错误。 +3. 规范化来源 URL、来源名称和计费模式。 +4. 如果两个原始键规范化为同一模型名,返回冲突错误,不依赖 map 随机迭代顺序选值。 +5. 全部成功后一次性替换原 map。 + +`normalizeSetting` 改为返回 `error`,`ValidateSettingJSONString` 和 `UpdateSettingByJSONString` 都必须传播该错误,避免“校验通过但应用结果不同”。 + +### 6.2 控制器错误响应 + +在 savings controller 内保留一个稳定业务 helper: + +```text +abortSavingsBadRequest(context, i18nKey) +``` + +该 helper 固定返回 HTTP 400 和 `{success:false,message}`,消息通过后端 `i18n.T` 获取。新增并维护英文、简体中文两个后端翻译键: + +- 缺少开始或结束时间。 +- 结束时间晚于当前时间。 +- 缺少或非法时区偏移。 +- 非法趋势粒度。 +- 查询范围超过允许天数。 + +service 层返回可分类错误或哨兵错误,controller 负责把错误映射为本地化消息;不得把中文错误字符串当作跨层 API 契约。 + +### 6.3 其余代码质量修正 + +- `savingsRelayInfo(model string)` 改为 `savingsRelayInfo(modelName string)`。 +- `formatUserLogs` 在循环前读取一次 `ShowOnUsageLogs()`,保证同一页日志使用一致可见性快照。 +- 本地价格查找显式区分“传入预构建 map”和“直接读取模型广场”两个分支。 +- 在 `|||` guard 前注明:request rules 依赖 header/param/time 等原始请求上下文,消费日志无法确定性重放,因此节省回算必须跳过。 +- 测试 setup 和必要前置条件使用 `require`,相互独立的结果属性使用 `assert`。 + +## 7. 前端整改 + +### 7.1 统一货币配置 + +`SummaryCards` 和 `SavingsTrendChart` 都通过选择器读取: + +```ts +const currency = useSystemConfigStore((state) => state.config.currency) +``` + +节省金额换算统一使用: + +```text +currency.quotaPerUnit +currency.usdExchangeRate +``` + +`useStatus()` 仍负责从服务端刷新并同步 store,但节省组件不再同时直接读取 status 和 store 两份货币值。这样可以避免 localStorage placeholder、持久化 store 和最新 status 在加载阶段短暂分叉。 + +### 7.2 可访问性 + +以下已具备文本等价或 `aria-label` 的装饰图标增加 `aria-hidden='true'`: + +- 历史回算说明按钮中的 `Info`。 +- 概览跳转链接中的 `ArrowUpRight`。 + +测试从用户视角断言控件的可访问名称,不断言完整 DOM 或 Tailwind class。 + +### 7.3 设置纯逻辑测试 + +将 `parseSavingsSetting`、`formatSavingsSetting` 和默认值定义从 400 行以上的组件中提取到同模块纯逻辑文件,组件只负责状态和交互。 + +测试至少覆盖: + +- 空字符串使用默认值。 +- 非对象 JSON 返回失败。 +- 布尔值类型错误回退默认值。 +- 数值取整及上下限校验。 +- 非对象 `official_prices` 回退为空 map。 +- 已废弃字段被删除。 +- parse -> format -> parse 保持业务值一致。 + +### 7.4 类型复用 + +删除 `DashboardTimeGranularity`,直接复用 `@/lib/time` 导出的 `TimeGranularity`。节省图表仍只把 hour/day/week 映射为服务端支持的 hour/day 粒度,不改变现有归一化行为。 + +## 8. 国际化整改 + +### 8.1 目标译文 + +俄语: + +```text +Calculate estimated savings using official model prices. + -> Рассчитывать оценочную экономию по официальным ценам моделей. + +Covered request actual cost + -> Фактическая стоимость охваченных запросов + +Covered requests + -> Охваченные запросы + +Recalculate legacy usage logs + -> Пересчитать устаревшие журналы использования +``` + +越南语: + +```text +Official Price Updated + -> Giá chính thức đã được cập nhật + +Official price updated {{time}} + -> Giá chính thức được cập nhật lúc {{time}} +``` + +繁体中文: + +```text +使用者儀表板 -> 用戶儀表板 +本機模型廣場價格 -> 本地模型廣場價格 +本機官方定價 -> 本地官方定價 +``` + +### 8.2 翻译回归测试 + +扩展现有 savings i18n 表格测试: + +- 所有新增 key 在 7 种语言中存在。 +- 非英语值不能等于英语 key。 +- `{{count}}`、`{{coverage}}`、`{{time}}` 等插值占位符必须保留。 +- 对本次评审指出的术语增加精确断言,防止同步工具再次覆盖为语义较弱的翻译。 + +运行 `bun run i18n:sync` 后要求所有语言均为 0 missing、0 extras、0 untranslated。 + +## 9. 分阶段实施 + +### 阶段 A:P0 查询保护 + +1. 增加两条路由的 `SearchRateLimit()`。 +2. 给配置增加 31 天和 50000 行硬上限。 +3. 增加关系库复合索引。 +4. 增加 summary/trend 结果缓存和 singleflight。 +5. 补充缓存隔离、缓存失效、部分结果和限流测试。 + +阶段 A 完成前不得把 PR 从 Draft 改为 Ready。 + +### 阶段 B:行为一致性 + +1. 统一货币配置源。 +2. 修复配置键冲突和 controller i18n。 +3. 修复全部俄语、越南语和繁体中文评审意见。 +4. 增加装饰图标可访问性属性。 + +### 阶段 C:代码质量与收尾 + +1. 完成参数改名、循环外设置读取、分支可读性和 guard 注释。 +2. 提取设置解析纯逻辑并补测试。 +3. 复用 `TimeGranularity`。 +4. 更新现有两份设计文档中的性能和查询限制说明。 +5. 逐条回复并解决 GitHub review thread。 + +## 10. 测试与验证 + +### 10.1 后端自动化测试 + +- `go test ./service -run Savings -count=1` +- `go test ./setting/savings_setting -count=1` +- `go test ./model -run Savings -count=1` +- `go test ./controller ./router -count=1` + +新增确定性测试: + +- 相同用户、窗口和设置产生相同缓存键。 +- 不同用户不能命中同一缓存项。 +- granularity、UTC offset 或设置指纹变化时必须 miss。 +- 缓存读写失败时仍返回真实计算结果。 +- 超过 50000 行返回 partial,不读取日志明细。 +- 配置规范化冲突返回错误,不随机覆盖。 +- controller 对各类非法参数返回 HTTP 400 和对应语言消息。 + +不使用 sleep、随机输入或执行耗时比较验证缓存;通过注入时钟、缓存接口或计数 fixture 断言真实调用次数。 + +### 10.2 前端自动化测试 + +- `bun run typecheck` +- 受影响文件 `oxlint` +- 受影响文件 `oxfmt --check` +- `bun run build` +- dashboard savings、settings 和 i18n 相关测试 +- `bun run i18n:sync` + +前端回归必须覆盖: + +- 概览和趋势使用同一 quota/USD/CNY 配置。 +- 装饰图标不重复进入无障碍名称。 +- 设置 JSON 与可视化编辑来回切换不丢业务字段。 +- 7 种语言动态文案不回退到英语 key。 + +### 10.3 数据库验证 + +对 SQLite、MySQL 和 PostgreSQL 分别执行代表性 count/fetch 查询计划,确认使用 `idx_logs_user_type_created_id`。对 ClickHouse 确认月份分区裁剪仍然生效,并记录用户过滤的残余扫描成本。 + +数据库验证使用固定 fixture,不依赖生产数据,不在自动测试中断言优化器的完整文本输出。 + +## 11. 验收标准 + +- 9 条未解决行内评论全部完成修复、回复并解决线程。 +- 10 条折叠建议均有明确处理结果;接受项完成,暂缓项说明理由。 +- 两个 savings API 都启用每用户搜索限流。 +- 后端无法配置超过 31 天或 50000 行的单次查询。 +- 相同查询在 60 秒内不会重复执行日志回算。 +- 缓存键包含用户、窗口、趋势参数和设置指纹,不存在跨用户复用。 +- SQLite、MySQL、PostgreSQL 迁移成功并建立复合索引。 +- ClickHouse 无表结构重建,现有查询行为不变。 +- 概览和趋势对同一 quota 显示相同人民币金额。 +- 7 种语言无新增缺失键或英语回退。 +- Go 定向测试、前端类型检查、构建、lint、format 和相关测试全部通过。 +- `git diff --check` 通过,提交不包含临时 i18n 脚本或生成产物。 + +## 12. 风险与回滚 + +### 12.1 缓存短暂陈旧 + +新消费最多延迟 60 秒出现在节省汇总中,与现有前端刷新周期一致。出现缓存异常时可在代码层关闭缓存读取,限流和扫描上限仍提供保护。 + +### 12.2 关系库索引迁移成本 + +大型 logs 表创建复合索引可能增加启动迁移时间和写入成本。发布前应在接近生产规模的数据副本上测量;必要时由运维先在线创建同名索引,再部署应用。 + +### 12.3 Redis 故障 + +Redis 超时不能阻断用户查询。缓存必须 fail-open 到真实计算,并保留搜索限流;不得因为缓存失败返回旧用户数据或空成功响应。 + +### 12.4 回滚顺序 + +如果上线后出现异常,按以下顺序回滚: + +1. 关闭结果缓存读取,保留限流和硬上限。 +2. 停止前端自动刷新,保留手动进入页面时查询。 +3. 关闭 `show_on_dashboard`,保留日志快照写入。 +4. 最后关闭整个 savings 功能。 + +关系库复合索引不需要随应用回滚删除;保留索引不会改变查询结果。 diff --git a/docs/user-savings-summary-ui-redesign.md b/docs/user-savings-summary-ui-redesign.md new file mode 100644 index 000000000000..73106012958b --- /dev/null +++ b/docs/user-savings-summary-ui-redesign.md @@ -0,0 +1,461 @@ +# 用户节省摘要 UI 改造方案 + +> 状态:已实施 +> 基准日期:2026-07-29 +> 前置设计:`docs/user-savings-estimate-design.md`、`docs/user-savings-trend-design.md` +> 适用页面:用户概览、钱包 +> 数据口径:基于官方公开定价的估算,不作为财务账单 + +## 1. 背景 + +节省金额功能已经具备近 24 小时估算、历史累计、覆盖率、历史回算状态和趋势图。改造前,钱包页在三项账户指标下方追加一条累计节省横栏,概览页则把近 24 小时与历史累计放在余额侧栏的同一个小区域中。 + +现有实现的数据口径完整,但展示层存在以下问题: + +1. 钱包页的累计金额使用普通说明文字,视觉上更像通知,不像用户需要识别的价值指标。 +2. 金额、指标名称和“基于官方定价估算”的声明没有形成稳定层级。 +3. 概览页的近 24 小时与历史累计使用相近文案,用户需要阅读完整句子才能区分时间范围;摘要只放在左栏或右栏时,还会把同一行另一栏拉高,形成大面积留白。 +4. 覆盖率、统计起点、回算进度和失败状态只在部分页面出现,两个入口的语义不完全一致。 +5. 钱包页没有节省趋势入口,用户看到累计金额后无法自然查看计算明细和变化趋势。 +6. 加载、无数据、接口失败和历史回算未完成等状态缺少统一展示规则。 + +本次以节省摘要展示重构为主,同时补充“启用用户展示必须要求官方价格确认”的配置校验。除此之外不修改估算算法、累计聚合、官方价格来源、服务端接口或数据库结构。 + +## 2. 设计结论 + +- 需要改造,但不把节省金额增加为第四张等权账户指标卡。 +- 钱包页保留“三项账户指标 + 一条节省摘要带”的结构,提高摘要带内部的信息层级。 +- 概览页保留现有余额侧栏,节省摘要改为横跨“使用概览 + 余额侧栏”的底部信息带,不新增图表或独立大卡片。 +- 历史累计是主指标;近 24 小时金额只在概览页作为次级指标出现。 +- 两个页面都必须直接显示“基于官方公开定价估算”,不能只放在 Tooltip 中。 +- 存在基础事件时必须显示覆盖率;累计完成后显示统计起点,未完成时显示“系统历史数据统计中”。 +- 只有概览短窗口接口确认趋势功能开启时,才显示进入 `/dashboard/models` 的尾部箭头;钱包页不增加无法保证目标内容存在的趋势入口。 +- 复用现有两个数据接口,不增加组合接口,不增加轮询频率,不在前端重新扫描日志;只增加配置合法性校验。 + +## 3. 目标与非目标 + +### 3.1 目标 + +改造后,用户应能在 3 秒内回答: + +1. RAPI 历史累计帮我节省了多少人民币。 +2. 这个金额是否已经完成历史统计。 +3. 结果覆盖了多少请求,统计从什么时候开始。 +4. 这个结果以什么价格为基准,是否只是估算。 +5. 在概览页哪里查看近期开销对比和节省趋势。 + +### 3.2 非目标 + +- 不修改逐请求 `max(official_quota - actual_quota, 0)` 的计算规则。 +- 不修改累计人民币冻结口径,不按当前汇率重算历史累计。 +- 不在钱包页或概览页增加趋势图、饼图、排行或动画数字。 +- 不展示“节省率”作为概览主指标;该指标继续保留在趋势面板中。 +- 不把累计节省与当前余额相加,也不描述为可消费余额、返现或收益。 +- 不增加价格来源 URL、服务商账单或逐模型明细接口。 +- 不在本次改造中调整账户三项指标、充值流程或钱包页面结构。 +- 不增加服务端响应字段、数据库迁移或累计事件重建逻辑;配置合法性校验除外。 + +## 4. 数据口径 + +### 4.1 历史累计 + +历史累计读取: + +```http +GET /api/user/savings/lifetime +``` + +主金额使用 `savings_cny_micros`,它是在实时累计或历史回算时冻结的人民币微元总计。前端必须继续使用 `BigInt` 安全格式化,不得转成 JavaScript `number`。 + +展示字段: + +| 字段 | 用途 | +| --- | --- | +| `enabled` | 是否启用历史累计 | +| `show_on_dashboard` | 是否在概览展示 | +| `show_on_wallet` | 是否在钱包展示 | +| `savings_cny_micros` | 历史累计主金额 | +| `coverage_ratio` | 已成功估算请求占全部基础事件的比例 | +| `statistics_started_at` | 累计统计起点 | +| `last_aggregated_at` | 当前用户累计总计最近一次聚合时间,仅用于新鲜度说明和诊断 | +| `backfill_status` | 历史回算状态 | +| `backfill_progress` | 全站历史回算任务进度,不是当前用户个人进度 | +| `is_complete` | 全站历史回算完成且不存在全局待聚合事件时为真,是保守的完整性信号 | + +历史累计金额不随当前汇率或当前官方价格变化。官方价格后来调整,也不能在页面打开时重算已冻结累计值。 + +`coverage_ratio` 是当前用户口径,`backfill_progress` 是系统任务口径,两者不能并列成两个用户级完成率。紧凑摘要只直接展示用户覆盖率和“系统历史数据统计中”;若后续确需展示 `backfill_progress`,必须明确标为“系统回算进度”。 + +`coverage_ratio` 和 `backfill_progress` 的正常范围均为 `0..1`。前端遇到非有限值或越界值时显示 `-` 并保留其余有效金额,不用钳制后的正常百分比掩盖数据错误。 + +### 4.2 近 24 小时 + +近 24 小时读取: + +```http +GET /api/user/savings/summary +``` + +金额使用 `savings_quota`,按当前系统 `quota_per_unit` 和汇率换算成人民币。它与历史累计存在包含关系,不能把两个金额相加展示为新的“总节省”。 + +概览页必须使用明确标签“近 24 小时”,不得仅使用“最近”或“本期”。钱包页不展示该指标,避免充值页面承担分析职责。 + +### 4.3 官方定价声明 + +用户可见固定声明: + +```text +基于官方公开定价估算 +``` + +说明 Tooltip: + +```text +官方价格来自模型广场收录并确认的本地价格快照;估算结果仅供成本对比,实际账单以模型服务商为准。 +``` + +约束: + +- 声明中的“官方”指模型服务商公开定价,不是 RAPI 自定义售价。 +- `enabled=true` 时必须同时满足 `require_official_confirmation=true`,服务端配置校验拒绝保存其他组合。 +- 管理设置在节省功能开启时锁定“要求官方价格确认”开关;需要关闭确认时必须先关闭整个节省功能。 +- 使用模型广场本地价格时还必须由管理员确认 `local_pricing_official_confirmed=true`;关闭该声明后,只允许已逐项确认的 `official_prices` 覆盖项参与估算。 +- 上线前必须确认历史累计数据没有在“无需官方确认”的配置下生成。若无法确认,先隐藏累计展示并执行显式修正或重建,不能直接沿用固定“官方定价”声明。 +- 不使用“已返现”“赚取”“到账”或“保证节省”等财务承诺文案。 +- 近 24 小时包含按当前官方价回算的历史日志时,继续显示现有回算说明。 +- `official_price_stale` 只属于近 24 小时接口:价格过期时保留短窗口金额,并增加非阻断警告和价格更新时间。冻结的历史累计不按当前价格过期状态重新标记。 + +## 5. 信息层级 + +从高到低统一为: + +1. 时间口径:`历史累计节省` 或 `当前已统计累计节省`。 +2. 人民币金额:页面中的主视觉数字。 +3. 估算口径:`基于官方公开定价估算`。 +4. 完整性:覆盖率、统计起点或回算进度。 +5. 操作:仅概览短窗口可用时查看节省趋势。 + +金额使用等宽数字或 `tabular-nums`,但不使用营销页级别的大字号。绿色只用于正向金额、图标和少量状态,不给整个区域铺高饱和绿色背景。 + +当金额为零、没有可估算请求或结果不完整时,不能仅通过颜色表达状态。 + +## 6. 钱包页改造 + +### 6.1 桌面布局 + +钱包页继续保留当前外层边框和三列账户指标。节省区域仍位于底部,但从单行提示改成带主金额、口径和元数据的摘要带: + +```text +┌ 当前余额 ─────────┬ 总用量 ──────────┬ API 请求 ─────────┐ +│ ¥123,123... │ ¥4,906.08 │ 25,414 │ +├─────────────────────────────────────────────────────────┤ +│ 历史累计节省 基于官方公开定价估算 │ +│ ¥1,857.77 覆盖率 96% · 统计始于 2026-05-01 │ +└─────────────────────────────────────────────────────────┘ +``` + +布局要求: + +- 摘要带使用外层容器已有的 `border-t`,不在卡片中再嵌套一张卡片。 +- 左侧为图标、指标名称和金额,右侧为估算声明、覆盖率和日期。 +- 金额建议使用 `text-lg sm:text-xl`,小于上方大屏账户金额但显著高于说明文字。 +- `BadgeDollarSign` 用于节省指标;不同时使用 `Sparkles` 和货币图标制造重复语义。 +- 钱包摘要不增加趋势箭头,整行也不做成隐式链接;钱包只承担账户与充值信息,不承担分析导航。 + +### 6.2 移动布局 + +```text +历史累计节省 +¥1,857.77 +基于官方公开定价估算 +覆盖率 96% +统计始于 2026-05-01 +``` + +- 左右结构改为上下结构。 +- 金额允许换行但不能溢出;超长金额使用 `break-all`,不缩放字号随视口变化。 +- 覆盖率和日期可以换成两行,不使用横向滚动。 +- 三项账户指标继续保持稳定网格,本次不改变其移动端布局。 + +## 7. 概览页改造 + +概览页仍使用“使用概览 + 余额侧栏”作为首行布局。节省摘要独占第二行并横跨两列,避免摘要高度在左栏或右栏制造大面积留白;摘要内部使用明确的两级指标: + +```text +┌──────────────────────────────┬───────────┐ +│ 使用概览与三项指标 │ 余额与钱包 │ +├──────────────────────────────┴───────────┤ +│ 历史累计节省 官方声明 ↗ │ +│ ¥1,857.77 近 24 小时 │ +│ 统计始于 2026-05-01 · 覆盖率 96% ¥28.36 │ +└──────────────────────────────────────────┘ +``` + +规则: + +- 历史累计可用时作为主金额,近 24 小时降为一行次级指标。 +- 历史累计未启用但短窗口已启用时,只显示近 24 小时摘要及其覆盖率。 +- 短窗口未启用但累计已启用时,只显示历史累计,不保留空的分隔线。 +- 两者都不可用时不渲染节省区域,余额和钱包按钮自然收拢。 +- 近 24 小时金额不能重复使用“累计”“总计”等文案。 +- 桌面端摘要使用 `xl:col-span-2` 横跨两列,不能嵌套在任一首行列中;移动端按“使用概览、余额、节省摘要”的顺序自然堆叠。 +- 余额侧栏宽度保持现有 `19rem`,不扩大整个概览容器。 +- 不在概览页展示趋势图。只有 `savingsSummary.enabled=true` 时显示箭头并进入 `/dashboard/models`;仅有历史累计时不显示箭头。 + +## 8. 状态矩阵 + +### 8.1 历史累计状态 + +展示状态必须按以下优先级唯一判定,命中后不再继续匹配: + +```text +页面展示关闭 + -> 首次加载且没有缓存 + -> 请求失败且没有缓存 + -> 历史回算失败 + -> 没有基础事件 + -> 任务完成但没有可估算事件 + -> 历史回算暂停 + -> 历史回算等待中 / 运行中 / 尚未开始 + -> 完整累计 +``` + +| 条件 | 主标题 | 主内容 | 辅助信息 | +| --- | --- | --- | --- | +| `enabled=false` 或页面展示开关关闭 | 不展示 | 无 | 无 | +| 首次加载且没有缓存 | 不展示 | 无 | 等接口返回展示开关,避免关闭功能也闪现占位 | +| 接口失败且没有缓存 | 不展示 | 无 | 依赖 Query 自动重试,不能猜测展示开关 | +| 后台刷新失败且有旧数据 | 保留原标题 | 保留旧金额 | 标记“更新失败”,提供重试入口 | +| `backfill_status=failed` | 当前已统计累计节省 | 有已统计金额时保留 | 警告:历史回算失败,结果尚不完整 | +| 无基础事件 | 历史累计节省 | 暂无可估算消费 | 基于官方公开定价估算 | +| 任务完成但无可估算事件 | 历史累计节省 | 暂无符合估算条件的消费 | 显示覆盖率,不显示主金额 `¥0` | +| `backfill_status=not_started` | 当前已统计累计节省 | 已有实时累计时显示当前金额 | 历史消费尚未回算 | +| `backfill_status=pending/running` | 当前已统计累计节省 | 有已统计金额时保留 | 覆盖率 · 系统历史数据统计中 | +| `backfill_status=pause_requested/paused` | 当前已统计累计节省 | 有已统计金额时保留 | 覆盖率 · 系统历史数据统计已暂停 | +| `is_complete=true` | 历史累计节省 | 冻结人民币金额 | 覆盖率、统计起点 | + +`is_complete` 是最终完整性的权威字段。即使 `backfill_progress=100%`,只要 `is_complete=false`,就不能显示最终“历史累计节省”语义。 + +覆盖率低于 100% 不等于任务失败。无法可靠估算的请求允许被跳过,页面必须如实展示覆盖率,不能把金额外推到未覆盖请求。`request_count=0` 时不展示覆盖率,避免没有分母时显示没有意义的 `0%`。 + +### 8.2 近 24 小时状态 + +| 条件 | 展示 | +| --- | --- | +| `enabled=false` | 隐藏近 24 小时区域 | +| 首次加载且没有缓存 | 不展示可选节省区域,不闪现未知功能的 Skeleton | +| `is_partial=true` | 隐藏金额,提示暂无法完成汇总,并保留趋势入口 | +| `estimated_request_count=0` | “近 24 小时暂无可估算消费” | +| 正常且金额大于 0 | 显示金额和覆盖率 | +| 正常且金额为 0 | 显示 `¥0`,使用中性色,不使用成功色 | +| `official_price_stale=true` | 显示价格更新时间警告 | +| 后台刷新失败且有旧数据 | 保留旧数据,显示“更新失败”和重试入口 | +| 接口失败且没有缓存 | 不显示 `¥0`,也不猜测功能是否开启 | + +### 8.3 避免闪烁 + +- React Query 有旧数据时保留旧数据,不在后台刷新时切回 Skeleton。 +- 节省区域属于可选功能;首次请求没有缓存时先不渲染,避免功能关闭的用户看到占位后再收缩布局。 +- 接口失败不能清除上一次成功数据;可保留旧数据并标记“更新失败”。 +- 首次请求失败且没有缓存时,前端无法得知 `show_on_dashboard` 或 `show_on_wallet`,此时不应无条件渲染节省错误区;依赖 Query 的自动重试恢复。只有已知该入口开启时才展示块内错误和手动重试。 +- 页面其他指标不等待节省接口,不因节省接口错误阻塞概览或钱包。 + +## 9. 交互与文案 + +### 9.1 操作 + +- `ArrowUpRight`:只在概览页且短窗口接口返回 `enabled=true` 时显示;跳转 `/dashboard/models`,Tooltip 为“查看节省趋势”。 +- `Info`:解释官方定价、估算属性和累计冻结口径。 +- `RotateCcw`:仅在已有可见旧数据且后台刷新失败时重试当前 Query,Tooltip 为“重新加载节省数据”。 + +除上述动作外,不新增展开面板、弹窗、下拉菜单或配置入口。普通用户不能在摘要区域修改管理员设置。 + +### 9.2 推荐中文文案 + +| 语义 | 文案 | +| --- | --- | +| 完整累计标题 | 历史累计节省 | +| 未完整累计标题 | 当前已统计累计节省 | +| 官方口径 | 基于官方公开定价估算 | +| 无数据 | 暂无可估算消费 | +| 完整元数据 | 覆盖率 {{coverage}} · 统计始于 {{date}} | +| 回算元数据 | 覆盖率 {{coverage}} · 系统历史数据统计中 | +| 回算进度 Tooltip | 系统回算进度 {{progress}} | +| 回算失败 | 历史回算失败,结果尚不完整 | +| 近期指标 | 近 24 小时 | +| 趋势入口 | 查看节省趋势 | + +品牌句“RAPI 已累计帮你节省约 {{amount}}”可以保留在营销或祝贺场景,不作为账户工具页的指标标题。账户页面优先使用短标签和独立金额,降低长句在移动端及多语言下的溢出风险。 + +## 10. 视觉规范 + +- 复用现有 `bg-card`、`bg-muted/30`、`border-border`、`text-foreground` 和 `text-muted-foreground` 主题变量。 +- 正金额使用 `text-success`;零金额、无数据和加载状态使用中性色。 +- 警告使用 `text-warning`,但不改变主金额颜色,避免把已统计金额表现成错误值。 +- 卡片圆角继续使用现有 `rounded-lg`;摘要带自身不增加圆角边框。 +- 概览摘要带使用整卡顶部边框分区并横跨两列,首行左右区域的内容高度不受摘要内容影响。 +- 金额、百分比和日期使用 `tabular-nums`。 +- 不使用渐变、发光、动画计数、彩色整行背景或庆祝装饰。 +- 标题字号保持 `text-xs` 或 `text-sm`,金额使用 `text-lg` 或 `text-xl`,不与页面主标题竞争。 +- 图标按钮保持稳定的最小点击区域,图标本身标记 `aria-hidden='true'`。 + +## 11. 可访问性与国际化 + +- 不能只依赖绿色表达“有节省”,必须同时显示明确标题和金额。 +- Tooltip 只能补充信息,官方定价声明、金额和完整性状态必须在页面正文可见。 +- 图标链接提供 `aria-label`;装饰图标提供 `aria-hidden='true'`。 +- 加载区域使用与最终内容一致的稳定尺寸;错误重试按钮可键盘访问。 +- 金额按当前语言环境分组,但货币固定为人民币 `CNY`。 +- 新文案必须补齐 `en`、`zh`、`zh-TW`、`ja`、`fr`、`ru`、`vi` 七种语言。 +- 测试最长英文、俄文和越南文,允许元数据换行,禁止文本覆盖箭头或金额。 +- 页面缩放到 200% 时,标题、金额、声明、覆盖率和操作仍可读取。 + +## 12. 前端实现边界 + +### 12.1 建议修改 + +- `web/src/features/wallet/components/wallet-stats-card.tsx` + - 将底部单行提示改成结构化累计摘要带。 + - 增加加载、失败、无数据、回算中和完成状态。 +- `web/src/features/dashboard/components/overview/summary-cards.tsx` + - 调整历史累计和近 24 小时的主次层级。 + - 将节省摘要提升到外层网格第二行并横跨两列,消除左右栏大面积留白。 + - 统一官方定价声明、覆盖率和状态文案。 + - 仅在短窗口功能可用时保留趋势入口。 + - 增加有旧数据时的 Query 错误降级和重试行为。 +- `web/src/features/dashboard/components/overview/summary-cards-layout.ts` + - 固化概览首行双列和节省摘要跨列的响应式布局契约。 +- `web/src/features/dashboard/components/overview/__tests__/summary-cards-layout.test.ts` + - 回归验证摘要位于双列下方且横跨两列,不再拉高任一侧栏。 +- `web/src/features/dashboard/lib/savings-query-keys.ts` + - 提供共享的 `savingsQueryKeys.lifetime`,供概览、钱包和设置任务复用同一缓存,并保持纯模块可直接测试。 +- `web/src/features/dashboard/lib/savings.ts` + - 保留现有安全金额格式化。 + - 增加纯函数,将累计响应归一化为 `loading` 之外的展示状态枚举。 +- `web/src/features/dashboard/lib/__tests__/savings-lifetime.test.ts` + - 增加累计状态归一化、大金额和边界测试。 +- `web/src/i18n/locales/*.json` + - 补齐新增文案。 +- `setting/savings_setting/config.go` + - 校验 `enabled=true` 时 `require_official_confirmation` 必须为 `true`。 +- `setting/savings_setting/config_test.go` + - 覆盖合法组合、非法组合和已有默认配置。 + +### 12.2 复用策略 + +钱包页和概览页共享数据口径、金额格式化和状态归一化,不强行共享整块 JSX: + +```text +SavingsLifetimeSummary + -> getLifetimeSavingsViewState(纯函数) + -> formatSavingsCNYMicros(安全金额格式化) + -> 钱包摘要带 / 概览侧栏分别组合布局 +``` + +这样可以避免两处对 `is_complete` 和 `backfill_status` 作出不同解释,同时保留适合各自页面密度的结构。只有出现第三个相同展示面后,才评估抽取通用 `LifetimeSavingsSummary` 组件。 + +### 12.3 不修改 + +- `GET /api/user/savings/summary` +- `GET /api/user/savings/lifetime` +- `GET /api/user/savings/trend` +- 后端聚合表、回算任务和事件模型 +- 模型分析趋势图的数据结构和图表实现 + +本次允许修改配置校验,但不修改三个用户接口的响应结构。 + +## 13. 查询与性能 + +- 钱包页继续只请求历史累计接口;不为展示近 24 小时而增加日志汇总请求。 +- 概览页继续请求近 24 小时和历史累计接口,不增加第三个请求。 +- 保持现有 `staleTime=60s`;概览页保持现有 60 秒刷新,不提高频率。 +- 概览和钱包统一使用 `['savings', 'lifetime']` Query Key;路由切换时在 60 秒新鲜期内复用同一用户缓存,避免对相同接口重复请求。 +- 登录、登出和账户切换继续调用现有 `queryClient.clear()`,共享 Query Key 不能跨认证会话保留数据。 +- 历史累计接口实际读取当前用户总计单行、最新全站回算任务,并按索引统计待聚合事件;UI 改造不得增加日表或原始日志扫描,也不得把该接口描述成严格的单行 `O(1)` 查询。 +- 不为两个接口增加前端串行依赖;一个失败时另一个仍可独立展示。 + +### 13.1 最终一致性与新鲜度 + +- 新消费通过进程内通知唤醒累计 worker,worker 另有 15 秒兜底周期;概览默认每 60 秒刷新一次。 +- 正常运行时,概览累计金额从消费发生到用户看到可能延迟约 75 秒;补偿路径、故障恢复或缓存异常时可能更久。 +- 钱包页不新增定时轮询;进入页面时若共享 Query 已过期则刷新。全局 Query 配置关闭了窗口聚焦刷新,因此钱包不是实时监控页面。 +- `last_aggregated_at` 表示当前用户总计最近一次聚合提交时间,不代表官方价格更新时间,也不能据此推断回算已经完成。 +- 页面默认不增加“最后更新”常驻文案;发生后台刷新失败时,可以在 Tooltip 中展示上次成功聚合时间,避免继续堆高摘要密度。 + +## 14. 实施步骤 + +1. 增加官方确认配置不变量及后端配置测试,确认当前实例历史累计数据满足官方来源声明。 +2. 在 `savings.ts` 中增加有明确优先级的累计展示状态纯函数及单元测试。 +3. 统一 Lifetime Query Key,确认认证会话切换会清空缓存。 +4. 重构钱包页节省摘要带,不增加趋势入口。 +5. 重构概览页节省区域,确定累计主指标和近 24 小时次级指标,并按短窗口开关控制趋势入口。 +6. 补齐七语言文案并运行 i18n 完整性检查。 +7. 运行 Go 配置测试、前端类型检查、单元测试和生产构建。 +8. 使用真实接口状态验证桌面和移动端,包括长金额、低覆盖率、回算中与失败状态。 +9. 对照本方案布局示意和验收矩阵检查后再更新 PR,避免仅凭构建成功判定 UI 完成。 + +## 15. 测试方案 + +### 15.1 单元测试 + +- `savings_cny_micros` 为 `0`、小数人民币、大额和超出安全整数范围时格式正确。 +- `is_complete=false` 且 `backfill_progress=1` 仍返回“未完整”状态。 +- `failed`、`paused`、`running` 和 `completed` 映射到正确展示状态。 +- 同时满足多个状态条件时严格遵守定义的判断优先级。 +- `coverage_ratio` 为 `0`、小数、`1` 和异常边界时安全显示。 +- 无统计起点时不生成 `1970-01-01`。 +- 接口错误状态不伪造 `¥0`。 +- 全站 `backfill_progress` 不会被格式化成当前用户个人进度文案。 +- `enabled=true` 且 `require_official_confirmation=false` 的配置保存失败。 +- 概览和钱包使用同一个 Lifetime Query Key,认证清理后不保留旧用户数据。 + +### 15.2 页面验证矩阵 + +| 页面 | 视口 | 数据状态 | +| --- | --- | --- | +| 钱包 | 1440 x 900 | 完整累计、大金额、覆盖率不足 100% | +| 钱包 | 390 x 844 | 长金额、长翻译、无数据 | +| 概览 | 1440 x 900 | 近 24 小时与累计同时存在 | +| 概览 | 390 x 844 | 仅累计、仅近 24 小时、两者关闭 | +| 两页 | 桌面与移动 | 回算中、暂停、失败、接口失败 | +| 两页 | 桌面与移动 | 浅色、深色、200% 页面缩放 | + +### 15.3 浏览器验收 + +- 页面无框架错误覆盖层和相关 Console 错误。 +- 金额、元数据、概览箭头和账户指标没有重叠或溢出。 +- 桌面端节省摘要横跨整卡底部,使用概览和余额栏下方均不存在由摘要错列造成的大面积留白。 +- 概览趋势图标只在短窗口功能开启时出现,可键盘聚焦并进入 `/dashboard/models`;钱包页不出现趋势图标。 +- 错误重试只刷新节省 Query,不刷新整个钱包或概览页面。 +- 后台刷新期间保留旧金额,不产生明显闪白。 +- 功能关闭后不保留空边框、空分隔线或占位标题。 + +## 16. 验收标准 + +必须全部满足: + +1. 钱包页累计金额不再以单行通知文案呈现,而是具有独立标题、金额和元数据层级。 +2. 概览页能一眼区分“历史累计”和“近 24 小时”,两者不会被相加或混称。 +3. 两个页面都直接显示“基于官方公开定价估算”。 +4. 存在基础事件时显示用户覆盖率;累计完成时显示统计起点,未完成时显示“当前已统计”和“系统历史数据统计中”。 +5. 回算失败或接口失败时不把不完整结果冒充最终累计,也不显示伪造的零金额。 +6. 只有概览短窗口功能开启时提供模型分析趋势入口,钱包页没有可能落空的入口。 +7. 概览和钱包复用同一 Lifetime Query 缓存,认证会话切换后缓存被清理。 +8. 不新增后端接口、数据库结构、日志扫描、前端生产依赖或高频轮询。 +9. 七语言、桌面、移动端、深色模式和长金额验证通过。 + +## 17. 风险与回退 + +| 风险 | 控制措施 | +| --- | --- | +| 节省金额过度抢占余额视觉层级 | 金额限制为 `text-lg/text-xl`,不改成第四张主卡 | +| 用户误认为财务账单或返现 | 固定显示“估算”和“官方公开定价”,禁止到账类文案 | +| 用户把近 24 小时与累计相加 | 使用主次布局和明确时间标签,不并列为两个同级总额 | +| 回算未完成却显示最终金额 | 以 `is_complete` 为权威状态,未完成固定使用“当前已统计” | +| 把全站任务进度误认为用户进度 | 正文只显示“系统历史数据统计中”,精确百分比也必须标明“系统” | +| 钱包趋势入口落到空页面 | 钱包不展示趋势入口,概览按短窗口 `enabled` 控制箭头 | +| 相同累计接口在页面切换时重复请求 | 两页复用统一 Lifetime Query Key,认证切换时清空 | +| 两页面状态语义继续漂移 | 共用纯状态归一化和金额格式化测试 | +| 移动端长文案挤压操作 | 标题行只保留短标题和图标,元数据允许独立换行 | + +本次为前端展示改造加一项后端配置不变量,不涉及接口和数据库迁移。出现不可接受的布局回归时,可以独立回退钱包与概览组件;官方确认配置校验应继续保留。若上线前发现已有累计数据无法证明来自官方确认价格,应保持用户入口关闭并走显式修正流程,不能通过回退 UI 隐藏口径问题后继续展示。 diff --git a/docs/user-savings-trend-design.md b/docs/user-savings-trend-design.md new file mode 100644 index 000000000000..f1dbcf3e87cc --- /dev/null +++ b/docs/user-savings-trend-design.md @@ -0,0 +1,387 @@ +# 用户节省趋势图设计方案 + +> 状态:已实施并完成桌面/移动端验证(2026-07-28) +> 前置设计:`docs/user-savings-estimate-design.md` +> 关联摘要改造:`docs/user-savings-summary-ui-redesign.md` +> 适用页面:用户仪表盘「模型调用分析」 +> 数据口径:估算,不作为财务账单 + +## 1. 目标 + +在不增加概览页信息密度的前提下,让用户直观看到同一批可估算请求在官方定价和实际扣费之间的差异,并回答三个问题: + +1. 最近一段时间实际节省了多少人民币。 +2. 官方价格与实际消费的差距如何随时间变化。 +3. 当前结果覆盖了多少请求,是否包含按当前价格回算的历史日志。 + +## 2. 设计结论 + +- 不在概览右栏增加图表;概览继续只显示近 24 小时节省金额和覆盖率。 +- 在现有 `/dashboard/models`「模型调用分析」中增加一个全宽“成本对比”面板。 +- 面板放在统计卡片和管理员性能概览之后、现有消费分布图之前。 +- 一期只实现一张趋势图,不增加一级导航、饼图、仪表盘或动画累计数字。 +- 概览节省块增加箭头图标入口,跳转到 `/dashboard/models`;趋势面板位于页面前部,不依赖异步组件挂载后的锚点滚动。 +- 二期再评估“按模型节省排行”,不与一期接口强行合并。 + +## 3. 展示口径 + +### 3.1 指标定义 + +图表中的两条金额序列必须基于同一批成功估算的请求: + +```text +官方价估算 = eligible logs 的 official_quota 之和 +实际消费 = eligible logs 的 actual_quota 之和 +估算节省 = eligible logs 的 max(official_quota - actual_quota, 0) 之和 +覆盖率 = estimated_request_count / request_count +节省率 = savings_quota / official_quota +``` + +`actual_quota` 不是时间范围内全部消费,只是已覆盖请求的实际消费。前端不得将它标成“总消费”,避免覆盖率不足时误导用户。 + +节省金额在单请求维度钳制为非负数后再汇总,因此汇总的 `savings_quota` 不保证等于汇总 `official_quota - actual_quota`。图表不得把两条总额曲线之间的面积直接解释为节省金额;实际高于官方价的请求仍保留在两条对比曲线中,但不会产生负节省。 + +当 `official_quota=0` 时节省率返回 0,不执行除零;节省率仅表示“逐请求非负节省占官方价估算的比例”。 + +### 3.2 人民币换算 + +后端继续返回 quota,不固化人民币金额。前端使用系统状态中的: + +```text +CNY = quota / quota_per_unit * usd_exchange_rate +``` + +- 图表纵轴、指标和 Tooltip 统一显示人民币。 +- 面板标题区固定展示“按当前系统汇率换算”和当前 `1 USD = ¥x`;Tooltip 中的金额沿用同一汇率,不重复增加汇率行。 +- 汇率变化只影响展示,不修改日志快照和 quota 汇总。 +- 非法或缺失汇率使用现有货币配置回退规则,不在组件内维护第二套默认值。 + +### 3.3 历史回算 + +- 新日志优先使用 `other.savings_estimate` 快照。 +- 无快照旧日志继续执行现有受限回算。 +- 历史回算使用查询时当前官方价,因此趋势可能随官方价格配置变化。 +- 只要任一桶包含历史回算,面板必须显示“含按当前官方价回算的历史消费”。 +- `rebuild_price_snapshot_at` 只表示本次查询读取价格的时间,不得描述为请求发生时的官方价格。 + +## 4. 页面布局 + +### 4.1 桌面端 + +```text +┌ 成本对比 ──────────────────────────────────────────────┐ +│ 近 7 天 · 人民币 已节省 ¥153.85 节省率 54.7% │ +│ 覆盖率 98% │ +├───────────────────────────────────────────────────────┤ +│ 官方价估算 - - - - │ +│ 实际消费 ━━━━━━━ 估算节省为绿色柱 │ +│ │ +│ 趋势图 │ +│ │ +└───────────────────────────────────────────────────────┘ + 含 667 条按当前官方价回算的历史请求 · ⓘ +``` + +布局要求: + +- 使用现有 `rounded-lg border` 面板结构,不嵌套卡片。 +- 标题左侧使用 `BadgeDollarSign` 或 `ChartNoAxesCombined` 图标。 +- 总节省、节省率、覆盖率使用紧凑的行内指标和分隔线,不做三个小卡片。 +- 图表桌面高度约 `360px`,移动端约 `300px`,保持稳定高度避免加载后跳动。 +- 控件和图例允许横向滚动,不压缩到文字重叠。 +- 概览入口使用熟悉的右箭头图标并提供 Tooltip,不增加新的文字按钮。 + +### 4.2 移动端 + +- 标题、时间范围和指标分两行排列。 +- 默认只显示“已节省”和“覆盖率”,节省率进入 Tooltip 或第二行。 +- 图例置于图表顶部,避免覆盖数据区域。 +- Tooltip 限制最大宽度,金额和日期使用不换行的 tabular 数字。 + +## 5. 图表设计 + +### 5.1 系列 + +使用 VChart 线柱组合图: + +- `official`:中性灰色虚线,表示官方价估算。 +- `actual`:蓝色或深色实线,表示已覆盖请求的实际消费。 +- `savings`:浅绿色柱,表示逐请求非负钳制后汇总的估算节省。 + +三组数据共用人民币纵轴。节省柱使用较低不透明度,避免遮挡折线。两条线允许交叉,不填充两线之间的面积;不得为了制造“始终节省”的视觉效果隐藏实际价格高于官方价的桶,也不得手写 Canvas。 + +### 5.2 Tooltip + +每个时间桶展示: + +```text +7 月 27 日 +官方价估算 ¥281.30 +实际消费 ¥127.45 +估算节省 ¥153.85 +覆盖请求 679 / 694(98%) +历史回算 667 条 +``` + +规则: + +- 三个金额均由 quota 在前端按同一汇率换算。 +- `savings_quota=0` 时仍可显示该桶,但不绘制负节省。 +- 覆盖率低于 100% 时 Tooltip 明确展示分子和分母。 +- 后端空桶返回零值;前端以 `estimated_request_count > 0` 判断该桶是否存在可绘制金额。无请求桶和有请求但零覆盖桶都映射为 `null`,不连接跨缺口折线,也不把未知成本误画成零成本。 +- 折线按连续有效区间分配 `seriesField`,缺口后的有效数据必须恢复绘制;两条折线关闭独立 Tooltip,只由柱系列输出一份完整明细。 + +### 5.3 时间范围 + +复用模型分析页面现有筛选器: + +- 近 1 天:按小时。 +- 近 7 天、14 天、29 天:按天。 +- 自定义范围最长 31 天。 + +一期不按周聚合。若现有筛选器选择 `week`,节省趋势仍规范化为按天,避免 29 天范围只剩少量点。 + +### 5.4 可访问性 + +- 不能只依赖颜色区分系列,同时使用虚线、实线和柱形。 +- 图表容器提供包含时间范围、总节省和覆盖率的可访问名称。 +- 根据 buckets 生成屏幕阅读器可读的数据表,列出时间、官方价、实际消费、节省和覆盖率;表格放在 `sr-only overflow-hidden` 容器内,避免原生表格布局撑宽移动端页面。 +- Header 中的汇总指标在图表不可交互或 Canvas 不可读时仍能表达核心结论。 + +## 6. API 设计 + +### 6.1 接口 + +```http +GET /api/user/savings/trend + ?start_timestamp=1785081600 + &end_timestamp=1785168000 + &granularity=hour + &utc_offset_minutes=480 +``` + +权限:`middleware.UserAuth()`,只能查询当前登录用户。 + +参数: + +| 参数 | 类型 | 规则 | +| ---- | ---- | ---- | +| `start_timestamp` | int64 | 必填,大于 0 | +| `end_timestamp` | int64 | 必填,复用现有 5 分钟时钟偏差和截断规则 | +| `granularity` | string | `hour` 或 `day` | +| `utc_offset_minutes` | int | 必填,用户相对 UTC 的分钟偏移,范围 `-720..840` | + +粒度限制: + +- `hour` 最长查询 48 小时。 +- `day` 最长查询 31 天。 +- 预计桶数超过 64 时返回 400,不在后端静默降采样。 + +### 6.2 响应 + +```json +{ + "success": true, + "data": { + "granularity": "day", + "utc_offset_minutes": 480, + "start_timestamp": 1785081600, + "end_timestamp": 1785168000, + "summary": { + "enabled": true, + "savings_quota": 10537576, + "official_quota": 24792049, + "actual_quota": 19289098, + "request_count": 694, + "estimated_request_count": 679, + "snapshot_request_count": 12, + "reconstructed_request_count": 667, + "coverage_ratio": 0.9784, + "source": "mixed", + "official_confirmed": true, + "source_updated_at": 1785081600, + "rebuild_price_snapshot_at": 1785168000, + "official_price_stale": false, + "window_days": 1, + "is_partial": false + }, + "buckets": [ + { + "start_timestamp": 1785081600, + "end_timestamp": 1785168000, + "official_quota": 24792049, + "actual_quota": 19289098, + "savings_quota": 10537576, + "request_count": 694, + "estimated_request_count": 679, + "snapshot_request_count": 12, + "reconstructed_request_count": 667, + "coverage_ratio": 0.9784 + } + ] + } +} +``` + +`summary` 复用现有 `SavingsSummary` 完整结构,避免总览和趋势接口产生两套汇总语义。金额汇总字段继续使用 `int64`。前端在当前 50,000 行保护阈值内可安全映射为 JavaScript `number`。 + +### 6.3 分桶规则 + +- 时间范围仍使用 `[start_timestamp, end_timestamp)`。 +- 小时桶为 3,600 秒,天桶为 86,400 秒。 +- 先计算 `offset_seconds = utc_offset_minutes * 60`,再对齐用户本地整点或本地零点:`floor((timestamp + offset_seconds) / bucket_size) * bucket_size - offset_seconds`。 +- 首尾桶可以是不完整桶,只统计查询范围内的日志;返回的桶边界仍是完整的本地日历边界,前端按响应时间戳显示标签。 +- 后端返回所有桶,包括无请求空桶,确保时间轴稳定。 +- 单条日志先完成快照解析或历史回算,再加入所属桶;不得为每个桶重新扫描日志。 + +## 7. 后端实现 + +### 7.1 复用边界 + +重构现有 `GetUserSavingsSummary` 的聚合循环,提取可复用的日志估算结果: + +```go +type SavingsLogEstimate struct { + CreatedAt int64 + Estimate *SavingsEstimate + CalculationMode string + SkipReason string +} +``` + +该结构只表达稳定业务结果,供总览汇总和趋势分桶共同使用。不要复制一套历史回算、价格匹配或 quota 校验逻辑。 + +### 7.2 查询流程 + +```text +校验并规范化时间范围 + -> COUNT 当前用户 consume logs + -> 超过 max_summary_log_rows:返回 is_partial=true、空 buckets + -> 一次查询必要日志字段 + -> 一次构建官方价格 Map + -> 每条日志执行快照优先 / 历史回算 + -> 同时累加 summary 和 bucket + -> 受检 int64 累加 + -> 计算总覆盖率和各桶覆盖率 +``` + +约束: + +- 不使用数据库 JSON 函数,保持 SQLite、MySQL、PostgreSQL 兼容,并兼容现有日志数据库查询路径。 +- 不新增趋势表或后台回填任务;31 天、50,000 行以内在 Go 中聚合。 +- `model.GetPricing()` 每次请求只调用一次。 +- 任一 quota 累加溢出时整次请求失败,不返回截断金额。 +- `is_partial=true` 时不返回部分趋势,防止用户把不完整金额当总额。 + +### 7.3 路由与文件 + +建议改动: + +- `router/api-router.go`:注册 `/api/user/savings/trend`。 +- `controller/savings.go`:解析时间和粒度参数。 +- `service/savings_estimate.go`:复用日志估算并实现分桶。 +- `model/savings_log.go`:继续复用现有时间范围日志查询,不新增方言 SQL。 + +### 7.4 用户与管理员作用域 + +一期趋势接口始终使用登录用户 ID,不接受 `user_id` 或 `username`,避免普通用户越权。普通用户的模型分析本来就是自身数据,口径一致。 + +管理员的模型分析默认可能展示全局数据,因此趋势面板标题必须显示“当前账户成本对比”,并带“仅当前账户”作用域标识,不跟随管理员用户名筛选器。管理员跨用户或全站节省趋势需要独立权限、扫描阈值和产品口径,留到后续版本,不能在一期接口中用可选参数隐式放开。 + +## 8. 前端实现 + +建议新增: + +- `web/src/features/dashboard/components/models/savings-trend-chart.tsx` +- `web/src/features/dashboard/lib/savings-chart.ts` +- `web/src/features/dashboard/lib/__tests__/savings-chart.test.ts` + +建议修改: + +- `web/src/features/dashboard/api.ts`:增加 `getUserSavingsTrend`。 +- `web/src/features/dashboard/types.ts`:增加 `SavingsTrend` 和 `SavingsTrendBucket`。 +- `web/src/features/dashboard/index.tsx`:懒加载趋势面板并接入现有筛选器。 +- `web/src/features/dashboard/components/overview/summary-cards.tsx`:增加查看趋势入口。 +- `web/scripts/add-missing-keys.mjs`:通过脚本补齐七语言文本。 + +React Query: + +```ts +queryKey: ['dashboard', 'savings-trend', start, end, granularity, utcOffset] +staleTime: 60 * 1000 +``` + +- 筛选条件变化时保留旧图直到新请求完成,避免闪白。 +- 功能关闭时不渲染面板。 +- `is_partial=true`、无可估算请求、接口错误分别提供明确状态。 +- 组件使用现有主题、`VCHART_OPTION` 和动态主题加载方式,不引入新图表依赖。 +- 普通用户标题为“成本对比”;管理员标题为“当前账户成本对比”,防止与同页全局模型数据混淆。 + +## 9. 状态与降级 + +| 状态 | 展示 | +| ---- | ---- | +| 功能关闭 | 不显示面板,概览入口也隐藏 | +| 加载中 | 固定高度 Skeleton | +| 无消费日志 | “所选时间范围暂无消费记录” | +| 有日志但无可估算请求 | “暂无符合估算条件的消费记录”,显示覆盖率 0% | +| 部分数据 | 隐藏金额和折线,提示缩小时间范围 | +| 官方价格过期 | 保留趋势并显示价格更新时间警告 | +| 接口失败 | 面板内错误状态和重试按钮,不影响其他分析图表 | + +## 10. 测试 + +后端必须覆盖: + +- 小时和天分桶边界、正负 UTC 偏移,尤其是 `[start, end)` 相邻桶不重复。 +- 快照、历史回算和两者混合时的桶计数与总计一致。 +- 空桶补齐。 +- 单条日志只进入一个桶。 +- 无效粒度、超长范围、未来时间和 5 分钟偏差截断。 +- `hour` 超过 48 小时、`day` 超过 31 天或桶数超过 64 时拒绝请求。 +- 超过扫描阈值返回全局 partial,不返回部分桶。 +- int64 受检累加。 +- 每桶 `savings_quota` 等于桶内逐请求非负节省之和;明确覆盖“汇总官方价减实际价不等于汇总节省”的场景。 + +前端必须覆盖: + +- quota 按系统汇率转换为 CNY。 +- 官方价线、实际消费线和节省柱使用同一桶数据,Tooltip 不用两线差值替代 `savings_quota`。 +- 无请求桶和有请求但零覆盖桶不跨越连线,缺口后的有效区间继续显示。 +- 覆盖率不足、历史回算、partial、disabled 和 empty 状态。 +- 筛选器切换后 query key 与粒度规范化正确。 +- 移动端标题、指标、图例不重叠。 +- 管理员页面明确显示“仅当前账户”,不误用管理员全局筛选条件。 + +验证命令沿用项目约定:Go 完整测试、前端单测、`tsgo -b`、目标 `oxlint`、生产构建和 `git diff --check`。 + +## 11. 施工顺序 + +1. 提取现有单日志估算与汇总累加的复用逻辑,确保原 summary 行为不变。 +2. 实现趋势 DTO、分桶服务、controller 和路由。 +3. 补后端分桶与安全边界测试。 +4. 增加前端类型、API、纯图表数据转换函数和单元测试。 +5. 在模型调用分析中接入趋势面板与完整状态。 +6. 在概览节省块增加跳转入口。 +7. 通过 i18n 脚本补齐七语言。 +8. 完整验证,并使用桌面和移动视口检查图表非空、Tooltip 和文字布局。 + +## 12. 验收标准 + +- 用户能在模型调用分析中查看 1、7、14、29 天的成本对比趋势。 +- 图表官方价和实际消费只比较同一批已覆盖请求。 +- 汇总金额等于所有桶之和,覆盖率分子分母准确。 +- 人民币金额使用当前系统汇率,Tooltip 明示换算口径。 +- 历史回算数量和价格快照时间可解释。 +- partial、无数据、功能关闭时不展示误导金额。 +- 不改变实际扣费、余额、订阅、退款和日志写入行为。 +- 不新增依赖、数据库表和跨数据库专用 SQL。 + +## 13. 暂不实施 + +- 本趋势图阶段不实现累计终身节省;其冻结聚合方案见 `docs/user-savings-estimate-design.md` 第 17 节。 +- 本趋势图阶段不实现钱包充值页节省模块;仅在累计回算完成后按第 17 节口径开放。 +- 管理员跨用户节省排行榜。 +- 饼图、仪表盘、动画金额。 +- 自动抓取官网价格。 +- 按模型节省排行;待趋势图上线后根据使用反馈评估。 diff --git a/i18n/keys.go b/i18n/keys.go index 64a835e1a942..89f49f384abb 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -28,6 +28,19 @@ const ( MsgBatchTooMany = "common.batch_too_many" ) +// Savings estimate messages +const ( + MsgSavingsTimeRangeRequired = "savings.time_range_required" + MsgSavingsUTCOffsetRequired = "savings.utc_offset_required" + MsgSavingsUTCOffsetInvalid = "savings.utc_offset_invalid" + MsgSavingsEndAfterNow = "savings.end_after_now" + MsgSavingsTimeRangeInvalid = "savings.time_range_invalid" + MsgSavingsTimeRangeTooLarge = "savings.time_range_too_large" + MsgSavingsHourRangeTooLarge = "savings.hour_range_too_large" + MsgSavingsGranularityInvalid = "savings.granularity_invalid" + MsgSavingsTooManyBuckets = "savings.too_many_buckets" +) + // Auth middleware messages const ( MsgAuthNotLoggedIn = "auth.not_logged_in" diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index c533daecc32d..53c09706ccdb 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -23,6 +23,17 @@ common.already_exists: "Already exists" common.name_cannot_be_empty: "Name cannot be empty" common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}" +# Savings estimate messages +savings.time_range_required: "Start and end times are required" +savings.utc_offset_required: "UTC offset is required" +savings.utc_offset_invalid: "UTC offset is invalid" +savings.end_after_now: "End time cannot be later than the current time" +savings.time_range_invalid: "Time range is invalid" +savings.time_range_too_large: "Time range is too large" +savings.hour_range_too_large: "Hourly granularity supports up to 48 hours" +savings.granularity_invalid: "Trend granularity is invalid" +savings.too_many_buckets: "Too many trend buckets; reduce the time range" + # Auth middleware messages auth.not_logged_in: "Unauthorized, not logged in and no access token provided" auth.access_token_invalid: "Unauthorized, invalid access token" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index a2f5275be9a8..052dbf68989e 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -24,6 +24,17 @@ common.already_exists: "已存在" common.name_cannot_be_empty: "名称不能为空" common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条" +# 节省金额估算消息 +savings.time_range_required: "必须传入开始和结束时间" +savings.utc_offset_required: "必须传入时区偏移" +savings.utc_offset_invalid: "时区偏移无效" +savings.end_after_now: "结束时间不能晚于当前时间" +savings.time_range_invalid: "时间范围无效" +savings.time_range_too_large: "时间范围过大,请缩小时间范围" +savings.hour_range_too_large: "小时粒度最多查询 48 小时" +savings.granularity_invalid: "趋势粒度无效" +savings.too_many_buckets: "趋势时间桶过多,请缩小时间范围" + # Auth middleware messages auth.not_logged_in: "无权进行此操作,未登录且未提供 access token" auth.access_token_invalid: "无权进行此操作,access token 无效" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index 84ebd57ed587..b60c311ce172 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -24,6 +24,17 @@ common.already_exists: "已存在" common.name_cannot_be_empty: "名稱不能為空" common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條" +# 節省金額估算訊息 +savings.time_range_required: "必須傳入開始和結束時間" +savings.utc_offset_required: "必須傳入時區偏移" +savings.utc_offset_invalid: "時區偏移無效" +savings.end_after_now: "結束時間不能晚於目前時間" +savings.time_range_invalid: "時間範圍無效" +savings.time_range_too_large: "時間範圍過大,請縮小時間範圍" +savings.hour_range_too_large: "小時粒度最多查詢 48 小時" +savings.granularity_invalid: "趨勢粒度無效" +savings.too_many_buckets: "趨勢時間桶過多,請縮小時間範圍" + # Auth middleware messages auth.not_logged_in: "無權進行此操作,未登入且未提供 access token" auth.access_token_invalid: "無權進行此操作,access token 無效" diff --git a/main.go b/main.go index 742d15515876..86b28f0a2ad7 100644 --- a/main.go +++ b/main.go @@ -131,6 +131,7 @@ func main() { // Report this process as a system instance so the System Info page can show // all currently alive nodes in multi-instance deployments. service.StartSystemInstanceReporter() + service.StartSavingsLifetimeAggregator() // Wire task polling adaptor factory (breaks service -> relay import cycle). // Must run before the system task runner starts: the async_task_poll handler diff --git a/model/log.go b/model/log.go index 1d2b38fc7c1c..b6fb7cd4e148 100644 --- a/model/log.go +++ b/model/log.go @@ -9,6 +9,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/setting/savings_setting" "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" @@ -57,10 +58,10 @@ func sanitizeClickHouseLikePattern(input string) (string, error) { } type Log struct { - Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"` - UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"` - CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"` - Type int `json:"type" gorm:"index:idx_created_at_type"` + Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2;index:idx_logs_user_type_created_id,priority:4"` + UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1;index:idx_logs_user_type_created_id,priority:1"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type;index:idx_logs_user_type_created_id,priority:3"` + Type int `json:"type" gorm:"index:idx_created_at_type;index:idx_logs_user_type_created_id,priority:2"` Content string `json:"content"` Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` TokenName string `json:"token_name" gorm:"index;default:''"` @@ -114,6 +115,7 @@ func assignDisplayLogIds(logs []*Log, startIdx int) { } func formatUserLogs(logs []*Log, startIdx int) { + showSavingsEstimate := savings_setting.ShowOnUsageLogs() for i := range logs { logs[i].ChannelName = "" var otherMap map[string]interface{} @@ -123,6 +125,9 @@ func formatUserLogs(logs []*Log, startIdx int) { delete(otherMap, "admin_info") // Remove operation-audit details (operator/route info), admin-only. delete(otherMap, "audit_info") + if !showSavingsEstimate { + delete(otherMap, "savings_estimate") + } // delete(otherMap, "reject_reason") // delete(otherMap, "stream_status") } @@ -340,9 +345,9 @@ type RecordConsumeLogParams struct { Other map[string]interface{} `json:"other"` } -func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) { +func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) *Log { if !common.LogConsumeEnabled { - return + return nil } logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params))) username := c.GetString("username") @@ -401,6 +406,10 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) NodeName: common.NodeName, }) } + if err != nil { + return nil + } + return log } type RecordTaskBillingLogParams struct { diff --git a/model/log_format_test.go b/model/log_format_test.go index f580dda637af..f69d1f608f72 100644 --- a/model/log_format_test.go +++ b/model/log_format_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/savings_setting" "github.com/stretchr/testify/require" ) @@ -33,3 +34,42 @@ func TestFormatUserLogsStripsQuotaSaturation(t *testing.T) { // Non-admin billing fields remain visible. require.Contains(t, parsed, "model_price") } + +func TestFormatUserLogsHidesSavingsEstimateWhenDisabled(t *testing.T) { + require.NoError(t, savings_setting.UpdateSettingByJSONString("")) + t.Cleanup(func() { require.NoError(t, savings_setting.UpdateSettingByJSONString("")) }) + + logs := []*Log{{ + Other: common.MapToJsonStr(map[string]any{ + "savings_estimate": map[string]any{"savings_quota": 100}, + "visible": true, + }), + }} + + formatUserLogs(logs, 0) + + otherMap, err := common.StrToMap(logs[0].Other) + require.NoError(t, err) + require.NotContains(t, otherMap, "savings_estimate") + require.Equal(t, true, otherMap["visible"]) +} + +func TestFormatUserLogsKeepsSavingsEstimateWhenEnabledForUsageLogs(t *testing.T) { + require.NoError(t, savings_setting.UpdateSettingByJSONString(`{ + "enabled": true, + "show_on_usage_logs": true + }`)) + t.Cleanup(func() { require.NoError(t, savings_setting.UpdateSettingByJSONString("")) }) + + logs := []*Log{{ + Other: common.MapToJsonStr(map[string]any{ + "savings_estimate": map[string]any{"savings_quota": 100}, + }), + }} + + formatUserLogs(logs, 0) + + otherMap, err := common.StrToMap(logs[0].Other) + require.NoError(t, err) + require.Contains(t, otherMap, "savings_estimate") +} diff --git a/model/main.go b/model/main.go index 21445593e54e..49176c8cbeff 100644 --- a/model/main.go +++ b/model/main.go @@ -290,6 +290,9 @@ func migrateDB() error { &SystemInstance{}, &SystemTask{}, &SystemTaskLock{}, + &SavingsLifetimeEvent{}, + &SavingsLifetimeDaily{}, + &SavingsLifetimeTotal{}, &CasbinRule{}, &AuthzRole{}, ) @@ -353,6 +356,9 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, + {&SavingsLifetimeEvent{}, "SavingsLifetimeEvent"}, + {&SavingsLifetimeDaily{}, "SavingsLifetimeDaily"}, + {&SavingsLifetimeTotal{}, "SavingsLifetimeTotal"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/option.go b/model/option.go index e7fda5231be7..91561a508b06 100644 --- a/model/option.go +++ b/model/option.go @@ -11,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/performance_setting" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/setting/savings_setting" "github.com/QuantumNous/new-api/setting/system_setting" "gorm.io/gorm" ) @@ -176,6 +177,7 @@ func InitOptionMap() { common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString() common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) + common.OptionMap[savings_setting.OptionKey] = savings_setting.Setting2JSONString() // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() @@ -573,6 +575,8 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateAudioRatioByJSONString(value) case "AudioCompletionRatio": err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value) + case savings_setting.OptionKey: + err = savings_setting.UpdateSettingByJSONString(value) case "TopUpLink": common.TopUpLink = value //case "ChatLink": diff --git a/model/pricing.go b/model/pricing.go index 6dfbfe7aa9f7..6a47bc26087c 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -55,6 +55,7 @@ var ( // 缓存映射:模型名 -> 启用分组 / 计费类型 modelEnableGroups = make(map[string][]string) modelQuotaTypeMap = make(map[string]int) + modelPricingMap = make(map[string]Pricing) modelEnableGroupsLock = sync.RWMutex{} ) @@ -77,6 +78,14 @@ func GetPricing() []Pricing { return pricingMap } +func GetPricingByModel(modelName string) (Pricing, bool) { + GetPricing() + modelEnableGroupsLock.RLock() + defer modelEnableGroupsLock.RUnlock() + pricing, ok := modelPricingMap[modelName] + return pricing, ok +} + func InvalidatePricingCache() { updatePricingLock.Lock() defer updatePricingLock.Unlock() @@ -84,6 +93,9 @@ func InvalidatePricingCache() { pricingMap = nil vendorsList = nil lastGetPricingTime = time.Time{} + modelEnableGroupsLock.Lock() + modelPricingMap = make(map[string]Pricing) + modelEnableGroupsLock.Unlock() } // GetVendors 返回当前定价接口使用到的供应商信息 @@ -418,9 +430,11 @@ func updatePricing() { modelEnableGroupsLock.Lock() modelEnableGroups = make(map[string][]string) modelQuotaTypeMap = make(map[string]int) + modelPricingMap = make(map[string]Pricing) for _, p := range pricingMap { modelEnableGroups[p.ModelName] = p.EnableGroup modelQuotaTypeMap[p.ModelName] = p.QuotaType + modelPricingMap[p.ModelName] = p } modelEnableGroupsLock.Unlock() diff --git a/model/savings_lifetime.go b/model/savings_lifetime.go new file mode 100644 index 000000000000..5b6d25c62e75 --- /dev/null +++ b/model/savings_lifetime.go @@ -0,0 +1,333 @@ +package model + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type sqliteQuickCheckRow struct { + Result string `gorm:"column:quick_check"` +} + +func CheckSavingsLifetimeSQLiteIntegrity(ctx context.Context) error { + if common.UsingMainDatabase(common.DatabaseTypeSQLite) { + if err := checkSQLiteDatabaseIntegrity(ctx, DB, "main database"); err != nil { + return err + } + } + if common.UsingLogDatabase(common.DatabaseTypeSQLite) && LOG_DB != DB { + if err := checkSQLiteDatabaseIntegrity(ctx, LOG_DB, "log database"); err != nil { + return err + } + } + return nil +} + +func checkSQLiteDatabaseIntegrity(ctx context.Context, db *gorm.DB, label string) error { + if db == nil { + return fmt.Errorf("%s is not initialized", label) + } + var rows []sqliteQuickCheckRow + if err := db.WithContext(ctx).Raw("PRAGMA quick_check(1)").Scan(&rows).Error; err != nil { + return fmt.Errorf("check %s integrity: %w", label, err) + } + if len(rows) == 1 && strings.EqualFold(strings.TrimSpace(rows[0].Result), "ok") { + return nil + } + issues := make([]string, 0, len(rows)) + for _, row := range rows { + if issue := strings.TrimSpace(row.Result); issue != "" { + issues = append(issues, issue) + } + } + if len(issues) == 0 { + issues = append(issues, "quick_check returned no result") + } + return fmt.Errorf("%s integrity check failed: %s", label, strings.Join(issues, "; ")) +} + +const ( + SavingsLifetimeEventTypeBase = "base" + SavingsLifetimeCoverageEstimated = "estimated" + SavingsLifetimeCoverageSkipped = "skipped" + SavingsLifetimeCalculationSnapshot = "snapshot" + SavingsLifetimeCalculationRebuild = "historical_rebuild_frozen" +) + +type SavingsLifetimeEvent struct { + ID int64 `json:"id" gorm:"primaryKey;index:idx_savings_events_pending,priority:2"` + EventKey string `json:"event_key" gorm:"type:varchar(128);uniqueIndex"` + SourceKey string `json:"source_key" gorm:"type:varchar(128);index"` + LogID int64 `json:"log_id" gorm:"index"` + UserID int `json:"user_id" gorm:"index"` + OccurredAt int64 `json:"occurred_at" gorm:"bigint;index"` + DayStartUTC int64 `json:"day_start_utc" gorm:"bigint;index"` + EventType string `json:"event_type" gorm:"type:varchar(32)"` + CoverageState string `json:"coverage_state" gorm:"type:varchar(32)"` + SkipReason string `json:"skip_reason" gorm:"type:varchar(64)"` + CalculationMode string `json:"calculation_mode" gorm:"type:varchar(32)"` + OfficialQuota int64 `json:"official_quota"` + ActualQuota int64 `json:"actual_quota"` + SavingsQuota int64 `json:"savings_quota"` + SavingsCNYMicros int64 `json:"savings_cny_micros"` + QuotaPerUnitSnapshot int64 `json:"quota_per_unit_snapshot"` + USDCNYRateMicros int64 `json:"usd_cny_rate_micros"` + PriceSnapshotAt int64 `json:"price_snapshot_at" gorm:"bigint"` + PriceFingerprint string `json:"price_fingerprint" gorm:"type:varchar(80)"` + AggregateVersion int `json:"aggregate_version"` + AggregatedAt int64 `json:"aggregated_at" gorm:"bigint;index:idx_savings_events_pending,priority:1"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +type SavingsLifetimeDaily struct { + ID int64 `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"uniqueIndex:idx_savings_daily_user_day,priority:1"` + DayStartUTC int64 `json:"day_start_utc" gorm:"bigint;uniqueIndex:idx_savings_daily_user_day,priority:2"` + RequestCount int64 `json:"request_count"` + EstimatedRequestCount int64 `json:"estimated_request_count"` + SnapshotRequestCount int64 `json:"snapshot_request_count"` + ReconstructedRequestCount int64 `json:"reconstructed_request_count"` + OfficialQuota int64 `json:"official_quota"` + ActualQuota int64 `json:"actual_quota"` + SavingsQuota int64 `json:"savings_quota"` + SavingsCNYMicros int64 `json:"savings_cny_micros"` + FirstOccurredAt int64 `json:"first_occurred_at" gorm:"bigint"` + LastOccurredAt int64 `json:"last_occurred_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type SavingsLifetimeTotal struct { + UserID int `json:"user_id" gorm:"primaryKey"` + RequestCount int64 `json:"request_count"` + EstimatedRequestCount int64 `json:"estimated_request_count"` + SnapshotRequestCount int64 `json:"snapshot_request_count"` + ReconstructedRequestCount int64 `json:"reconstructed_request_count"` + OfficialQuota int64 `json:"official_quota"` + ActualQuota int64 `json:"actual_quota"` + SavingsQuota int64 `json:"savings_quota"` + SavingsCNYMicros int64 `json:"savings_cny_micros"` + StatisticsStartedAt int64 `json:"statistics_started_at" gorm:"bigint"` + LastAggregatedAt int64 `json:"last_aggregated_at" gorm:"bigint"` +} + +func (event *SavingsLifetimeEvent) BeforeCreate(_ *gorm.DB) error { + if event.CreatedAt == 0 { + event.CreatedAt = time.Now().Unix() + } + if event.AggregateVersion == 0 { + event.AggregateVersion = 1 + } + return nil +} + +func CreateSavingsLifetimeEvents(events []SavingsLifetimeEvent) error { + if len(events) == 0 { + return nil + } + return DB.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(&events, 500).Error +} + +func AggregatePendingSavingsLifetimeEvents(limit int) (int, error) { + if limit <= 0 { + limit = 1000 + } + processed := 0 + err := DB.Transaction(func(tx *gorm.DB) error { + var events []SavingsLifetimeEvent + if err := lockForUpdate(tx). + Where("aggregated_at = ?", 0). + Order("id asc"). + Limit(limit). + Find(&events).Error; err != nil { + return err + } + if len(events) == 0 { + return nil + } + + userIDs := make([]int, 0) + userSet := make(map[int]struct{}) + minDay, maxDay := events[0].DayStartUTC, events[0].DayStartUTC + for i := range events { + if _, ok := userSet[events[i].UserID]; !ok { + userSet[events[i].UserID] = struct{}{} + userIDs = append(userIDs, events[i].UserID) + } + if events[i].DayStartUTC < minDay { + minDay = events[i].DayStartUTC + } + if events[i].DayStartUTC > maxDay { + maxDay = events[i].DayStartUTC + } + } + + var existingDaily []SavingsLifetimeDaily + if err := lockForUpdate(tx). + Where("user_id IN ? AND day_start_utc >= ? AND day_start_utc <= ?", userIDs, minDay, maxDay). + Find(&existingDaily).Error; err != nil { + return err + } + var existingTotals []SavingsLifetimeTotal + if err := lockForUpdate(tx).Where("user_id IN ?", userIDs).Find(&existingTotals).Error; err != nil { + return err + } + + type dailyKey struct { + UserID int + Day int64 + } + dailyByKey := make(map[dailyKey]*SavingsLifetimeDaily, len(existingDaily)+len(events)) + for i := range existingDaily { + row := &existingDaily[i] + dailyByKey[dailyKey{UserID: row.UserID, Day: row.DayStartUTC}] = row + } + totalByUser := make(map[int]*SavingsLifetimeTotal, len(existingTotals)+len(userIDs)) + for i := range existingTotals { + row := &existingTotals[i] + totalByUser[row.UserID] = row + } + + now := time.Now().Unix() + for i := range events { + event := &events[i] + key := dailyKey{UserID: event.UserID, Day: event.DayStartUTC} + daily := dailyByKey[key] + if daily == nil { + daily = &SavingsLifetimeDaily{UserID: event.UserID, DayStartUTC: event.DayStartUTC} + dailyByKey[key] = daily + } + total := totalByUser[event.UserID] + if total == nil { + total = &SavingsLifetimeTotal{UserID: event.UserID} + totalByUser[event.UserID] = total + } + if err := addSavingsLifetimeEvent(daily, total, event, now); err != nil { + return err + } + } + + dailyRows := make([]SavingsLifetimeDaily, 0, len(dailyByKey)) + for _, row := range dailyByKey { + dailyRows = append(dailyRows, *row) + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "day_start_utc"}}, + UpdateAll: true, + }).Create(&dailyRows).Error; err != nil { + return err + } + + totalRows := make([]SavingsLifetimeTotal, 0, len(totalByUser)) + for _, row := range totalByUser { + totalRows = append(totalRows, *row) + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + UpdateAll: true, + }).Create(&totalRows).Error; err != nil { + return err + } + + eventIDs := make([]int64, 0, len(events)) + for i := range events { + eventIDs = append(eventIDs, events[i].ID) + } + result := tx.Model(&SavingsLifetimeEvent{}). + Where("id IN ? AND aggregated_at = ?", eventIDs, 0). + Update("aggregated_at", now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != int64(len(events)) { + return errors.New("savings lifetime event aggregation conflict") + } + processed = len(events) + return nil + }) + return processed, err +} + +func addSavingsLifetimeEvent(daily *SavingsLifetimeDaily, total *SavingsLifetimeTotal, event *SavingsLifetimeEvent, now int64) error { + if event.EventType == SavingsLifetimeEventTypeBase { + if !checkedAddInt64(&daily.RequestCount, 1) || !checkedAddInt64(&total.RequestCount, 1) { + return errors.New("savings lifetime request count overflow") + } + if daily.FirstOccurredAt == 0 || event.OccurredAt < daily.FirstOccurredAt { + daily.FirstOccurredAt = event.OccurredAt + } + if event.OccurredAt > daily.LastOccurredAt { + daily.LastOccurredAt = event.OccurredAt + } + if total.StatisticsStartedAt == 0 || event.OccurredAt < total.StatisticsStartedAt { + total.StatisticsStartedAt = event.OccurredAt + } + } + if event.CoverageState == SavingsLifetimeCoverageEstimated { + if event.EventType == SavingsLifetimeEventTypeBase { + if !checkedAddInt64(&daily.EstimatedRequestCount, 1) || !checkedAddInt64(&total.EstimatedRequestCount, 1) { + return errors.New("savings lifetime estimated count overflow") + } + if event.CalculationMode == SavingsLifetimeCalculationRebuild { + if !checkedAddInt64(&daily.ReconstructedRequestCount, 1) || !checkedAddInt64(&total.ReconstructedRequestCount, 1) { + return errors.New("savings lifetime reconstructed count overflow") + } + } else { + if !checkedAddInt64(&daily.SnapshotRequestCount, 1) || !checkedAddInt64(&total.SnapshotRequestCount, 1) { + return errors.New("savings lifetime snapshot count overflow") + } + } + } + if !checkedAddInt64(&daily.OfficialQuota, event.OfficialQuota) || + !checkedAddInt64(&daily.ActualQuota, event.ActualQuota) || + !checkedAddInt64(&daily.SavingsQuota, event.SavingsQuota) || + !checkedAddInt64(&daily.SavingsCNYMicros, event.SavingsCNYMicros) || + !checkedAddInt64(&total.OfficialQuota, event.OfficialQuota) || + !checkedAddInt64(&total.ActualQuota, event.ActualQuota) || + !checkedAddInt64(&total.SavingsQuota, event.SavingsQuota) || + !checkedAddInt64(&total.SavingsCNYMicros, event.SavingsCNYMicros) { + return errors.New("savings lifetime amount overflow") + } + } + daily.UpdatedAt = now + total.LastAggregatedAt = now + return nil +} + +func checkedAddInt64(target *int64, delta int64) bool { + if (delta > 0 && *target > math.MaxInt64-delta) || (delta < 0 && *target < math.MinInt64-delta) { + return false + } + *target += delta + return true +} + +func GetSavingsLifetimeTotal(userID int) (*SavingsLifetimeTotal, error) { + var total SavingsLifetimeTotal + err := DB.Where("user_id = ?", userID).First(&total).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &SavingsLifetimeTotal{UserID: userID}, nil + } + if err != nil { + return nil, err + } + return &total, nil +} + +func HasPendingSavingsLifetimeEvents() (bool, error) { + var event SavingsLifetimeEvent + result := DB.Select("id"). + Where("aggregated_at = ?", 0). + Order("id asc"). + Limit(1). + Find(&event) + return result.RowsAffected > 0, result.Error +} diff --git a/model/savings_lifetime_test.go b/model/savings_lifetime_test.go new file mode 100644 index 000000000000..2655f045b061 --- /dev/null +++ b/model/savings_lifetime_test.go @@ -0,0 +1,180 @@ +package model + +import ( + "context" + "math" + "testing" + + "github.com/QuantumNous/new-api/common" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupSavingsLifetimeTestDB(t *testing.T) { + t.Helper() + previousDB := DB + previousType := common.MainDatabaseType() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &SavingsLifetimeEvent{}, + &SavingsLifetimeDaily{}, + &SavingsLifetimeTotal{}, + )) + DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + t.Cleanup(func() { + DB = previousDB + common.SetMainDatabaseType(previousType) + }) +} + +func TestAggregatePendingSavingsLifetimeEventsIsIdempotent(t *testing.T) { + setupSavingsLifetimeTestDB(t) + dayOne := int64(1_785_081_600) + dayTwo := dayOne + 24*60*60 + events := []SavingsLifetimeEvent{ + { + EventKey: "log:1:base", + SourceKey: "db:1", + UserID: 7, + OccurredAt: dayOne + 10, + DayStartUTC: dayOne, + EventType: SavingsLifetimeEventTypeBase, + CoverageState: SavingsLifetimeCoverageEstimated, + CalculationMode: SavingsLifetimeCalculationSnapshot, + OfficialQuota: 300, + ActualQuota: 100, + SavingsQuota: 200, + SavingsCNYMicros: 2_000, + }, + { + EventKey: "log:2:base", + SourceKey: "db:2", + UserID: 7, + OccurredAt: dayOne + 20, + DayStartUTC: dayOne, + EventType: SavingsLifetimeEventTypeBase, + CoverageState: SavingsLifetimeCoverageSkipped, + SkipReason: "missing_official_price", + }, + { + EventKey: "log:3:base", + SourceKey: "db:3", + UserID: 7, + OccurredAt: dayTwo + 30, + DayStartUTC: dayTwo, + EventType: SavingsLifetimeEventTypeBase, + CoverageState: SavingsLifetimeCoverageEstimated, + CalculationMode: SavingsLifetimeCalculationRebuild, + OfficialQuota: 500, + ActualQuota: 250, + SavingsQuota: 250, + SavingsCNYMicros: 2_500, + }, + } + require.NoError(t, CreateSavingsLifetimeEvents(events)) + require.NoError(t, CreateSavingsLifetimeEvents(events[:1])) + + processed, err := AggregatePendingSavingsLifetimeEvents(100) + require.NoError(t, err) + assert.Equal(t, 3, processed) + + total, err := GetSavingsLifetimeTotal(7) + require.NoError(t, err) + assert.Equal(t, int64(3), total.RequestCount) + assert.Equal(t, int64(2), total.EstimatedRequestCount) + assert.Equal(t, int64(1), total.SnapshotRequestCount) + assert.Equal(t, int64(1), total.ReconstructedRequestCount) + assert.Equal(t, int64(800), total.OfficialQuota) + assert.Equal(t, int64(350), total.ActualQuota) + assert.Equal(t, int64(450), total.SavingsQuota) + assert.Equal(t, int64(4_500), total.SavingsCNYMicros) + assert.Equal(t, dayOne+10, total.StatisticsStartedAt) + + var daily []SavingsLifetimeDaily + require.NoError(t, DB.Order("day_start_utc asc").Find(&daily).Error) + require.Len(t, daily, 2) + assert.Equal(t, int64(2), daily[0].RequestCount) + assert.Equal(t, int64(1), daily[0].EstimatedRequestCount) + assert.Equal(t, int64(200), daily[0].SavingsQuota) + assert.Equal(t, int64(1), daily[1].RequestCount) + assert.Equal(t, int64(250), daily[1].SavingsQuota) + + processed, err = AggregatePendingSavingsLifetimeEvents(100) + require.NoError(t, err) + assert.Zero(t, processed) +} + +func TestAggregatePendingSavingsLifetimeEventsRollsBackOnOverflow(t *testing.T) { + setupSavingsLifetimeTestDB(t) + require.NoError(t, DB.Create(&SavingsLifetimeTotal{ + UserID: 9, + SavingsQuota: math.MaxInt64, + OfficialQuota: math.MaxInt64, + }).Error) + require.NoError(t, CreateSavingsLifetimeEvents([]SavingsLifetimeEvent{{ + EventKey: "log:overflow:base", + SourceKey: "db:overflow", + UserID: 9, + OccurredAt: 1_785_081_610, + DayStartUTC: 1_785_081_600, + EventType: SavingsLifetimeEventTypeBase, + CoverageState: SavingsLifetimeCoverageEstimated, + OfficialQuota: 1, + SavingsQuota: 1, + }})) + + processed, err := AggregatePendingSavingsLifetimeEvents(100) + require.Error(t, err) + assert.Zero(t, processed) + + var event SavingsLifetimeEvent + require.NoError(t, DB.Where("event_key = ?", "log:overflow:base").First(&event).Error) + assert.Zero(t, event.AggregatedAt) + total, err := GetSavingsLifetimeTotal(9) + require.NoError(t, err) + assert.Equal(t, int64(math.MaxInt64), total.SavingsQuota) + + var dailyCount int64 + require.NoError(t, DB.Model(&SavingsLifetimeDaily{}).Count(&dailyCount).Error) + assert.Zero(t, dailyCount) +} + +func TestHasPendingSavingsLifetimeEventsReportsExistence(t *testing.T) { + setupSavingsLifetimeTestDB(t) + + hasPending, err := HasPendingSavingsLifetimeEvents() + require.NoError(t, err) + assert.False(t, hasPending) + + require.NoError(t, CreateSavingsLifetimeEvents([]SavingsLifetimeEvent{{ + EventKey: "log:pending:base", + }})) + hasPending, err = HasPendingSavingsLifetimeEvents() + require.NoError(t, err) + assert.True(t, hasPending) + + _, err = AggregatePendingSavingsLifetimeEvents(1) + require.NoError(t, err) + hasPending, err = HasPendingSavingsLifetimeEvents() + require.NoError(t, err) + assert.False(t, hasPending) +} + +func TestCheckSavingsLifetimeSQLiteIntegrityAcceptsHealthyDatabase(t *testing.T) { + setupSavingsLifetimeTestDB(t) + previousLogDB := LOG_DB + previousLogType := common.LogDatabaseType() + LOG_DB = DB + common.SetLogDatabaseType(common.DatabaseTypeSQLite) + t.Cleanup(func() { + LOG_DB = previousLogDB + common.SetLogDatabaseType(previousLogType) + }) + + require.NoError(t, CheckSavingsLifetimeSQLiteIntegrity(context.Background())) +} diff --git a/model/savings_log.go b/model/savings_log.go new file mode 100644 index 000000000000..ab3b45c55cd4 --- /dev/null +++ b/model/savings_log.go @@ -0,0 +1,144 @@ +package model + +import "github.com/QuantumNous/new-api/common" + +type SavingsLogRow struct { + Id int + CreatedAt int64 + ModelName string + PromptTokens int + CompletionTokens int + Quota int + Other string +} + +type SavingsLifetimeLogRow struct { + Id int + RequestId string + UserId int + CreatedAt int64 + ModelName string + PromptTokens int + CompletionTokens int + Quota int + Other string +} + +type SavingsLifetimeLogCursor struct { + ID int + CreatedAt int64 + RequestID string +} + +func GetSavingsLifetimeLogBoundary() (SavingsLifetimeLogCursor, int64, error) { + var total int64 + if err := LOG_DB.Model(&Log{}).Where("type = ?", LogTypeConsume).Count(&total).Error; err != nil { + return SavingsLifetimeLogCursor{}, 0, err + } + if total == 0 { + return SavingsLifetimeLogCursor{}, 0, nil + } + var row SavingsLifetimeLogRow + query := LOG_DB.Model(&Log{}). + Select("id", "created_at", "request_id"). + Where("type = ?", LogTypeConsume) + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + query = query.Order("created_at desc, request_id desc") + } else { + query = query.Order("id desc") + } + if err := query.First(&row).Error; err != nil { + return SavingsLifetimeLogCursor{}, 0, err + } + return SavingsLifetimeLogCursor{ID: row.Id, CreatedAt: row.CreatedAt, RequestID: row.RequestId}, total, nil +} + +func GetSavingsLifetimeLogBatch(cursor SavingsLifetimeLogCursor, target SavingsLifetimeLogCursor, limit int) ([]SavingsLifetimeLogRow, error) { + if limit <= 0 { + return []SavingsLifetimeLogRow{}, nil + } + rows := make([]SavingsLifetimeLogRow, 0, limit) + query := LOG_DB.Model(&Log{}). + Select("id", "request_id", "user_id", "created_at", "model_name", "prompt_tokens", "completion_tokens", "quota", "other"). + Where("type = ?", LogTypeConsume) + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + query = query. + Where("(created_at > ? OR (created_at = ? AND request_id > ?))", cursor.CreatedAt, cursor.CreatedAt, cursor.RequestID). + Where("(created_at < ? OR (created_at = ? AND request_id <= ?))", target.CreatedAt, target.CreatedAt, target.RequestID). + Order("created_at asc, request_id asc") + } else { + query = query.Where("id > ? AND id <= ?", cursor.ID, target.ID).Order("id asc") + } + if err := query.Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func GetRecentSavingsLifetimeLogBatch(startTimestamp int64, cursor SavingsLifetimeLogCursor, target SavingsLifetimeLogCursor, limit int) ([]SavingsLifetimeLogRow, error) { + if limit <= 0 { + return []SavingsLifetimeLogRow{}, nil + } + rows := make([]SavingsLifetimeLogRow, 0, limit) + query := LOG_DB.Model(&Log{}). + Select("id", "request_id", "user_id", "created_at", "model_name", "prompt_tokens", "completion_tokens", "quota", "other"). + Where("type = ? AND created_at >= ?", LogTypeConsume, startTimestamp) + if common.UsingLogDatabase(common.DatabaseTypeClickHouse) { + query = query. + Where("(created_at > ? OR (created_at = ? AND request_id > ?))", cursor.CreatedAt, cursor.CreatedAt, cursor.RequestID). + Where("(created_at < ? OR (created_at = ? AND request_id <= ?))", target.CreatedAt, target.CreatedAt, target.RequestID). + Order("created_at asc, request_id asc") + } else { + query = query.Where("id > ? AND id <= ?", cursor.ID, target.ID).Order("id asc") + } + if err := query.Limit(limit).Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func CountSavingsLifetimeClickHouseCursorAmbiguity(rows []SavingsLifetimeLogRow) (int64, error) { + if !common.UsingLogDatabase(common.DatabaseTypeClickHouse) || len(rows) == 0 { + return 0, nil + } + last := rows[len(rows)-1] + included := int64(0) + for i := len(rows) - 1; i >= 0; i-- { + if rows[i].CreatedAt != last.CreatedAt || rows[i].RequestId != last.RequestId { + break + } + included++ + } + var total int64 + if err := LOG_DB.Model(&Log{}). + Where("type = ? AND created_at = ? AND request_id = ?", LogTypeConsume, last.CreatedAt, last.RequestId). + Count(&total).Error; err != nil { + return 0, err + } + if total <= included { + return 0, nil + } + return total - included, nil +} + +func CountUserSavingsConsumeLogs(userId int, startTimestamp int64, endTimestamp int64) (int64, error) { + var total int64 + err := LOG_DB.Model(&Log{}). + Where("user_id = ? AND type = ? AND created_at >= ? AND created_at < ?", userId, LogTypeConsume, startTimestamp, endTimestamp). + Count(&total).Error + return total, err +} + +func GetUserSavingsConsumeLogs(userId int, startTimestamp int64, endTimestamp int64, limit int) ([]SavingsLogRow, error) { + rows := make([]SavingsLogRow, 0) + if limit <= 0 { + return rows, nil + } + err := LOG_DB.Model(&Log{}). + Select("id", "created_at", "model_name", "prompt_tokens", "completion_tokens", "quota", "other"). + Where("user_id = ? AND type = ? AND created_at >= ? AND created_at < ?", userId, LogTypeConsume, startTimestamp, endTimestamp). + Order("created_at asc, id asc"). + Limit(limit). + Find(&rows).Error + return rows, err +} diff --git a/model/savings_log_test.go b/model/savings_log_test.go new file mode 100644 index 000000000000..28ef64bdabe5 --- /dev/null +++ b/model/savings_log_test.go @@ -0,0 +1,127 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupSavingsLogTestDB(t *testing.T) { + t.Helper() + previousDB := LOG_DB + previousType := common.LogDatabaseType() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Log{})) + LOG_DB = db + common.SetLogDatabaseType(common.DatabaseTypeSQLite) + t.Cleanup(func() { + LOG_DB = previousDB + common.SetLogDatabaseType(previousType) + }) +} + +func TestGetSavingsLifetimeLogBatchUsesRelationalIDKeyset(t *testing.T) { + setupSavingsLogTestDB(t) + logs := []Log{ + {Id: 1, Type: LogTypeConsume, RequestId: "req-1", CreatedAt: 100}, + {Id: 2, Type: LogTypeManage, RequestId: "req-ignore", CreatedAt: 101}, + {Id: 3, Type: LogTypeConsume, RequestId: "req-3", CreatedAt: 102}, + {Id: 4, Type: LogTypeConsume, RequestId: "req-4", CreatedAt: 103}, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + + target, total, err := GetSavingsLifetimeLogBoundary() + require.NoError(t, err) + assert.Equal(t, int64(3), total) + assert.Equal(t, 4, target.ID) + + first, err := GetSavingsLifetimeLogBatch(SavingsLifetimeLogCursor{}, target, 2) + require.NoError(t, err) + require.Len(t, first, 2) + assert.Equal(t, []int{1, 3}, []int{first[0].Id, first[1].Id}) + second, err := GetSavingsLifetimeLogBatch( + SavingsLifetimeLogCursor{ID: first[1].Id}, + target, + 2, + ) + require.NoError(t, err) + require.Len(t, second, 1) + assert.Equal(t, 4, second[0].Id) +} + +func TestGetSavingsLifetimeLogBatchUsesClickHouseCompositeKeyset(t *testing.T) { + // SQLite exercises the keyset predicate shape, not ClickHouse SQL compatibility. + setupSavingsLogTestDB(t) + common.SetLogDatabaseType(common.DatabaseTypeClickHouse) + logs := []Log{ + {Id: 1, Type: LogTypeConsume, RequestId: "req-a", CreatedAt: 100}, + {Id: 2, Type: LogTypeConsume, RequestId: "req-b", CreatedAt: 100}, + {Id: 3, Type: LogTypeConsume, RequestId: "req-c", CreatedAt: 100}, + {Id: 4, Type: LogTypeConsume, RequestId: "req-a", CreatedAt: 101}, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + target := SavingsLifetimeLogCursor{CreatedAt: 101, RequestID: "req-a"} + + first, err := GetSavingsLifetimeLogBatch(SavingsLifetimeLogCursor{}, target, 2) + require.NoError(t, err) + require.Len(t, first, 2) + assert.Equal(t, []string{"req-a", "req-b"}, []string{first[0].RequestId, first[1].RequestId}) + second, err := GetSavingsLifetimeLogBatch( + SavingsLifetimeLogCursor{CreatedAt: first[1].CreatedAt, RequestID: first[1].RequestId}, + target, + 3, + ) + require.NoError(t, err) + require.Len(t, second, 2) + assert.Equal(t, []string{"req-c", "req-a"}, []string{second[0].RequestId, second[1].RequestId}) +} + +func TestCountSavingsLifetimeClickHouseCursorAmbiguityReportsSplitTie(t *testing.T) { + setupSavingsLogTestDB(t) + common.SetLogDatabaseType(common.DatabaseTypeClickHouse) + logs := []Log{ + {Id: 1, Type: LogTypeConsume, RequestId: "same", CreatedAt: 100}, + {Id: 2, Type: LogTypeConsume, RequestId: "same", CreatedAt: 100}, + {Id: 3, Type: LogTypeConsume, RequestId: "same", CreatedAt: 100}, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + rows, err := GetSavingsLifetimeLogBatch( + SavingsLifetimeLogCursor{}, + SavingsLifetimeLogCursor{CreatedAt: 100, RequestID: "same"}, + 2, + ) + require.NoError(t, err) + + ambiguous, err := CountSavingsLifetimeClickHouseCursorAmbiguity(rows) + + require.NoError(t, err) + assert.Equal(t, int64(1), ambiguous) +} + +func TestGetRecentSavingsLifetimeLogBatchAppliesLookbackAndCursor(t *testing.T) { + setupSavingsLogTestDB(t) + logs := []Log{ + {Id: 1, Type: LogTypeConsume, RequestId: "old", CreatedAt: 99}, + {Id: 2, Type: LogTypeConsume, RequestId: "first", CreatedAt: 100}, + {Id: 3, Type: LogTypeConsume, RequestId: "second", CreatedAt: 101}, + {Id: 4, Type: LogTypeConsume, RequestId: "beyond-target", CreatedAt: 102}, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + + rows, err := GetRecentSavingsLifetimeLogBatch( + 100, + SavingsLifetimeLogCursor{ID: 2}, + SavingsLifetimeLogCursor{ID: 3}, + 10, + ) + + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "second", rows[0].RequestId) +} diff --git a/model/system_task.go b/model/system_task.go index c811409b487d..939ec623f1f6 100644 --- a/model/system_task.go +++ b/model/system_task.go @@ -11,16 +11,19 @@ import ( type SystemTaskStatus string const ( - SystemTaskStatusPending SystemTaskStatus = "pending" - SystemTaskStatusRunning SystemTaskStatus = "running" - SystemTaskStatusSucceeded SystemTaskStatus = "succeeded" - SystemTaskStatusFailed SystemTaskStatus = "failed" - - SystemTaskTypeLogCleanup = "log_cleanup" - SystemTaskTypeChannelTest = "channel_test" - SystemTaskTypeModelUpdate = "model_update" - SystemTaskTypeMidjourneyPoll = "midjourney_poll" - SystemTaskTypeAsyncTaskPoll = "async_task_poll" + SystemTaskStatusPending SystemTaskStatus = "pending" + SystemTaskStatusRunning SystemTaskStatus = "running" + SystemTaskStatusPauseRequested SystemTaskStatus = "pause_requested" + SystemTaskStatusPaused SystemTaskStatus = "paused" + SystemTaskStatusSucceeded SystemTaskStatus = "succeeded" + SystemTaskStatusFailed SystemTaskStatus = "failed" + + SystemTaskTypeLogCleanup = "log_cleanup" + SystemTaskTypeChannelTest = "channel_test" + SystemTaskTypeModelUpdate = "model_update" + SystemTaskTypeMidjourneyPoll = "midjourney_poll" + SystemTaskTypeAsyncTaskPoll = "async_task_poll" + SystemTaskTypeSavingsBackfill = "savings_lifetime_backfill" ) var ErrSystemTaskLockLost = errors.New("system task lock lost") @@ -313,7 +316,7 @@ func UpdateSystemTaskState(taskID string, lockedBy string, state any) error { } now := common.GetTimestamp() result := DB.Model(&SystemTask{}). - Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy). + Where("task_id = ? AND status IN ? AND locked_by = ?", taskID, []SystemTaskStatus{SystemTaskStatusRunning, SystemTaskStatusPauseRequested}, lockedBy). Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now). Updates(map[string]any{ "state": stateText, @@ -347,7 +350,7 @@ func RenewSystemTaskLock(taskID string, lockedBy string, lockUntil int64) error func MarkSystemTaskLeaseExpired(taskID string) error { result := DB.Model(&SystemTask{}). - Where("task_id = ? AND status = ?", taskID, SystemTaskStatusRunning). + Where("task_id = ? AND status IN ?", taskID, []SystemTaskStatus{SystemTaskStatusRunning, SystemTaskStatusPauseRequested}). Updates(map[string]any{ "status": SystemTaskStatusFailed, "active_key": nil, @@ -387,7 +390,7 @@ func FinishSystemTask(taskID string, lockedBy string, status SystemTaskStatus, r } now := common.GetTimestamp() result := DB.Model(&SystemTask{}). - Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusRunning, lockedBy). + Where("task_id = ? AND status IN ? AND locked_by = ?", taskID, []SystemTaskStatus{SystemTaskStatusRunning, SystemTaskStatusPauseRequested}, lockedBy). Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now). Updates(map[string]any{ "status": status, @@ -431,7 +434,110 @@ func (task *SystemTask) ToResponse() SystemTaskResponse { } func activeSystemTaskStatuses() []string { - return []string{string(SystemTaskStatusPending), string(SystemTaskStatusRunning)} + return []string{ + string(SystemTaskStatusPending), + string(SystemTaskStatusRunning), + string(SystemTaskStatusPauseRequested), + string(SystemTaskStatusPaused), + } +} + +func RequestSystemTaskPause(taskID string, taskType string) (*SystemTask, error) { + var task SystemTask + if err := DB.Where("task_id = ? AND type = ?", taskID, taskType).First(&task).Error; err != nil { + return nil, err + } + if task.Status == SystemTaskStatusPaused || task.Status == SystemTaskStatusPauseRequested { + return &task, nil + } + if task.Status != SystemTaskStatusPending && task.Status != SystemTaskStatusRunning { + return nil, errors.New("system task cannot be paused in its current state") + } + nextStatus := SystemTaskStatusPaused + if task.Status == SystemTaskStatusRunning { + nextStatus = SystemTaskStatusPauseRequested + } + result := DB.Model(&SystemTask{}). + Where("task_id = ? AND type = ? AND status = ?", taskID, taskType, task.Status). + Updates(map[string]any{ + "status": nextStatus, + "updated_at": common.GetTimestamp(), + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrSystemTaskLockLost + } + if err := DB.Where("task_id = ?", taskID).First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +func CompleteSystemTaskPause(taskID string, lockedBy string) error { + now := common.GetTimestamp() + result := DB.Model(&SystemTask{}). + Where("task_id = ? AND status = ? AND locked_by = ?", taskID, SystemTaskStatusPauseRequested, lockedBy). + Where("EXISTS (SELECT 1 FROM system_task_locks WHERE system_task_locks.task_id = system_tasks.task_id AND system_task_locks.locked_by = ? AND system_task_locks.locked_until >= ?)", lockedBy, now). + Updates(map[string]any{ + "status": SystemTaskStatusPaused, + "locked_by": "", + "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSystemTaskLockLost + } + return ReleaseSystemTaskLock(taskID, lockedBy) +} + +func ResumeSystemTask(taskID string, taskType string) (*SystemTask, error) { + result := DB.Model(&SystemTask{}). + Where("task_id = ? AND type = ? AND status = ?", taskID, taskType, SystemTaskStatusPaused). + Updates(map[string]any{ + "status": SystemTaskStatusPending, + "locked_by": "", + "updated_at": common.GetTimestamp(), + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, errors.New("system task is not paused") + } + var task SystemTask + if err := DB.Where("task_id = ?", taskID).First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +func RetryFailedSystemTask(taskID string, taskType string) (*SystemTask, error) { + activeKey := taskType + result := DB.Model(&SystemTask{}). + Where("task_id = ? AND type = ? AND status = ?", taskID, taskType, SystemTaskStatusFailed). + Updates(map[string]any{ + "status": SystemTaskStatusPending, + "active_key": &activeKey, + "result": "", + "error": "", + "locked_by": "", + "updated_at": common.GetTimestamp(), + }) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, errors.New("system task is not failed") + } + var task SystemTask + if err := DB.Where("task_id = ?", taskID).First(&task).Error; err != nil { + return nil, err + } + return &task, nil } func marshalSystemTaskJSON(v any) (string, error) { diff --git a/model/system_task_test.go b/model/system_task_test.go index ac5678f74b1a..80e1ad5b1acf 100644 --- a/model/system_task_test.go +++ b/model/system_task_test.go @@ -74,6 +74,81 @@ func TestSystemTaskCreateAndActiveLifecycle(t *testing.T) { require.NoError(t, err) } +func TestSystemTaskPauseAndResumePreservesStateAndActiveKey(t *testing.T) { + truncateTables(t) + task, err := CreateSystemTask(SystemTaskTypeSavingsBackfill, testSystemTaskPayload{}, testSystemTaskState{}) + require.NoError(t, err) + claimed, ok, err := ClaimSystemTask(task.ID, task.Type, "runner-pause", common.GetTimestamp()+60) + require.NoError(t, err) + require.True(t, ok) + + requested, err := RequestSystemTaskPause(task.TaskID, task.Type) + require.NoError(t, err) + assert.Equal(t, SystemTaskStatusPauseRequested, requested.Status) + state := testSystemTaskState{Processed: 25, Progress: 50} + require.NoError(t, UpdateSystemTaskState(claimed.TaskID, "runner-pause", state)) + require.NoError(t, CompleteSystemTaskPause(claimed.TaskID, "runner-pause")) + + paused, err := GetSystemTaskByTaskID(task.TaskID) + require.NoError(t, err) + require.NotNil(t, paused) + assert.Equal(t, SystemTaskStatusPaused, paused.Status) + require.NotNil(t, paused.ActiveKey) + assert.Empty(t, paused.LockedBy) + var savedState testSystemTaskState + require.NoError(t, paused.DecodeState(&savedState)) + assert.Equal(t, state, savedState) + + resumed, err := ResumeSystemTask(task.TaskID, task.Type) + require.NoError(t, err) + assert.Equal(t, SystemTaskStatusPending, resumed.Status) + claimedAgain, ok, err := ClaimSystemTask(task.ID, task.Type, "runner-resume", common.GetTimestamp()+60) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, FinishSystemTask(claimedAgain.TaskID, "runner-resume", SystemTaskStatusSucceeded, nil, "")) +} + +func TestSystemTaskCanPauseBeforeClaim(t *testing.T) { + truncateTables(t) + task, err := CreateSystemTask(SystemTaskTypeSavingsBackfill, nil, nil) + require.NoError(t, err) + + paused, err := RequestSystemTaskPause(task.TaskID, task.Type) + require.NoError(t, err) + assert.Equal(t, SystemTaskStatusPaused, paused.Status) + _, ok, err := ClaimSystemTask(task.ID, task.Type, "runner", common.GetTimestamp()+60) + require.NoError(t, err) + assert.False(t, ok) + + _, err = ResumeSystemTask(task.TaskID, task.Type) + require.NoError(t, err) +} + +func TestRetryFailedSystemTaskPreservesPayloadAndState(t *testing.T) { + truncateTables(t) + payload := testSystemTaskPayload{TargetTimestamp: 123, BatchSize: 500} + state := testSystemTaskState{Processed: 50, Progress: 25} + task, err := CreateSystemTask(SystemTaskTypeSavingsBackfill, payload, state) + require.NoError(t, err) + claimed, ok, err := ClaimSystemTask(task.ID, task.Type, "runner-failed", common.GetTimestamp()+60) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, FinishSystemTask(claimed.TaskID, "runner-failed", SystemTaskStatusFailed, nil, "database error")) + + retried, err := RetryFailedSystemTask(task.TaskID, task.Type) + + require.NoError(t, err) + assert.Equal(t, SystemTaskStatusPending, retried.Status) + require.NotNil(t, retried.ActiveKey) + assert.Empty(t, retried.Error) + var decodedPayload testSystemTaskPayload + var decodedState testSystemTaskState + require.NoError(t, retried.DecodePayload(&decodedPayload)) + require.NoError(t, retried.DecodeState(&decodedState)) + assert.Equal(t, payload, decodedPayload) + assert.Equal(t, state, decodedState) +} + func TestSystemTaskActiveKeyPreventsDuplicateActiveRun(t *testing.T) { truncateTables(t) diff --git a/router/api-router.go b/router/api-router.go index 907cf1ed2885..03b1840e2a02 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -88,6 +88,9 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/self/groups", controller.GetUserGroups) selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/models", controller.GetUserModels) + selfRoute.GET("/savings/summary", middleware.SearchRateLimit(), controller.GetUserSavingsSummary) + selfRoute.GET("/savings/trend", middleware.SearchRateLimit(), controller.GetUserSavingsTrend) + selfRoute.GET("/savings/lifetime", middleware.SearchRateLimit(), controller.GetUserSavingsLifetime) selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", middleware.DisableCache(), controller.GenerateAccessToken) @@ -282,6 +285,11 @@ func SetApiRouter(router *gin.Engine) { systemTaskRoute.Use(middleware.RootAuth()) { systemTaskRoute.POST("/log-cleanup", controller.CreateLogCleanupSystemTask) + systemTaskRoute.POST("/savings-lifetime-backfill", controller.CreateSavingsLifetimeBackfillTask) + systemTaskRoute.GET("/savings-lifetime-backfill", controller.GetSavingsLifetimeBackfillTask) + systemTaskRoute.POST("/savings-lifetime-backfill/pause", controller.PauseSavingsLifetimeBackfillTask) + systemTaskRoute.POST("/savings-lifetime-backfill/resume", controller.ResumeSavingsLifetimeBackfillTask) + systemTaskRoute.POST("/savings-lifetime-backfill/retry", controller.RetrySavingsLifetimeBackfillTask) systemTaskRoute.GET("/list", controller.ListSystemTasks) systemTaskRoute.GET("/current", controller.GetCurrentSystemTask) systemTaskRoute.GET("/:task_id", controller.GetSystemTask) diff --git a/service/savings_estimate.go b/service/savings_estimate.go new file mode 100644 index 000000000000..f5df083f769a --- /dev/null +++ b/service/savings_estimate.go @@ -0,0 +1,1417 @@ +package service + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/pkg/billingexpr" + "github.com/QuantumNous/new-api/pkg/cachex" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/billing_setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/setting/savings_setting" + + "github.com/gin-gonic/gin" + "github.com/samber/hot" + "github.com/shopspring/decimal" + "golang.org/x/sync/singleflight" +) + +const ( + savingsEstimateSchemaVersion = 1 + savingsTextCalculator = "text_token_v1" + savingsHistoricalCalculator = "historical_text_rebuild_v1" + savingsCalculationSnapshot = "snapshot" + savingsCalculationHistorical = "historical_rebuild" + savingsSourceLocalPricing = "local_pricing_snapshot" + savingsSourceOfficialOverride = "official_override" + savingsSourceMixed = "mixed" + + SavingsSkipDisabled = "disabled" + SavingsSkipMissingOfficialPrice = "missing_official_price" + SavingsSkipUnconfirmedOfficialPrice = "unconfirmed_official_price" + SavingsSkipMissingUsage = "missing_usage" + SavingsSkipUnsupportedBillingMode = "unsupported_billing_mode" + SavingsSkipUnknownExtraRatio = "unknown_extra_ratio" + SavingsSkipQuotaSaturated = "quota_saturated" + SavingsSkipInvalidSnapshot = "invalid_snapshot" + SavingsSkipLegacyMissingBaseFields = "legacy_log_missing_base_fields" + SavingsSkipLegacyInvalidSnapshot = "legacy_log_invalid_snapshot" + SavingsSkipLegacyActualQuotaMismatch = "legacy_actual_quota_mismatch" + + SavingsTrendGranularityHour = "hour" + SavingsTrendGranularityDay = "day" + savingsTrendMaxBuckets = 64 + savingsTrendMaxHourSeconds = 48 * 3600 + savingsResultCacheTTL = time.Minute + savingsResultCacheCapacity = 5000 + savingsSummaryCacheKind = "summary" + savingsTrendCacheKind = "trend" + savingsSummaryCacheNS = "new-api:user_savings_summary:v1" + savingsTrendCacheNS = "new-api:user_savings_trend:v1" +) + +var ( + ErrSavingsTimeRangeRequired = errors.New("savings time range is required") + ErrSavingsUTCOffsetRequired = errors.New("savings UTC offset is required") + ErrSavingsUTCOffsetInvalid = errors.New("savings UTC offset is invalid") + ErrSavingsEndAfterNow = errors.New("savings end time is after current time") + ErrSavingsTimeRangeInvalid = errors.New("savings time range is invalid") + ErrSavingsTimeRangeTooLarge = errors.New("savings time range is too large") + ErrSavingsHourRangeTooLarge = errors.New("hourly savings time range is too large") + ErrSavingsGranularity = errors.New("savings trend granularity is invalid") + ErrSavingsTooManyBuckets = errors.New("savings trend has too many buckets") + + savingsSummaryCacheOnce sync.Once + savingsSummaryCache *cachex.HybridCache[SavingsSummary] + savingsSummaryGroup singleflight.Group + savingsTrendCacheOnce sync.Once + savingsTrendCache *cachex.HybridCache[SavingsTrend] + savingsTrendGroup singleflight.Group +) + +type SavingsEstimate struct { + SchemaVersion int `json:"schema_version"` + Calculator string `json:"calculator"` + OfficialQuota int `json:"official_quota"` + ActualQuota int `json:"actual_quota"` + SavingsQuota int `json:"savings_quota"` + Source string `json:"source"` + SourceURL string `json:"source_url,omitempty"` + SourceUpdatedAt int64 `json:"source_updated_at"` + PriceSnapshotAt int64 `json:"price_snapshot_at,omitempty"` + PriceFingerprint string `json:"price_fingerprint,omitempty"` + OfficialConfirmed bool `json:"official_confirmed"` + MatchedModel string `json:"matched_model"` + PricingMode string `json:"pricing_mode"` + CalculationMode string `json:"calculation_mode,omitempty"` + Estimated bool `json:"estimated"` + AggregationKey string `json:"savings_aggregation_key,omitempty"` + QuotaPerUnit int64 `json:"quota_per_unit_snapshot,omitempty"` + USDCNYRateMicros int64 `json:"usd_cny_rate_micros,omitempty"` + SavingsCNYMicros string `json:"savings_cny_micros,omitempty"` +} + +type SavingsEstimateResult struct { + Estimate *SavingsEstimate + SkipReason string +} + +type SavingsSummary struct { + Enabled bool `json:"enabled"` + SavingsQuota int64 `json:"savings_quota"` + OfficialQuota int64 `json:"official_quota"` + ActualQuota int64 `json:"actual_quota"` + RequestCount int64 `json:"request_count"` + EstimatedRequestCount int64 `json:"estimated_request_count"` + SnapshotRequestCount int64 `json:"snapshot_request_count"` + ReconstructedRequestCount int64 `json:"reconstructed_request_count"` + CoverageRatio float64 `json:"coverage_ratio"` + Source string `json:"source"` + OfficialConfirmed bool `json:"official_confirmed"` + SourceUpdatedAt int64 `json:"source_updated_at"` + RebuildPriceSnapshotAt int64 `json:"rebuild_price_snapshot_at"` + OfficialPriceStale bool `json:"official_price_stale"` + IsPartial bool `json:"is_partial"` + WindowDays int `json:"window_days"` +} + +type SavingsTrendBucket struct { + StartTimestamp int64 `json:"start_timestamp"` + EndTimestamp int64 `json:"end_timestamp"` + OfficialQuota int64 `json:"official_quota"` + ActualQuota int64 `json:"actual_quota"` + SavingsQuota int64 `json:"savings_quota"` + RequestCount int64 `json:"request_count"` + EstimatedRequestCount int64 `json:"estimated_request_count"` + SnapshotRequestCount int64 `json:"snapshot_request_count"` + ReconstructedRequestCount int64 `json:"reconstructed_request_count"` + CoverageRatio float64 `json:"coverage_ratio"` +} + +type SavingsTrend struct { + Granularity string `json:"granularity"` + UTCOffsetMinutes int `json:"utc_offset_minutes"` + StartTimestamp int64 `json:"start_timestamp"` + EndTimestamp int64 `json:"end_timestamp"` + Summary SavingsSummary `json:"summary"` + Buckets []SavingsTrendBucket `json:"buckets"` +} + +type savingsLogEstimate struct { + Estimate *SavingsEstimate + CalculationMode string + SkipReason string +} + +type savingsLogEstimator struct { + setting savings_setting.Setting + localPrices map[string]savings_setting.OfficialPrice + priceSnapshotAt int64 +} + +type savingsSummaryAccumulator struct { + summary *SavingsSummary + priceSnapshotAt int64 + staleBefore int64 + sources map[string]struct{} + allConfirmed bool +} + +type savingsLogOther struct { + SavingsEstimate json.RawMessage `json:"savings_estimate"` + AggregationKey string `json:"savings_aggregation_key"` + AdminInfo struct { + SavingsSkipReason string `json:"savings_skip_reason"` + } `json:"admin_info"` +} + +type legacySavingsLogOther struct { + ModelRatio *float64 `json:"model_ratio"` + GroupRatio *float64 `json:"group_ratio"` + CompletionRatio *float64 `json:"completion_ratio"` + ModelPrice *float64 `json:"model_price"` + CacheTokens *int `json:"cache_tokens"` + CacheRatio *float64 `json:"cache_ratio"` + CacheCreationTokens int `json:"cache_creation_tokens"` + CacheCreationRatio *float64 `json:"cache_creation_ratio"` + CacheCreationTokens5m int `json:"cache_creation_tokens_5m"` + CacheCreationRatio5m *float64 `json:"cache_creation_ratio_5m"` + CacheCreationTokens1h int `json:"cache_creation_tokens_1h"` + CacheCreationRatio1h *float64 `json:"cache_creation_ratio_1h"` + Image bool `json:"image"` + ImageOutput *int `json:"image_output"` + ImageRatio *float64 `json:"image_ratio"` + UsageSemantic string `json:"usage_semantic"` + Claude bool `json:"claude"` + BillingMode string `json:"billing_mode"` + Audio bool `json:"audio"` + WSS bool `json:"ws"` + WebSearch bool `json:"web_search"` + FileSearch bool `json:"file_search"` + AudioInputSeparatePrice bool `json:"audio_input_seperate_price"` + ImageGenerationCall bool `json:"image_generation_call"` + ExprB64 string `json:"expr_b64"` +} + +type savingsPriceFingerprint struct { + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio *float64 `json:"model_ratio"` + ModelPrice *float64 `json:"model_price"` + CompletionRatio *float64 `json:"completion_ratio"` + CacheRatio *float64 `json:"cache_ratio"` + CreateCacheRatio *float64 `json:"create_cache_ratio"` + CacheCreation5mRatio *float64 `json:"cache_creation_ratio_5m"` + CacheCreation1hRatio *float64 `json:"cache_creation_ratio_1h"` + ImageRatio *float64 `json:"image_ratio"` + AudioRatio *float64 `json:"audio_ratio"` + AudioCompletionRatio *float64 `json:"audio_completion_ratio"` + BillingMode string `json:"billing_mode"` + BillingExpr string `json:"billing_expr"` +} + +func AttachTextSavingsEstimate(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, summary textQuotaSummary, other map[string]interface{}) { + if other == nil { + return + } + aggregationKey := "" + if savings_setting.LifetimeEnabled() { + aggregationKey = "savg_" + common.GetUUID() + other["savings_aggregation_key"] = aggregationKey + } + result := buildTextSavingsEstimate(ctx, relayInfo, summary) + if result.Estimate == nil { + if aggregationKey != "" { + adminInfo, ok := other["admin_info"].(map[string]interface{}) + if !ok || adminInfo == nil { + adminInfo = map[string]interface{}{} + other["admin_info"] = adminInfo + } + adminInfo["savings_skip_reason"] = result.SkipReason + } + return + } + attachSavingsLifetimeSnapshot(result.Estimate, aggregationKey) + other["savings_estimate"] = result.Estimate +} + +func attachSavingsLifetimeSnapshot(estimate *SavingsEstimate, aggregationKey string) { + if estimate == nil || !savings_setting.LifetimeEnabled() { + return + } + quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit).Round(0) + rate := decimal.NewFromFloat(operation_setting.USDExchangeRate) + if quotaPerUnit.LessThanOrEqual(decimal.Zero) || rate.LessThanOrEqual(decimal.Zero) { + return + } + rateMicros := rate.Mul(decimal.NewFromInt(1_000_000)).Round(0) + amountMicros := decimal.NewFromInt(int64(estimate.SavingsQuota)). + Div(quotaPerUnit). + Mul(rate). + Mul(decimal.NewFromInt(1_000_000)). + Round(0) + maxInt64 := decimal.NewFromInt(math.MaxInt64) + if rateMicros.GreaterThan(maxInt64) || amountMicros.GreaterThan(maxInt64) { + common.SysError("savings lifetime currency snapshot exceeds int64") + return + } + estimate.AggregationKey = aggregationKey + estimate.QuotaPerUnit = quotaPerUnit.IntPart() + estimate.USDCNYRateMicros = rateMicros.IntPart() + estimate.SavingsCNYMicros = amountMicros.StringFixed(0) +} + +func RecordSavingsLifetimeLog(log *model.Log) error { + if log == nil || !savings_setting.LifetimeEnabled() { + return nil + } + event, ok := buildSavingsLifetimeEvent(log) + if !ok { + return nil + } + if err := model.CreateSavingsLifetimeEvents([]model.SavingsLifetimeEvent{event}); err != nil { + return err + } + notifySavingsLifetimeAggregator() + return nil +} + +func buildSavingsLifetimeEvent(log *model.Log) (model.SavingsLifetimeEvent, bool) { + if log == nil { + return model.SavingsLifetimeEvent{}, false + } + var other savingsLogOther + if err := common.UnmarshalJsonStr(log.Other, &other); err != nil || strings.TrimSpace(other.AggregationKey) == "" { + return model.SavingsLifetimeEvent{}, false + } + event := model.SavingsLifetimeEvent{ + EventKey: "log:" + other.AggregationKey + ":base", + SourceKey: other.AggregationKey, + LogID: int64(log.Id), + UserID: log.UserId, + OccurredAt: log.CreatedAt, + DayStartUTC: log.CreatedAt / (24 * 60 * 60) * (24 * 60 * 60), + EventType: model.SavingsLifetimeEventTypeBase, + CoverageState: model.SavingsLifetimeCoverageSkipped, + SkipReason: other.AdminInfo.SavingsSkipReason, + AggregateVersion: 1, + } + estimate := savingsEstimateFromOther(log.Other) + if estimate != nil { + amountMicros, valid := savingsLifetimeFrozenAmount(estimate) + if !valid { + event.SkipReason = SavingsSkipInvalidSnapshot + } else { + event.CoverageState = model.SavingsLifetimeCoverageEstimated + event.SkipReason = "" + event.CalculationMode = model.SavingsLifetimeCalculationSnapshot + event.OfficialQuota = int64(estimate.OfficialQuota) + event.ActualQuota = int64(estimate.ActualQuota) + event.SavingsQuota = int64(estimate.SavingsQuota) + event.SavingsCNYMicros = amountMicros + event.QuotaPerUnitSnapshot = estimate.QuotaPerUnit + event.USDCNYRateMicros = estimate.USDCNYRateMicros + event.PriceSnapshotAt = estimate.PriceSnapshotAt + event.PriceFingerprint = estimate.PriceFingerprint + } + } + if event.SkipReason == "" && event.CoverageState == model.SavingsLifetimeCoverageSkipped { + event.SkipReason = SavingsSkipInvalidSnapshot + } + return event, true +} + +func savingsLifetimeFrozenAmount(estimate *SavingsEstimate) (int64, bool) { + if estimate == nil || estimate.QuotaPerUnit <= 0 || estimate.USDCNYRateMicros <= 0 { + return 0, false + } + amountMicros, err := strconv.ParseInt(estimate.SavingsCNYMicros, 10, 64) + if err != nil || amountMicros < 0 { + return 0, false + } + return amountMicros, true +} + +func buildTextSavingsEstimate(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, summary textQuotaSummary) SavingsEstimateResult { + if !savings_setting.IsEnabled() { + return SavingsEstimateResult{SkipReason: SavingsSkipDisabled} + } + if relayInfo == nil { + return SavingsEstimateResult{SkipReason: SavingsSkipInvalidSnapshot} + } + if summary.TotalTokens <= 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipMissingUsage} + } + if summary.Quota < 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipInvalidSnapshot} + } + if relayInfo.PriceData.UsePrice { + return SavingsEstimateResult{SkipReason: SavingsSkipUnsupportedBillingMode} + } + if hasSavingsUnsupportedTextExtra(relayInfo, summary) { + return SavingsEstimateResult{SkipReason: SavingsSkipUnknownExtraRatio} + } + + setting := savings_setting.GetSetting() + price, matchedModel, skipReason := matchSavingsOfficialPrice(setting, relayInfo, summary.ModelName) + if skipReason != "" { + return SavingsEstimateResult{SkipReason: skipReason} + } + officialQuota, skipReason := calculateOfficialTextQuota(summary, price) + if skipReason != "" { + return SavingsEstimateResult{SkipReason: skipReason} + } + + savingsQuota := officialQuota - summary.Quota + if savingsQuota < 0 { + savingsQuota = 0 + } + return SavingsEstimateResult{ + Estimate: &SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + Calculator: savingsTextCalculator, + OfficialQuota: officialQuota, + ActualQuota: summary.Quota, + SavingsQuota: savingsQuota, + Source: price.Source, + SourceURL: price.SourceURL, + SourceUpdatedAt: price.SourceUpdatedAt, + PriceSnapshotAt: price.PriceSnapshotAt, + PriceFingerprint: price.PriceFingerprint, + OfficialConfirmed: price.OfficialConfirmed, + MatchedModel: matchedModel, + PricingMode: savingsPricingMode(price), + CalculationMode: savingsCalculationSnapshot, + Estimated: true, + }, + } +} + +func GetUserSavingsSummary(userId int, startTimestamp int64, endTimestamp int64) (*SavingsSummary, error) { + setting := savings_setting.GetSetting() + cacheKey, err := buildUserSavingsCacheKey( + savingsSummaryCacheKind, + userId, + startTimestamp, + endTimestamp, + "", + 0, + setting, + ) + if err != nil { + return nil, err + } + result, err := loadCachedSavingsResult(getUserSavingsSummaryCache(), &savingsSummaryGroup, cacheKey, func() (SavingsSummary, error) { + summary, err := buildUserSavingsSummary(setting, userId, startTimestamp, endTimestamp) + if err != nil { + return SavingsSummary{}, err + } + return *summary, nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +func buildUserSavingsSummary(setting savings_setting.Setting, userId int, startTimestamp int64, endTimestamp int64) (*SavingsSummary, error) { + summary := newSavingsSummary(setting, startTimestamp, endTimestamp) + if !summary.Enabled { + return summary, nil + } + + rows, total, partial, err := loadUserSavingsRows(userId, startTimestamp, endTimestamp) + if err != nil { + return nil, err + } + summary.RequestCount = total + if partial { + summary.IsPartial = true + return summary, nil + } + if total == 0 { + return summary, nil + } + + estimator := newSavingsLogEstimator(setting) + accumulator := newSavingsSummaryAccumulator(summary, estimator.priceSnapshotAt, setting.OfficialPriceStaleDays) + for _, row := range rows { + result := estimator.estimate(row) + if result.Estimate == nil { + continue + } + if err := accumulator.add(result); err != nil { + return nil, err + } + } + accumulator.finish() + return summary, nil +} + +func GetUserSavingsTrend(userId int, startTimestamp int64, endTimestamp int64, granularity string, utcOffsetMinutes int) (*SavingsTrend, error) { + setting := savings_setting.GetSetting() + cacheKey, err := buildUserSavingsCacheKey( + savingsTrendCacheKind, + userId, + startTimestamp, + endTimestamp, + granularity, + utcOffsetMinutes, + setting, + ) + if err != nil { + return nil, err + } + result, err := loadCachedSavingsResult(getUserSavingsTrendCache(), &savingsTrendGroup, cacheKey, func() (SavingsTrend, error) { + trend, err := buildUserSavingsTrend(setting, userId, startTimestamp, endTimestamp, granularity, utcOffsetMinutes) + if err != nil { + return SavingsTrend{}, err + } + return *trend, nil + }) + if err != nil { + return nil, err + } + return &result, nil +} + +func buildUserSavingsTrend(setting savings_setting.Setting, userId int, startTimestamp int64, endTimestamp int64, granularity string, utcOffsetMinutes int) (*SavingsTrend, error) { + summary := newSavingsSummary(setting, startTimestamp, endTimestamp) + buckets, bucketSize, err := buildSavingsTrendBuckets(startTimestamp, endTimestamp, granularity, utcOffsetMinutes) + if err != nil { + return nil, err + } + trend := &SavingsTrend{ + Granularity: granularity, + UTCOffsetMinutes: utcOffsetMinutes, + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + Summary: *summary, + Buckets: buckets, + } + if !summary.Enabled { + return trend, nil + } + + rows, total, partial, err := loadUserSavingsRows(userId, startTimestamp, endTimestamp) + if err != nil { + return nil, err + } + summary.RequestCount = total + if partial { + summary.IsPartial = true + trend.Summary = *summary + trend.Buckets = []SavingsTrendBucket{} + return trend, nil + } + if total == 0 { + trend.Summary = *summary + return trend, nil + } + + estimator := newSavingsLogEstimator(setting) + accumulator := newSavingsSummaryAccumulator(summary, estimator.priceSnapshotAt, setting.OfficialPriceStaleDays) + firstBucketStart := buckets[0].StartTimestamp + for _, row := range rows { + bucketIndex := int((row.CreatedAt - firstBucketStart) / bucketSize) + if bucketIndex < 0 || bucketIndex >= len(buckets) { + continue + } + bucket := &buckets[bucketIndex] + bucket.RequestCount++ + + result := estimator.estimate(row) + if result.Estimate == nil { + continue + } + if err := accumulator.add(result); err != nil { + return nil, err + } + if err := addSavingsTrendBucket(bucket, result); err != nil { + return nil, err + } + } + accumulator.finish() + for i := range buckets { + if buckets[i].RequestCount > 0 { + buckets[i].CoverageRatio = float64(buckets[i].EstimatedRequestCount) / float64(buckets[i].RequestCount) + } + } + trend.Summary = *summary + trend.Buckets = buckets + return trend, nil +} + +func getUserSavingsSummaryCache() *cachex.HybridCache[SavingsSummary] { + savingsSummaryCacheOnce.Do(func() { + savingsSummaryCache = cachex.NewHybridCache[SavingsSummary](cachex.HybridCacheConfig[SavingsSummary]{ + Namespace: cachex.Namespace(savingsSummaryCacheNS), + Redis: common.RDB, + RedisEnabled: func() bool { + return common.RedisEnabled && common.RDB != nil + }, + RedisCodec: cachex.JSONCodec[SavingsSummary]{}, + Memory: func() *hot.HotCache[string, SavingsSummary] { + return hot.NewHotCache[string, SavingsSummary](hot.LRU, savingsResultCacheCapacity). + WithTTL(savingsResultCacheTTL). + WithJanitor(). + Build() + }, + }) + }) + return savingsSummaryCache +} + +func getUserSavingsTrendCache() *cachex.HybridCache[SavingsTrend] { + savingsTrendCacheOnce.Do(func() { + savingsTrendCache = cachex.NewHybridCache[SavingsTrend](cachex.HybridCacheConfig[SavingsTrend]{ + Namespace: cachex.Namespace(savingsTrendCacheNS), + Redis: common.RDB, + RedisEnabled: func() bool { + return common.RedisEnabled && common.RDB != nil + }, + RedisCodec: cachex.JSONCodec[SavingsTrend]{}, + Memory: func() *hot.HotCache[string, SavingsTrend] { + return hot.NewHotCache[string, SavingsTrend](hot.LRU, savingsResultCacheCapacity). + WithTTL(savingsResultCacheTTL). + WithJanitor(). + Build() + }, + }) + }) + return savingsTrendCache +} + +func loadCachedSavingsResult[V any](cache *cachex.HybridCache[V], group *singleflight.Group, key string, load func() (V, error)) (V, error) { + if cached, found, err := cache.Get(key); err == nil && found { + return cached, nil + } else if err != nil { + common.SysError("failed to read user savings cache: " + err.Error()) + } + + result, err, _ := group.Do(cache.FullKey(key), func() (interface{}, error) { + loaded, loadErr := load() + if loadErr != nil { + return loaded, loadErr + } + if cacheErr := cache.SetWithTTL(key, loaded, savingsResultCacheTTL); cacheErr != nil { + common.SysError("failed to write user savings cache: " + cacheErr.Error()) + } + return loaded, nil + }) + if err != nil { + var zero V + return zero, err + } + return result.(V), nil +} + +func buildUserSavingsCacheKey(kind string, userId int, startTimestamp int64, endTimestamp int64, granularity string, utcOffsetMinutes int, setting savings_setting.Setting) (string, error) { + settingJSON, err := common.Marshal(setting) + if err != nil { + return "", err + } + settingHash := sha256.Sum256(settingJSON) + return fmt.Sprintf( + "%s:%d:%d:%d:%d:%s:%d:%s", + kind, + savingsEstimateSchemaVersion, + userId, + startTimestamp, + endTimestamp, + granularity, + utcOffsetMinutes, + hex.EncodeToString(settingHash[:]), + ), nil +} + +func newSavingsSummary(setting savings_setting.Setting, startTimestamp int64, endTimestamp int64) *SavingsSummary { + return &SavingsSummary{ + Enabled: setting.Enabled && setting.ShowOnDashboard, + Source: "official_snapshot", + WindowDays: savingsWindowDays(startTimestamp, endTimestamp), + } +} + +func loadUserSavingsRows(userId int, startTimestamp int64, endTimestamp int64) ([]model.SavingsLogRow, int64, bool, error) { + maxRows := savings_setting.MaxSummaryLogRows() + total, err := model.CountUserSavingsConsumeLogs(userId, startTimestamp, endTimestamp) + if err != nil { + return nil, 0, false, err + } + if int64(maxRows) > 0 && total > int64(maxRows) { + return nil, total, true, nil + } + if total == 0 { + return []model.SavingsLogRow{}, 0, false, nil + } + rows, err := model.GetUserSavingsConsumeLogs(userId, startTimestamp, endTimestamp, maxRows) + if err != nil { + return nil, 0, false, err + } + return rows, total, false, nil +} + +func newSavingsLogEstimator(setting savings_setting.Setting) savingsLogEstimator { + priceSnapshotAt := time.Now().Unix() + var localPrices map[string]savings_setting.OfficialPrice + if setting.RebuildLegacyLogs { + localPrices = buildLocalSavingsPriceMap(setting, priceSnapshotAt) + } + return savingsLogEstimator{ + setting: setting, + localPrices: localPrices, + priceSnapshotAt: priceSnapshotAt, + } +} + +func (e savingsLogEstimator) estimate(row model.SavingsLogRow) savingsLogEstimate { + estimate, snapshotPresent := parseSavingsEstimateFromOther(row.Other) + if snapshotPresent { + if estimate == nil { + return savingsLogEstimate{SkipReason: SavingsSkipInvalidSnapshot} + } + return savingsLogEstimate{Estimate: estimate, CalculationMode: savingsCalculationSnapshot} + } + if !e.setting.RebuildLegacyLogs { + return savingsLogEstimate{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + result := rebuildHistoricalSavingsEstimate(e.setting, e.localPrices, row, e.priceSnapshotAt) + return savingsLogEstimate{ + Estimate: result.Estimate, + CalculationMode: savingsCalculationHistorical, + SkipReason: result.SkipReason, + } +} + +func newSavingsSummaryAccumulator(summary *SavingsSummary, priceSnapshotAt int64, staleDays int) savingsSummaryAccumulator { + return savingsSummaryAccumulator{ + summary: summary, + priceSnapshotAt: priceSnapshotAt, + staleBefore: time.Now().Add(-time.Duration(staleDays) * 24 * time.Hour).Unix(), + sources: make(map[string]struct{}, 2), + allConfirmed: true, + } +} + +func (a *savingsSummaryAccumulator) add(result savingsLogEstimate) error { + if result.Estimate == nil { + return nil + } + if !addSavingsSummaryQuota(&a.summary.OfficialQuota, result.Estimate.OfficialQuota) || + !addSavingsSummaryQuota(&a.summary.ActualQuota, result.Estimate.ActualQuota) || + !addSavingsSummaryQuota(&a.summary.SavingsQuota, result.Estimate.SavingsQuota) { + return errors.New("user savings summary exceeds safe range") + } + a.summary.EstimatedRequestCount++ + if result.CalculationMode == savingsCalculationHistorical { + a.summary.ReconstructedRequestCount++ + } else { + a.summary.SnapshotRequestCount++ + } + a.allConfirmed = a.allConfirmed && result.Estimate.OfficialConfirmed + a.sources[savingsSummarySource(result.Estimate.Source)] = struct{}{} + if result.Estimate.SourceUpdatedAt > 0 && (a.summary.SourceUpdatedAt == 0 || result.Estimate.SourceUpdatedAt < a.summary.SourceUpdatedAt) { + a.summary.SourceUpdatedAt = result.Estimate.SourceUpdatedAt + } + if result.Estimate.SourceUpdatedAt > 0 && result.Estimate.SourceUpdatedAt < a.staleBefore { + a.summary.OfficialPriceStale = true + } + return nil +} + +func (a *savingsSummaryAccumulator) finish() { + if a.summary.EstimatedRequestCount > 0 { + a.summary.OfficialConfirmed = a.allConfirmed + } + if a.summary.ReconstructedRequestCount > 0 { + a.summary.RebuildPriceSnapshotAt = a.priceSnapshotAt + } + if len(a.sources) == 1 { + for source := range a.sources { + a.summary.Source = source + } + } else if len(a.sources) > 1 { + a.summary.Source = savingsSourceMixed + } + if a.summary.RequestCount > 0 { + a.summary.CoverageRatio = float64(a.summary.EstimatedRequestCount) / float64(a.summary.RequestCount) + } +} + +func addSavingsTrendBucket(bucket *SavingsTrendBucket, result savingsLogEstimate) error { + if result.Estimate == nil { + return nil + } + if !addSavingsSummaryQuota(&bucket.OfficialQuota, result.Estimate.OfficialQuota) || + !addSavingsSummaryQuota(&bucket.ActualQuota, result.Estimate.ActualQuota) || + !addSavingsSummaryQuota(&bucket.SavingsQuota, result.Estimate.SavingsQuota) { + return errors.New("user savings trend exceeds safe range") + } + bucket.EstimatedRequestCount++ + if result.CalculationMode == savingsCalculationHistorical { + bucket.ReconstructedRequestCount++ + } else { + bucket.SnapshotRequestCount++ + } + return nil +} + +func buildSavingsTrendBuckets(startTimestamp int64, endTimestamp int64, granularity string, utcOffsetMinutes int) ([]SavingsTrendBucket, int64, error) { + if utcOffsetMinutes < -720 || utcOffsetMinutes > 840 { + return nil, 0, ErrSavingsUTCOffsetInvalid + } + duration := endTimestamp - startTimestamp + var bucketSize int64 + switch granularity { + case SavingsTrendGranularityHour: + bucketSize = 3600 + if duration > savingsTrendMaxHourSeconds { + return nil, 0, ErrSavingsHourRangeTooLarge + } + case SavingsTrendGranularityDay: + bucketSize = 24 * 3600 + default: + return nil, 0, ErrSavingsGranularity + } + if duration <= 0 { + return nil, 0, ErrSavingsTimeRangeInvalid + } + + offsetSeconds := int64(utcOffsetMinutes) * 60 + firstBucketStart := ((startTimestamp + offsetSeconds) / bucketSize * bucketSize) - offsetSeconds + bucketCount := int((endTimestamp - firstBucketStart + bucketSize - 1) / bucketSize) + if bucketCount <= 0 || bucketCount > savingsTrendMaxBuckets { + return nil, 0, ErrSavingsTooManyBuckets + } + buckets := make([]SavingsTrendBucket, bucketCount) + for i := range buckets { + bucketStart := firstBucketStart + int64(i)*bucketSize + buckets[i] = SavingsTrendBucket{ + StartTimestamp: bucketStart, + EndTimestamp: bucketStart + bucketSize, + } + } + return buckets, bucketSize, nil +} + +func addSavingsSummaryQuota(total *int64, value int) bool { + if value < 0 || *total > math.MaxInt64-int64(value) { + return false + } + *total += int64(value) + return true +} + +func savingsSummarySource(source string) string { + if source == savingsSourceLocalPricing { + return savingsSourceLocalPricing + } + return savingsSourceOfficialOverride +} + +func NormalizeSavingsSummaryWindow(startTimestamp int64, endTimestamp int64) (int64, error) { + if startTimestamp <= 0 || endTimestamp <= 0 { + return 0, ErrSavingsTimeRangeRequired + } + now := time.Now().Unix() + if endTimestamp > now+5*60 { + return 0, ErrSavingsEndAfterNow + } + if endTimestamp > now { + endTimestamp = now + } + if endTimestamp <= startTimestamp { + return 0, ErrSavingsTimeRangeInvalid + } + maxDays := savings_setting.MaxSummaryDays() + if maxDays > 0 && endTimestamp-startTimestamp > int64(maxDays)*24*3600 { + return 0, ErrSavingsTimeRangeTooLarge + } + return endTimestamp, nil +} + +func NormalizeSavingsTrendWindow(startTimestamp int64, endTimestamp int64, granularity string, utcOffsetMinutes int) (int64, error) { + effectiveEndTimestamp, err := NormalizeSavingsSummaryWindow(startTimestamp, endTimestamp) + if err != nil { + return 0, err + } + if _, _, err := buildSavingsTrendBuckets(startTimestamp, effectiveEndTimestamp, granularity, utcOffsetMinutes); err != nil { + return 0, err + } + return effectiveEndTimestamp, nil +} + +func ValidateSavingsSummaryWindow(startTimestamp int64, endTimestamp int64) error { + _, err := NormalizeSavingsSummaryWindow(startTimestamp, endTimestamp) + return err +} + +func savingsEstimateFromOther(other string) *SavingsEstimate { + estimate, _ := parseSavingsEstimateFromOther(other) + return estimate +} + +func parseSavingsEstimateFromOther(other string) (*SavingsEstimate, bool) { + if strings.TrimSpace(other) == "" { + return nil, false + } + var parsed savingsLogOther + if err := common.UnmarshalJsonStr(other, &parsed); err != nil { + return nil, false + } + if len(parsed.SavingsEstimate) == 0 { + return nil, false + } + var estimate SavingsEstimate + if err := common.Unmarshal(parsed.SavingsEstimate, &estimate); err != nil { + return nil, true + } + if estimate.SchemaVersion != savingsEstimateSchemaVersion { + return nil, true + } + if estimate.OfficialQuota < 0 || estimate.ActualQuota < 0 || estimate.SavingsQuota < 0 { + return nil, true + } + expectedSavings := estimate.OfficialQuota - estimate.ActualQuota + if expectedSavings < 0 { + expectedSavings = 0 + } + if estimate.SavingsQuota != expectedSavings { + return nil, true + } + if estimate.CalculationMode == "" { + estimate.CalculationMode = savingsCalculationSnapshot + } + return &estimate, true +} + +func matchSavingsOfficialPrice(setting savings_setting.Setting, relayInfo *relaycommon.RelayInfo, modelName string) (savings_setting.OfficialPrice, string, string) { + return matchSavingsOfficialPriceCandidates(setting, savingsModelCandidates(relayInfo, modelName), nil, time.Now().Unix()) +} + +func matchSavingsOfficialPriceCandidates(setting savings_setting.Setting, candidates []string, localPrices map[string]savings_setting.OfficialPrice, priceSnapshotAt int64) (savings_setting.OfficialPrice, string, string) { + foundUnconfirmed := false + for _, candidate := range candidates { + price, ok := setting.OfficialPrices[candidate] + if !ok { + continue + } + if setting.RequireOfficialConfirmation && !price.OfficialConfirmed { + foundUnconfirmed = true + continue + } + if strings.TrimSpace(price.Source) == "" { + price.Source = savingsSourceOfficialOverride + } + price.PriceSnapshotAt = priceSnapshotAt + if !finalizeSavingsOfficialPrice(&price, candidate) { + return price, candidate, SavingsSkipInvalidSnapshot + } + return price, candidate, "" + } + if setting.LocalPricingOfficialConfirmed { + for _, candidate := range candidates { + var price savings_setting.OfficialPrice + var ok bool + if localPrices == nil { + localPricing, found := model.GetPricingByModel(candidate) + if !found { + continue + } + price = savingsOfficialPriceFromPricing(localPricing, priceSnapshotAt) + } else { + price, ok = localPrices[candidate] + if !ok { + continue + } + } + if !finalizeSavingsOfficialPrice(&price, candidate) { + return price, candidate, SavingsSkipInvalidSnapshot + } + return price, candidate, "" + } + } + if foundUnconfirmed { + return savings_setting.OfficialPrice{}, "", SavingsSkipUnconfirmedOfficialPrice + } + return savings_setting.OfficialPrice{}, "", SavingsSkipMissingOfficialPrice +} + +func buildLocalSavingsPriceMap(setting savings_setting.Setting, priceSnapshotAt int64) map[string]savings_setting.OfficialPrice { + prices := make(map[string]savings_setting.OfficialPrice) + if !setting.LocalPricingOfficialConfirmed { + return prices + } + for _, pricing := range model.GetPricing() { + prices[pricing.ModelName] = savingsOfficialPriceFromPricing(pricing, priceSnapshotAt) + } + return prices +} + +func savingsOfficialPriceFromPricing(pricing model.Pricing, priceSnapshotAt int64) savings_setting.OfficialPrice { + price := savings_setting.OfficialPrice{ + QuotaType: pricing.QuotaType, + ModelRatio: savingsFloat64Ptr(pricing.ModelRatio), + ModelPrice: savingsFloat64Ptr(pricing.ModelPrice), + CompletionRatio: savingsFloat64Ptr(pricing.CompletionRatio), + CacheRatio: pricing.CacheRatio, + CreateCacheRatio: pricing.CreateCacheRatio, + ImageRatio: pricing.ImageRatio, + AudioRatio: pricing.AudioRatio, + AudioCompletionRatio: pricing.AudioCompletionRatio, + BillingMode: pricing.BillingMode, + BillingExpr: pricing.BillingExpr, + Source: savingsSourceLocalPricing, + PriceSnapshotAt: priceSnapshotAt, + OfficialConfirmed: true, + } + return price +} + +func finalizeSavingsOfficialPrice(price *savings_setting.OfficialPrice, matchedModel string) bool { + fingerprint := savingsPriceFingerprint{ + ModelName: matchedModel, + QuotaType: price.QuotaType, + ModelRatio: price.ModelRatio, + ModelPrice: price.ModelPrice, + CompletionRatio: price.CompletionRatio, + CacheRatio: price.CacheRatio, + CreateCacheRatio: price.CreateCacheRatio, + CacheCreation5mRatio: price.CacheCreation5mRatio, + CacheCreation1hRatio: price.CacheCreation1hRatio, + ImageRatio: price.ImageRatio, + AudioRatio: price.AudioRatio, + AudioCompletionRatio: price.AudioCompletionRatio, + BillingMode: price.BillingMode, + BillingExpr: price.BillingExpr, + } + data, err := common.Marshal(fingerprint) + if err != nil { + return false + } + hash := sha256.Sum256(data) + price.PriceFingerprint = "sha256:" + hex.EncodeToString(hash[:]) + return true +} + +func savingsFloat64Ptr(value float64) *float64 { + return &value +} + +func rebuildHistoricalSavingsEstimate(setting savings_setting.Setting, localPrices map[string]savings_setting.OfficialPrice, row model.SavingsLogRow, priceSnapshotAt int64) SavingsEstimateResult { + if strings.TrimSpace(row.Other) == "" || row.PromptTokens < 0 || row.CompletionTokens < 0 || row.Quota < 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + var legacy legacySavingsLogOther + if err := common.UnmarshalJsonStr(row.Other, &legacy); err != nil { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + if legacy.GroupRatio == nil || legacy.CacheTokens == nil { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + if legacy.Audio || legacy.WSS || legacy.WebSearch || legacy.FileSearch || + legacy.AudioInputSeparatePrice || legacy.ImageGenerationCall { + return SavingsEstimateResult{SkipReason: SavingsSkipUnsupportedBillingMode} + } + mode := strings.TrimSpace(legacy.BillingMode) + if mode == "" { + mode = billing_setting.BillingModeRatio + } + if mode != billing_setting.BillingModeRatio && mode != "per_token" && mode != billing_setting.BillingModeTieredExpr { + return SavingsEstimateResult{SkipReason: SavingsSkipUnsupportedBillingMode} + } + if *legacy.CacheTokens < 0 || legacy.CacheCreationTokens < 0 || legacy.CacheCreationTokens5m < 0 || legacy.CacheCreationTokens1h < 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + if legacy.UsageSemantic != "" && legacy.UsageSemantic != "openai" && legacy.UsageSemantic != "anthropic" { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + isClaudeUsage := legacy.UsageSemantic == "anthropic" || legacy.Claude + if !isClaudeUsage && (legacy.CacheCreationTokens5m > 0 || legacy.CacheCreationTokens1h > 0) { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + imageTokens := 0 + if legacy.Image { + if legacy.ImageOutput == nil || *legacy.ImageOutput < 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + imageTokens = *legacy.ImageOutput + } else if legacy.ImageOutput != nil && *legacy.ImageOutput != 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + + summary := textQuotaSummary{ + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + TotalTokens: row.PromptTokens + row.CompletionTokens, + CacheTokens: *legacy.CacheTokens, + CacheCreationTokens: legacy.CacheCreationTokens, + CacheCreationTokens5m: legacy.CacheCreationTokens5m, + CacheCreationTokens1h: legacy.CacheCreationTokens1h, + ImageTokens: imageTokens, + ModelName: row.ModelName, + Quota: row.Quota, + IsClaudeUsageSemantic: isClaudeUsage, + UsageSemantic: legacy.UsageSemantic, + } + if summary.TotalTokens <= 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipMissingUsage} + } + actualQuota := row.Quota + if mode == billing_setting.BillingModeTieredExpr { + exprBytes, err := base64.StdEncoding.DecodeString(legacy.ExprB64) + if err != nil || len(exprBytes) == 0 { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyInvalidSnapshot} + } + rebuiltQuota, skipReason := calculateSavingsTieredTextQuota(summary, string(exprBytes), *legacy.GroupRatio) + if skipReason != "" { + return SavingsEstimateResult{SkipReason: skipReason} + } + if rebuiltQuota != row.Quota { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyActualQuotaMismatch} + } + } else { + if legacy.ModelRatio == nil || legacy.CompletionRatio == nil || legacy.ModelPrice == nil || legacy.CacheRatio == nil { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + if *legacy.ModelPrice != 0 && *legacy.ModelPrice != -1 { + return SavingsEstimateResult{SkipReason: SavingsSkipUnsupportedBillingMode} + } + if (legacy.CacheCreationTokens > 0 && legacy.CacheCreationRatio == nil) || + (legacy.CacheCreationTokens5m > 0 && legacy.CacheCreationRatio5m == nil) || + (legacy.CacheCreationTokens1h > 0 && legacy.CacheCreationRatio1h == nil) || + (legacy.Image && legacy.ImageRatio == nil) { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyMissingBaseFields} + } + actualPrice := savings_setting.OfficialPrice{ + QuotaType: 0, + ModelRatio: legacy.ModelRatio, + CompletionRatio: legacy.CompletionRatio, + CacheRatio: legacy.CacheRatio, + CreateCacheRatio: legacy.CacheCreationRatio, + CacheCreation5mRatio: legacy.CacheCreationRatio5m, + CacheCreation1hRatio: legacy.CacheCreationRatio1h, + ImageRatio: legacy.ImageRatio, + BillingMode: billing_setting.BillingModeRatio, + } + rebuiltQuota, skipReason := calculateSavingsTextQuota(summary, actualPrice, *legacy.GroupRatio) + if skipReason != "" { + return SavingsEstimateResult{SkipReason: skipReason} + } + if rebuiltQuota != row.Quota { + return SavingsEstimateResult{SkipReason: SavingsSkipLegacyActualQuotaMismatch} + } + } + + price, matchedModel, skipReason := matchSavingsOfficialPriceCandidates( + setting, + savingsModelCandidates(nil, row.ModelName), + localPrices, + priceSnapshotAt, + ) + if skipReason != "" { + return SavingsEstimateResult{SkipReason: skipReason} + } + officialQuota, skipReason := calculateOfficialTextQuota(summary, price) + if skipReason != "" { + return SavingsEstimateResult{SkipReason: skipReason} + } + savingsQuota := officialQuota - actualQuota + if savingsQuota < 0 { + savingsQuota = 0 + } + return SavingsEstimateResult{Estimate: &SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + Calculator: savingsHistoricalCalculator, + OfficialQuota: officialQuota, + ActualQuota: actualQuota, + SavingsQuota: savingsQuota, + Source: price.Source, + SourceURL: price.SourceURL, + SourceUpdatedAt: price.SourceUpdatedAt, + PriceSnapshotAt: price.PriceSnapshotAt, + PriceFingerprint: price.PriceFingerprint, + OfficialConfirmed: price.OfficialConfirmed, + MatchedModel: matchedModel, + PricingMode: savingsPricingMode(price), + CalculationMode: savingsCalculationHistorical, + Estimated: true, + }} +} + +func savingsModelCandidates(relayInfo *relaycommon.RelayInfo, modelName string) []string { + candidates := make([]string, 0, 6) + add := func(name string) { + name = strings.TrimSpace(name) + if name == "" { + return + } + for _, existing := range candidates { + if existing == name { + return + } + } + candidates = append(candidates, name) + } + + add(modelName) + if relayInfo != nil { + add(relayInfo.OriginModelName) + if relayInfo.ChannelMeta != nil { + add(relayInfo.ChannelMeta.UpstreamModelName) + } + } + + baseLen := len(candidates) + for i := 0; i < baseLen; i++ { + add(ratio_setting.FormatMatchingModelName(candidates[i])) + } + for _, candidate := range candidates { + if strings.HasSuffix(candidate, ratio_setting.CompactModelSuffix) { + add(ratio_setting.CompactWildcardModelKey) + break + } + } + return candidates +} + +func calculateOfficialTextQuota(summary textQuotaSummary, price savings_setting.OfficialPrice) (int, string) { + if strings.TrimSpace(price.BillingMode) == billing_setting.BillingModeTieredExpr { + return calculateSavingsTieredTextQuota(summary, price.BillingExpr, 1) + } + return calculateSavingsTextQuota(summary, price, 1) +} + +func savingsPricingMode(price savings_setting.OfficialPrice) string { + if strings.TrimSpace(price.BillingMode) == billing_setting.BillingModeTieredExpr { + return billing_setting.BillingModeTieredExpr + } + return "per_token" +} + +func calculateSavingsTieredTextQuota(summary textQuotaSummary, expr string, groupRatio float64) (int, string) { + expr = strings.TrimSpace(expr) + // Request rules after ||| depend on headers, parameters, or time context that usage logs do not retain. + if expr == "" || strings.Contains(expr, "|||") || !validSavingsRatio(groupRatio, true) { + return 0, SavingsSkipUnsupportedBillingMode + } + usedVars := billingexpr.UsedVars(expr) + if usedVars == nil { + return 0, SavingsSkipInvalidSnapshot + } + for _, name := range []string{"header", "param", "has", "hour", "minute", "weekday", "month", "day", "img_o", "ao"} { + if usedVars[name] { + return 0, SavingsSkipUnsupportedBillingMode + } + } + + promptTokens := float64(summary.PromptTokens) + completionTokens := float64(summary.CompletionTokens) + cacheTokens := float64(summary.CacheTokens) + cacheCreationTokens := float64(summary.CacheCreationTokens) + cacheCreationTokens1h := float64(0) + inputLength := promptTokens + if summary.IsClaudeUsageSemantic { + if summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 { + cacheCreationTokens = float64(summary.CacheCreationTokens5m) + cacheCreationTokens1h = float64(summary.CacheCreationTokens1h) + } + inputLength += cacheTokens + cacheCreationTokens + cacheCreationTokens1h + } else { + if usedVars["cr"] { + promptTokens -= cacheTokens + } + if usedVars["cc"] { + promptTokens -= cacheCreationTokens + } + if usedVars["img"] { + promptTokens -= float64(summary.ImageTokens) + } + if usedVars["ai"] { + promptTokens -= float64(summary.AudioTokens) + } + } + if promptTokens < 0 { + promptTokens = 0 + } + if completionTokens < 0 { + completionTokens = 0 + } + + snapshot := billingexpr.BillingSnapshot{ + BillingMode: billing_setting.BillingModeTieredExpr, + ExprString: expr, + ExprHash: billingexpr.ExprHashString(expr), + GroupRatio: groupRatio, + QuotaPerUnit: common.QuotaPerUnit, + ExprVersion: billingexpr.ExprVersion(expr), + } + result, err := billingexpr.ComputeTieredQuota(&snapshot, billingexpr.TokenParams{ + P: promptTokens, + C: completionTokens, + Len: inputLength, + CR: cacheTokens, + CC: cacheCreationTokens, + CC1h: cacheCreationTokens1h, + Img: float64(summary.ImageTokens), + AI: float64(summary.AudioTokens), + }) + if err != nil || result.ActualQuotaAfterGroup < 0 { + return 0, SavingsSkipInvalidSnapshot + } + if result.Clamp != nil { + return 0, SavingsSkipQuotaSaturated + } + return result.ActualQuotaAfterGroup, "" +} + +func calculateSavingsTextQuota(summary textQuotaSummary, price savings_setting.OfficialPrice, groupRatio float64) (int, string) { + mode := strings.TrimSpace(price.BillingMode) + if mode == "" { + mode = billing_setting.BillingModeRatio + } + if price.QuotaType != 0 || (mode != billing_setting.BillingModeRatio && mode != "per_token") { + return 0, SavingsSkipUnsupportedBillingMode + } + if price.ModelRatio == nil || price.CompletionRatio == nil || + !validSavingsRatio(*price.ModelRatio, false) || + !validSavingsRatio(*price.CompletionRatio, true) || + !validSavingsRatio(groupRatio, true) { + return 0, SavingsSkipInvalidSnapshot + } + if summary.AudioTokens > 0 { + return 0, SavingsSkipUnsupportedBillingMode + } + + baseTokens := decimal.NewFromInt(int64(summary.PromptTokens)) + cacheQuota := decimal.Zero + if summary.CacheTokens > 0 { + cacheRatio := 1.0 + if price.CacheRatio != nil { + if !validSavingsRatio(*price.CacheRatio, true) { + return 0, SavingsSkipInvalidSnapshot + } + cacheRatio = *price.CacheRatio + } + if !summary.IsClaudeUsageSemantic && !summary.LegacyClaudeDerivedUsage { + baseTokens = baseTokens.Sub(decimal.NewFromInt(int64(summary.CacheTokens))) + } + cacheQuota = decimal.NewFromInt(int64(summary.CacheTokens)).Mul(decimal.NewFromFloat(cacheRatio)) + } + + cacheCreateQuota, skipReason := calculateOfficialCacheCreateQuota(summary, price) + if skipReason != "" { + return 0, skipReason + } + + imageQuota := decimal.Zero + if summary.ImageTokens > 0 { + imageRatio := 1.0 + if price.ImageRatio != nil { + if !validSavingsRatio(*price.ImageRatio, true) { + return 0, SavingsSkipInvalidSnapshot + } + imageRatio = *price.ImageRatio + } + baseTokens = baseTokens.Sub(decimal.NewFromInt(int64(summary.ImageTokens))) + imageQuota = decimal.NewFromInt(int64(summary.ImageTokens)).Mul(decimal.NewFromFloat(imageRatio)) + } + + if baseTokens.IsNegative() { + baseTokens = decimal.Zero + } + + promptQuota := baseTokens.Add(cacheQuota).Add(cacheCreateQuota).Add(imageQuota) + completionQuota := decimal.NewFromInt(int64(summary.CompletionTokens)).Mul(decimal.NewFromFloat(*price.CompletionRatio)) + quotaDecimal := promptQuota.Add(completionQuota). + Mul(decimal.NewFromFloat(*price.ModelRatio)). + Mul(decimal.NewFromFloat(groupRatio)) + if quotaDecimal.IsNegative() { + return 0, SavingsSkipInvalidSnapshot + } + quota, clamp := common.QuotaFromDecimalChecked(quotaDecimal) + if clamp != nil { + return 0, SavingsSkipQuotaSaturated + } + if quota == 0 && summary.TotalTokens > 0 && *price.ModelRatio > 0 && groupRatio > 0 { + quota = 1 + } + return quota, "" +} + +func calculateOfficialCacheCreateQuota(summary textQuotaSummary, price savings_setting.OfficialPrice) (decimal.Decimal, string) { + cacheWriteTokens := summary.CacheCreationTokens + hasSplitCacheCreationTokens := summary.CacheCreationTokens5m > 0 || summary.CacheCreationTokens1h > 0 + if cacheWriteTokens <= 0 && !hasSplitCacheCreationTokens { + return decimal.Zero, "" + } + if !summary.IsClaudeUsageSemantic && !summary.LegacyClaudeDerivedUsage { + if price.CreateCacheRatio == nil || !validSavingsRatio(*price.CreateCacheRatio, true) { + return decimal.Zero, SavingsSkipInvalidSnapshot + } + return decimal.NewFromInt(int64(cacheWriteTokens)).Mul(decimal.NewFromFloat(*price.CreateCacheRatio)), "" + } + + total := decimal.Zero + remaining := cacheWriteTokens - summary.CacheCreationTokens5m - summary.CacheCreationTokens1h + if remaining < 0 { + remaining = 0 + } + if remaining > 0 { + if price.CreateCacheRatio == nil || !validSavingsRatio(*price.CreateCacheRatio, true) { + return decimal.Zero, SavingsSkipInvalidSnapshot + } + total = total.Add(decimal.NewFromInt(int64(remaining)).Mul(decimal.NewFromFloat(*price.CreateCacheRatio))) + } + if summary.CacheCreationTokens5m > 0 { + if price.CacheCreation5mRatio == nil || !validSavingsRatio(*price.CacheCreation5mRatio, true) { + return decimal.Zero, SavingsSkipInvalidSnapshot + } + total = total.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens5m)).Mul(decimal.NewFromFloat(*price.CacheCreation5mRatio))) + } + if summary.CacheCreationTokens1h > 0 { + if price.CacheCreation1hRatio == nil || !validSavingsRatio(*price.CacheCreation1hRatio, true) { + return decimal.Zero, SavingsSkipInvalidSnapshot + } + total = total.Add(decimal.NewFromInt(int64(summary.CacheCreationTokens1h)).Mul(decimal.NewFromFloat(*price.CacheCreation1hRatio))) + } + return total, "" +} + +func hasSavingsUnsupportedTextExtra(relayInfo *relaycommon.RelayInfo, summary textQuotaSummary) bool { + if relayInfo != nil && len(relayInfo.PriceData.OtherRatios()) > 0 { + return true + } + return len(summary.ToolSurchargeItems) > 0 || summary.AudioInputPrice > 0 +} + +func validSavingsRatio(value float64, allowZero bool) bool { + if math.IsNaN(value) || math.IsInf(value, 0) { + return false + } + if allowZero { + return value >= 0 + } + return value > 0 +} + +func savingsWindowDays(startTimestamp int64, endTimestamp int64) int { + if endTimestamp <= startTimestamp { + return 0 + } + return int((endTimestamp - startTimestamp + 24*3600 - 1) / (24 * 3600)) +} diff --git a/service/savings_estimate_test.go b/service/savings_estimate_test.go new file mode 100644 index 000000000000..aa309c584740 --- /dev/null +++ b/service/savings_estimate_test.go @@ -0,0 +1,561 @@ +package service + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/pkg/billingexpr" + "github.com/QuantumNous/new-api/pkg/cachex" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/setting/savings_setting" + "github.com/QuantumNous/new-api/types" + + "github.com/samber/hot" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/singleflight" +) + +func TestLoadCachedSavingsResultReusesCachedValue(t *testing.T) { + cache := cachex.NewHybridCache[int](cachex.HybridCacheConfig[int]{ + Namespace: "test:user_savings", + Memory: func() *hot.HotCache[string, int] { + return hot.NewHotCache[string, int](hot.LRU, 2).Build() + }, + }) + var group singleflight.Group + loadCalls := 0 + load := func() (int, error) { + loadCalls++ + return 42, nil + } + + first, err := loadCachedSavingsResult(cache, &group, "same-window", load) + require.NoError(t, err) + second, err := loadCachedSavingsResult(cache, &group, "same-window", load) + require.NoError(t, err) + + assert.Equal(t, 42, first) + assert.Equal(t, 42, second) + assert.Equal(t, 1, loadCalls) +} + +func TestBuildUserSavingsCacheKeyTracksNormalizedSetting(t *testing.T) { + setting := savings_setting.Setting{ + Enabled: true, + OfficialPrices: map[string]savings_setting.OfficialPrice{ + "model-b": {Source: "source-b"}, + "model-a": {Source: "source-a"}, + }, + } + key, err := buildUserSavingsCacheKey(savingsSummaryCacheKind, 7, 100, 200, "", 0, setting) + require.NoError(t, err) + + reordered := setting + reordered.OfficialPrices = map[string]savings_setting.OfficialPrice{ + "model-a": {Source: "source-a"}, + "model-b": {Source: "source-b"}, + } + reorderedKey, err := buildUserSavingsCacheKey(savingsSummaryCacheKind, 7, 100, 200, "", 0, reordered) + require.NoError(t, err) + assert.Equal(t, key, reorderedKey) + + reordered.ShowOnDashboard = true + changedKey, err := buildUserSavingsCacheKey(savingsSummaryCacheKind, 7, 100, 200, "", 0, reordered) + require.NoError(t, err) + assert.NotEqual(t, key, changedKey) +} + +func TestBuildTextSavingsEstimateSkipsWhenDisabled(t *testing.T) { + require.NoError(t, savings_setting.UpdateSettingByJSONString("")) + t.Cleanup(func() { require.NoError(t, savings_setting.UpdateSettingByJSONString("")) }) + + result := buildTextSavingsEstimate(nil, savingsRelayInfo("gpt-4o-mini"), savingsSummary(100)) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipDisabled, result.SkipReason) +} + +func TestBuildTextSavingsEstimateUsesConfirmedOfficialPrice(t *testing.T) { + configureSavingsSetting(t, map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + ModelRatio: float64Ptr(2), + CompletionRatio: float64Ptr(3), + Source: "OpenAI", + SourceURL: "https://openai.com/api/pricing", + SourceUpdatedAt: 1_700_000_000, + OfficialConfirmed: true, + }, + }) + + result := buildTextSavingsEstimate(nil, savingsRelayInfo("gpt-4o-mini"), savingsSummary(100)) + + require.Empty(t, result.SkipReason) + require.NotNil(t, result.Estimate) + require.Equal(t, 260, result.Estimate.OfficialQuota) + require.Equal(t, 100, result.Estimate.ActualQuota) + require.Equal(t, 160, result.Estimate.SavingsQuota) + require.Equal(t, "OpenAI", result.Estimate.Source) + require.Equal(t, "https://openai.com/api/pricing", result.Estimate.SourceURL) + require.Equal(t, "gpt-4o-mini", result.Estimate.MatchedModel) + require.True(t, result.Estimate.OfficialConfirmed) + require.Equal(t, savingsCalculationSnapshot, result.Estimate.CalculationMode) + require.NotEmpty(t, result.Estimate.PriceFingerprint) +} + +func TestBuildTextSavingsEstimateSkipsUnconfirmedOfficialPrice(t *testing.T) { + configureSavingsSetting(t, map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + ModelRatio: float64Ptr(1), + CompletionRatio: float64Ptr(1), + SourceUpdatedAt: 1_700_000_000, + }, + }) + + result := buildTextSavingsEstimate(nil, savingsRelayInfo("gpt-4o-mini"), savingsSummary(100)) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipUnconfirmedOfficialPrice, result.SkipReason) +} + +func TestBuildTextSavingsEstimateSkipsUnsupportedBillingModes(t *testing.T) { + configureSavingsSetting(t, map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + ModelRatio: float64Ptr(1), + CompletionRatio: float64Ptr(1), + SourceUpdatedAt: 1_700_000_000, + OfficialConfirmed: true, + }, + }) + + t.Run("fixed price billing", func(t *testing.T) { + relayInfo := savingsRelayInfo("gpt-4o-mini") + relayInfo.PriceData.UsePrice = true + + result := buildTextSavingsEstimate(nil, relayInfo, savingsSummary(100)) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipUnsupportedBillingMode, result.SkipReason) + }) + + t.Run("other ratios", func(t *testing.T) { + relayInfo := savingsRelayInfo("gpt-4o-mini") + relayInfo.PriceData.AddOtherRatio("n", 2) + + result := buildTextSavingsEstimate(nil, relayInfo, savingsSummary(100)) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipUnknownExtraRatio, result.SkipReason) + }) + + t.Run("tool surcharges", func(t *testing.T) { + summary := savingsSummary(100) + summary.ToolSurchargeItems = []ToolSurchargeItem{{ + Name: "web_search", + Count: 1, + Price: 10, + }} + + result := buildTextSavingsEstimate(nil, savingsRelayInfo("gpt-4o-mini"), summary) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipUnknownExtraRatio, result.SkipReason) + }) +} + +func TestBuildTextSavingsEstimateSupportsTieredActualAndOfficialPricing(t *testing.T) { + configureSavingsSetting(t, map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + BillingMode: "tiered_expr", + BillingExpr: `tier("base", p * 2 + c * 4)`, + Source: "OpenAI", + OfficialConfirmed: true, + }, + }) + relayInfo := savingsRelayInfo("gpt-4o-mini") + relayInfo.TieredBillingSnapshot = &billingexpr.BillingSnapshot{BillingMode: "tiered_expr"} + + result := buildTextSavingsEstimate(nil, relayInfo, savingsSummary(40)) + + require.Empty(t, result.SkipReason) + require.NotNil(t, result.Estimate) + require.Equal(t, 120, result.Estimate.OfficialQuota) + require.Equal(t, 40, result.Estimate.ActualQuota) + require.Equal(t, 80, result.Estimate.SavingsQuota) + require.Equal(t, "tiered_expr", result.Estimate.PricingMode) +} + +func TestBuildTextSavingsEstimateRejectsRequestDependentOfficialExpression(t *testing.T) { + configureSavingsSetting(t, map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + BillingMode: "tiered_expr", + BillingExpr: `tier("base", p * 2 + c * 4) * (header("x-fast") == "1" ? 2 : 1)`, + Source: "OpenAI", + OfficialConfirmed: true, + }, + }) + + result := buildTextSavingsEstimate(nil, savingsRelayInfo("gpt-4o-mini"), savingsSummary(40)) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipUnsupportedBillingMode, result.SkipReason) +} + +func TestBuildTextSavingsEstimateNeverReturnsNegativeSavings(t *testing.T) { + configureSavingsSetting(t, map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + ModelRatio: float64Ptr(1), + CompletionRatio: float64Ptr(1), + SourceUpdatedAt: 1_700_000_000, + OfficialConfirmed: true, + }, + }) + + result := buildTextSavingsEstimate(nil, savingsRelayInfo("gpt-4o-mini"), savingsSummary(500)) + + require.NotNil(t, result.Estimate) + require.Equal(t, 110, result.Estimate.OfficialQuota) + require.Equal(t, 500, result.Estimate.ActualQuota) + require.Equal(t, 0, result.Estimate.SavingsQuota) +} + +func TestSavingsEstimateFromOtherRejectsInvalidPayloads(t *testing.T) { + require.Nil(t, savingsEstimateFromOther("")) + require.Nil(t, savingsEstimateFromOther("{bad json")) + require.Nil(t, savingsEstimateFromOther(common.MapToJsonStr(map[string]any{ + "savings_estimate": SavingsEstimate{SchemaVersion: 999}, + }))) + require.Nil(t, savingsEstimateFromOther(common.MapToJsonStr(map[string]any{ + "savings_estimate": SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: -1, + }, + }))) + require.Nil(t, savingsEstimateFromOther(common.MapToJsonStr(map[string]any{ + "savings_estimate": SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 200, + ActualQuota: 80, + SavingsQuota: 121, + }, + }))) + + other := common.MapToJsonStr(map[string]any{ + "savings_estimate": SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 200, + ActualQuota: 80, + SavingsQuota: 120, + }, + }) + + estimate := savingsEstimateFromOther(other) + require.NotNil(t, estimate) + require.Equal(t, 120, estimate.SavingsQuota) +} + +func TestMatchSavingsOfficialPriceUsesLocalPricingFallback(t *testing.T) { + setting := savings_setting.Setting{ + LocalPricingOfficialConfirmed: true, + RequireOfficialConfirmation: true, + OfficialPrices: map[string]savings_setting.OfficialPrice{}, + } + localPrices := map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + ModelRatio: float64Ptr(1), + CompletionRatio: float64Ptr(2), + Source: savingsSourceLocalPricing, + PriceSnapshotAt: 1_700_000_000, + OfficialConfirmed: true, + }, + } + + price, matchedModel, skipReason := matchSavingsOfficialPriceCandidates( + setting, + []string{"gpt-4o-mini"}, + localPrices, + 1_700_000_000, + ) + + require.Empty(t, skipReason) + require.Equal(t, "gpt-4o-mini", matchedModel) + require.Equal(t, savingsSourceLocalPricing, price.Source) + require.NotEmpty(t, price.PriceFingerprint) +} + +func TestSavingsPriceFingerprintTracksPricingFields(t *testing.T) { + first := savings_setting.OfficialPrice{ + ModelRatio: float64Ptr(1), + CompletionRatio: float64Ptr(2), + } + second := first + require.True(t, finalizeSavingsOfficialPrice(&first, "gpt-4o-mini")) + require.True(t, finalizeSavingsOfficialPrice(&second, "gpt-4o-mini")) + require.Equal(t, first.PriceFingerprint, second.PriceFingerprint) + + second.CompletionRatio = float64Ptr(3) + require.True(t, finalizeSavingsOfficialPrice(&second, "gpt-4o-mini")) + require.NotEqual(t, first.PriceFingerprint, second.PriceFingerprint) +} + +func TestRebuildHistoricalSavingsEstimateRequiresActualQuotaMatch(t *testing.T) { + setting := savings_setting.Setting{ + RequireOfficialConfirmation: true, + OfficialPrices: map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + ModelRatio: float64Ptr(2), + CompletionRatio: float64Ptr(3), + Source: "OpenAI", + OfficialConfirmed: true, + }, + }, + } + row := model.SavingsLogRow{ + ModelName: "gpt-4o-mini", + PromptTokens: 100, + CompletionTokens: 11, + Quota: 56, + Other: common.MapToJsonStr(map[string]any{ + "model_ratio": 0.5, + "group_ratio": 1.0, + "completion_ratio": 1.0, + "model_price": -1.0, + "cache_tokens": 0, + "cache_ratio": 0.5, + }), + } + + result := rebuildHistoricalSavingsEstimate(setting, nil, row, 1_700_000_000) + + require.Empty(t, result.SkipReason) + require.NotNil(t, result.Estimate) + require.Equal(t, 266, result.Estimate.OfficialQuota) + require.Equal(t, 56, result.Estimate.ActualQuota) + require.Equal(t, 210, result.Estimate.SavingsQuota) + require.Equal(t, savingsCalculationHistorical, result.Estimate.CalculationMode) + + row.Quota = 57 + result = rebuildHistoricalSavingsEstimate(setting, nil, row, 1_700_000_000) + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipLegacyActualQuotaMismatch, result.SkipReason) +} + +func TestRebuildHistoricalSavingsEstimateValidatesTieredLogSnapshot(t *testing.T) { + setting := savings_setting.Setting{ + RequireOfficialConfirmation: true, + OfficialPrices: map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": { + BillingMode: "tiered_expr", + BillingExpr: `tier("base", p * 2 + c * 4 + cr * 0.2)`, + Source: "OpenAI", + OfficialConfirmed: true, + }, + }, + } + actualExpr := `tier("base", p * 1 + c * 2 + cr * 0.1)` + row := model.SavingsLogRow{ + ModelName: "gpt-4o-mini", + PromptTokens: 100, + CompletionTokens: 10, + Quota: 26, + Other: common.MapToJsonStr(map[string]any{ + "billing_mode": "tiered_expr", + "expr_b64": base64.StdEncoding.EncodeToString([]byte(actualExpr)), + "group_ratio": 0.5, + "cache_tokens": 20, + }), + } + + result := rebuildHistoricalSavingsEstimate(setting, nil, row, 1_700_000_000) + + require.Empty(t, result.SkipReason) + require.NotNil(t, result.Estimate) + require.Equal(t, 102, result.Estimate.OfficialQuota) + require.Equal(t, 26, result.Estimate.ActualQuota) + require.Equal(t, 76, result.Estimate.SavingsQuota) + require.Equal(t, "tiered_expr", result.Estimate.PricingMode) + + row.Quota = 27 + result = rebuildHistoricalSavingsEstimate(setting, nil, row, 1_700_000_000) + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipLegacyActualQuotaMismatch, result.SkipReason) +} + +func TestRebuildHistoricalSavingsEstimateRejectsMissingBaseFields(t *testing.T) { + result := rebuildHistoricalSavingsEstimate( + savings_setting.Setting{}, + nil, + model.SavingsLogRow{ + ModelName: "gpt-4o-mini", + PromptTokens: 100, + CompletionTokens: 10, + Quota: 55, + Other: `{}`, + }, + 1_700_000_000, + ) + + require.Nil(t, result.Estimate) + require.Equal(t, SavingsSkipLegacyMissingBaseFields, result.SkipReason) +} + +func TestNormalizeSavingsSummaryWindowClampsSmallClockSkew(t *testing.T) { + now := time.Now().Unix() + effectiveEnd, err := NormalizeSavingsSummaryWindow(now-3600, now+60) + + require.NoError(t, err) + require.InDelta(t, time.Now().Unix(), effectiveEnd, 1) + + _, err = NormalizeSavingsSummaryWindow(now-3600, now+5*60+10) + require.ErrorIs(t, err, ErrSavingsEndAfterNow) +} + +func TestBuildSavingsTrendBucketsAlignsToLocalCalendar(t *testing.T) { + const ( + localOffsetMinutes = 8 * 60 + localMidnightUTC = int64(1_785_081_600) + ) + start := localMidnightUTC + 30*60 + end := localMidnightUTC + 2*24*3600 + 30*60 + + buckets, bucketSize, err := buildSavingsTrendBuckets( + start, + end, + SavingsTrendGranularityDay, + localOffsetMinutes, + ) + + require.NoError(t, err) + require.Equal(t, int64(24*3600), bucketSize) + require.Len(t, buckets, 3) + require.Equal(t, localMidnightUTC, buckets[0].StartTimestamp) + require.Equal(t, localMidnightUTC+3*24*3600, buckets[2].EndTimestamp) +} + +func TestBuildSavingsTrendBucketsRejectsInvalidBoundaries(t *testing.T) { + _, _, err := buildSavingsTrendBuckets(1_000, 2_000, "minute", 0) + require.ErrorIs(t, err, ErrSavingsGranularity) + + _, _, err = buildSavingsTrendBuckets(1_000, 2_000, SavingsTrendGranularityHour, 841) + require.ErrorIs(t, err, ErrSavingsUTCOffsetInvalid) + + _, _, err = buildSavingsTrendBuckets(1_000, 1_000+49*3600, SavingsTrendGranularityHour, 0) + require.ErrorIs(t, err, ErrSavingsHourRangeTooLarge) +} + +func TestSavingsTrendAggregationPreservesPerRequestNonNegativeSavings(t *testing.T) { + summary := &SavingsSummary{RequestCount: 2, Source: "official_snapshot"} + accumulator := newSavingsSummaryAccumulator(summary, 1_700_000_000, 90) + bucket := SavingsTrendBucket{RequestCount: 2} + results := []savingsLogEstimate{ + { + Estimate: &SavingsEstimate{ + OfficialQuota: 100, + ActualQuota: 40, + SavingsQuota: 60, + Source: savingsSourceLocalPricing, + OfficialConfirmed: true, + }, + CalculationMode: savingsCalculationSnapshot, + }, + { + Estimate: &SavingsEstimate{ + OfficialQuota: 50, + ActualQuota: 80, + SavingsQuota: 0, + Source: savingsSourceOfficialOverride, + OfficialConfirmed: true, + }, + CalculationMode: savingsCalculationHistorical, + }, + } + + for _, result := range results { + require.NoError(t, accumulator.add(result)) + require.NoError(t, addSavingsTrendBucket(&bucket, result)) + } + accumulator.finish() + bucket.CoverageRatio = float64(bucket.EstimatedRequestCount) / float64(bucket.RequestCount) + + require.Equal(t, int64(150), summary.OfficialQuota) + require.Equal(t, int64(120), summary.ActualQuota) + require.Equal(t, int64(60), summary.SavingsQuota) + require.NotEqual(t, summary.OfficialQuota-summary.ActualQuota, summary.SavingsQuota) + require.Equal(t, int64(1), summary.SnapshotRequestCount) + require.Equal(t, int64(1), summary.ReconstructedRequestCount) + require.Equal(t, savingsSourceMixed, summary.Source) + require.Equal(t, float64(1), summary.CoverageRatio) + require.Equal(t, summary.OfficialQuota, bucket.OfficialQuota) + require.Equal(t, summary.ActualQuota, bucket.ActualQuota) + require.Equal(t, summary.SavingsQuota, bucket.SavingsQuota) + require.Equal(t, float64(1), bucket.CoverageRatio) +} + +func TestNormalizeSavingsTrendWindowValidatesGranularityAndClockSkew(t *testing.T) { + now := time.Now().Unix() + effectiveEnd, err := NormalizeSavingsTrendWindow( + now-24*3600, + now+60, + SavingsTrendGranularityHour, + 8*60, + ) + + require.NoError(t, err) + require.InDelta(t, time.Now().Unix(), effectiveEnd, 1) + + _, err = NormalizeSavingsTrendWindow(now-3600, now, "minute", 8*60) + require.ErrorIs(t, err, ErrSavingsGranularity) +} + +func configureSavingsSetting(t *testing.T, prices map[string]savings_setting.OfficialPrice) { + t.Helper() + require.NoError(t, savings_setting.UpdateSettingByJSONString("")) + t.Cleanup(func() { require.NoError(t, savings_setting.UpdateSettingByJSONString("")) }) + + value := savings_setting.Setting{ + Enabled: true, + ShowOnDashboard: true, + ShowOnUsageLogs: true, + LocalPricingOfficialConfirmed: false, + RebuildLegacyLogs: true, + RequireOfficialConfirmation: true, + OfficialPriceStaleDays: 90, + MaxSummaryDays: 31, + MaxSummaryLogRows: 50000, + OfficialPrices: prices, + } + jsonBytes, err := common.Marshal(value) + require.NoError(t, err) + require.NoError(t, savings_setting.UpdateSettingByJSONString(string(jsonBytes))) +} + +func savingsRelayInfo(modelName string) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + OriginModelName: modelName, + PriceData: types.PriceData{ + ModelRatio: 0.5, + CompletionRatio: 1, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 1, + }, + }, + StartTime: time.Now(), + } +} + +func savingsSummary(actualQuota int) textQuotaSummary { + return textQuotaSummary{ + PromptTokens: 100, + CompletionTokens: 10, + TotalTokens: 110, + Quota: actualQuota, + ModelName: "gpt-4o-mini", + } +} + +func float64Ptr(value float64) *float64 { + return &value +} diff --git a/service/savings_lifetime_backfill.go b/service/savings_lifetime_backfill.go new file mode 100644 index 000000000000..db631684460c --- /dev/null +++ b/service/savings_lifetime_backfill.go @@ -0,0 +1,485 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "math" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/savings_setting" + + "github.com/shopspring/decimal" +) + +type SavingsLifetimeBackfillPayload struct { + Target model.SavingsLifetimeLogCursor `json:"target"` + TargetCount int64 `json:"target_count"` + BatchSize int `json:"batch_size"` + PriceSnapshotAt int64 `json:"price_snapshot_at"` + PricingSnapshotHash string `json:"pricing_snapshot_hash"` + Setting savings_setting.Setting `json:"setting"` + QuotaPerUnit int64 `json:"quota_per_unit_snapshot"` + USDCNYRateMicros int64 `json:"usd_cny_rate_micros"` +} + +type SavingsLifetimeBackfillState struct { + Cursor model.SavingsLifetimeLogCursor `json:"cursor"` + ProcessedCount int64 `json:"processed_count"` + EstimatedCount int64 `json:"estimated_count"` + SkippedCount int64 `json:"skipped_count"` + AmbiguousCursorCount int64 `json:"ambiguous_cursor_count"` + Progress float64 `json:"progress"` +} + +type SavingsLifetimeBackfillResult struct { + ProcessedCount int64 `json:"processed_count"` + EstimatedCount int64 `json:"estimated_count"` + SkippedCount int64 `json:"skipped_count"` + AmbiguousCursorCount int64 `json:"ambiguous_cursor_count"` +} + +type SavingsLifetimeSummary struct { + Enabled bool `json:"enabled"` + ShowOnDashboard bool `json:"show_on_dashboard"` + ShowOnWallet bool `json:"show_on_wallet"` + Currency string `json:"currency"` + SavingsCNYMicros string `json:"savings_cny_micros"` + SavingsQuota string `json:"savings_quota"` + OfficialQuota string `json:"official_quota"` + ActualQuota string `json:"actual_quota"` + RequestCount int64 `json:"request_count"` + EstimatedRequestCount int64 `json:"estimated_request_count"` + SnapshotRequestCount int64 `json:"snapshot_request_count"` + ReconstructedRequestCount int64 `json:"reconstructed_request_count"` + CoverageRatio float64 `json:"coverage_ratio"` + StatisticsStartedAt int64 `json:"statistics_started_at"` + LastAggregatedAt int64 `json:"last_aggregated_at"` + BackfillStatus string `json:"backfill_status"` + BackfillProgress float64 `json:"backfill_progress"` + IsComplete bool `json:"is_complete"` +} + +type savingsLifetimeBackfillHandler struct{} + +func (savingsLifetimeBackfillHandler) Type() string { + return model.SystemTaskTypeSavingsBackfill +} + +func init() { + RegisterSystemTaskHandler(savingsLifetimeBackfillHandler{}) +} + +func StartSavingsLifetimeBackfill() (*model.SystemTask, bool, error) { + if !savings_setting.LifetimeEnabled() { + return nil, false, errors.New("savings lifetime estimate is not enabled") + } + target, total, err := model.GetSavingsLifetimeLogBoundary() + if err != nil { + return nil, false, err + } + setting := savings_setting.GetSetting() + priceSnapshotAt := common.GetTimestamp() + prices := buildLocalSavingsPriceMap(setting, priceSnapshotAt) + for modelName, price := range setting.OfficialPrices { + prices[modelName] = price + } + setting.OfficialPrices = prices + setting.LocalPricingOfficialConfirmed = false + pricingJSON, err := common.Marshal(prices) + if err != nil { + return nil, false, err + } + pricingHash := sha256.Sum256(pricingJSON) + quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit).Round(0) + rateMicros := decimal.NewFromFloat(operation_setting.USDExchangeRate). + Mul(decimal.NewFromInt(1_000_000)). + Round(0) + maxInt64 := decimal.NewFromInt(math.MaxInt64) + if quotaPerUnit.LessThanOrEqual(decimal.Zero) || rateMicros.LessThanOrEqual(decimal.Zero) || + quotaPerUnit.GreaterThan(maxInt64) || rateMicros.GreaterThan(maxInt64) { + return nil, false, errors.New("invalid savings lifetime currency snapshot") + } + payload := SavingsLifetimeBackfillPayload{ + Target: target, + TargetCount: total, + BatchSize: savings_setting.LifetimeBackfillBatchSize(), + PriceSnapshotAt: priceSnapshotAt, + PricingSnapshotHash: hex.EncodeToString(pricingHash[:]), + Setting: setting, + QuotaPerUnit: quotaPerUnit.IntPart(), + USDCNYRateMicros: rateMicros.IntPart(), + } + return EnqueueSystemTask(model.SystemTaskTypeSavingsBackfill, payload) +} + +func PauseSavingsLifetimeBackfill(taskID string) (*model.SystemTask, error) { + task, err := savingsLifetimeBackfillTask(taskID) + if err != nil { + return nil, err + } + return model.RequestSystemTaskPause(task.TaskID, model.SystemTaskTypeSavingsBackfill) +} + +func ResumeSavingsLifetimeBackfill(taskID string) (*model.SystemTask, error) { + task, err := savingsLifetimeBackfillTask(taskID) + if err != nil { + return nil, err + } + resumed, err := model.ResumeSystemTask(task.TaskID, model.SystemTaskTypeSavingsBackfill) + if err != nil { + return nil, err + } + notifySystemTaskRunner() + return resumed, nil +} + +func RetrySavingsLifetimeBackfill(taskID string) (*model.SystemTask, error) { + task, err := savingsLifetimeBackfillTask(taskID) + if err != nil { + return nil, err + } + retried, err := model.RetryFailedSystemTask(task.TaskID, model.SystemTaskTypeSavingsBackfill) + if err != nil { + return nil, err + } + notifySystemTaskRunner() + return retried, nil +} + +func savingsLifetimeBackfillTask(taskID string) (*model.SystemTask, error) { + var task *model.SystemTask + var err error + if strings.TrimSpace(taskID) == "" { + task, err = model.GetLatestSystemTask(model.SystemTaskTypeSavingsBackfill) + } else { + task, err = model.GetSystemTaskByTaskID(taskID) + } + if err != nil { + return nil, err + } + if task == nil || task.Type != model.SystemTaskTypeSavingsBackfill { + return nil, errors.New("savings lifetime backfill task not found") + } + return task, nil +} + +func GetUserSavingsLifetimeSummary(userID int) (*SavingsLifetimeSummary, error) { + setting := savings_setting.GetSetting() + summary := &SavingsLifetimeSummary{ + Enabled: setting.Enabled && setting.LifetimeEnabled, + ShowOnDashboard: setting.Enabled && setting.LifetimeEnabled && setting.LifetimeShowOnDashboard, + ShowOnWallet: setting.Enabled && setting.LifetimeEnabled && setting.LifetimeShowOnWallet, + Currency: "CNY", + } + if !summary.Enabled { + return summary, nil + } + total, err := model.GetSavingsLifetimeTotal(userID) + if err != nil { + return nil, err + } + summary.SavingsCNYMicros = strconv.FormatInt(total.SavingsCNYMicros, 10) + summary.SavingsQuota = strconv.FormatInt(total.SavingsQuota, 10) + summary.OfficialQuota = strconv.FormatInt(total.OfficialQuota, 10) + summary.ActualQuota = strconv.FormatInt(total.ActualQuota, 10) + summary.RequestCount = total.RequestCount + summary.EstimatedRequestCount = total.EstimatedRequestCount + summary.SnapshotRequestCount = total.SnapshotRequestCount + summary.ReconstructedRequestCount = total.ReconstructedRequestCount + summary.StatisticsStartedAt = total.StatisticsStartedAt + summary.LastAggregatedAt = total.LastAggregatedAt + if total.RequestCount > 0 { + summary.CoverageRatio = float64(total.EstimatedRequestCount) / float64(total.RequestCount) + } + + task, err := model.GetLatestSystemTask(model.SystemTaskTypeSavingsBackfill) + if err != nil { + return nil, err + } + summary.BackfillStatus = "not_started" + if task != nil { + summary.BackfillStatus = string(task.Status) + state := SavingsLifetimeBackfillState{} + if task.DecodeState(&state) == nil { + summary.BackfillProgress = state.Progress + } + if task.Status == model.SystemTaskStatusSucceeded { + summary.BackfillStatus = "completed" + summary.BackfillProgress = 1 + } + } + if summary.BackfillStatus == "completed" { + hasPending, err := model.HasPendingSavingsLifetimeEvents() + if err != nil { + return nil, err + } + summary.IsComplete = !hasPending + } + return summary, nil +} + +func (savingsLifetimeBackfillHandler) Run(ctx context.Context, task *model.SystemTask, runnerID string) { + payload := SavingsLifetimeBackfillPayload{} + if err := task.DecodePayload(&payload); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + if err := validateSavingsLifetimeBackfillPayload(payload); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + if err := model.CheckSavingsLifetimeSQLiteIntegrity(ctx); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + state := SavingsLifetimeBackfillState{} + if strings.TrimSpace(task.State) != "" { + if err := task.DecodeState(&state); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + } + if payload.BatchSize <= 0 { + payload.BatchSize = 1000 + } + estimator := savingsLogEstimator{setting: payload.Setting, priceSnapshotAt: payload.PriceSnapshotAt} + for { + paused, err := pauseSavingsLifetimeBackfillIfRequested(task, runnerID) + if err != nil { + common.SysError(fmt.Sprintf("failed to pause savings lifetime backfill task %s: %v", task.TaskID, err)) + return + } + if paused { + return + } + if err := ctx.Err(); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + rows, err := model.GetSavingsLifetimeLogBatch(state.Cursor, payload.Target, payload.BatchSize) + if err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, fmt.Errorf("read savings lifetime log batch: %w", err)) + return + } + if len(rows) == 0 { + break + } + ambiguousCount, err := model.CountSavingsLifetimeClickHouseCursorAmbiguity(rows) + if err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, fmt.Errorf("count ClickHouse cursor ambiguity: %w", err)) + return + } + events := make([]model.SavingsLifetimeEvent, 0, len(rows)) + for i := range rows { + event, estimated, err := buildSavingsLifetimeBackfillEvent(&rows[i], estimator, payload) + if err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, err) + return + } + events = append(events, event) + state.ProcessedCount++ + if estimated { + state.EstimatedCount++ + } else { + state.SkippedCount++ + } + } + state.ProcessedCount += ambiguousCount + state.SkippedCount += ambiguousCount + state.AmbiguousCursorCount += ambiguousCount + if err := model.CreateSavingsLifetimeEvents(events); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, fmt.Errorf("write savings lifetime events: %w", err)) + return + } + for { + processed, err := aggregateSavingsLifetimePending(payload.BatchSize) + if err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, fmt.Errorf("aggregate savings lifetime events: %w", err)) + return + } + if processed == 0 { + break + } + } + last := rows[len(rows)-1] + state.Cursor = model.SavingsLifetimeLogCursor{ID: last.Id, CreatedAt: last.CreatedAt, RequestID: last.RequestId} + if payload.TargetCount > 0 { + state.Progress = math.Min(float64(state.ProcessedCount)/float64(payload.TargetCount), 1) + } + if err := model.UpdateSystemTaskState(task.TaskID, runnerID, state); err != nil { + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusFailed, nil, fmt.Errorf("save savings lifetime backfill cursor: %w", err)) + return + } + paused, err = pauseSavingsLifetimeBackfillIfRequested(task, runnerID) + if err != nil { + common.SysError(fmt.Sprintf("failed to pause savings lifetime backfill task %s: %v", task.TaskID, err)) + return + } + if paused { + return + } + } + result := SavingsLifetimeBackfillResult{ + ProcessedCount: state.ProcessedCount, + EstimatedCount: state.EstimatedCount, + SkippedCount: state.SkippedCount, + AmbiguousCursorCount: state.AmbiguousCursorCount, + } + finishSavingsLifetimeBackfill(task, runnerID, model.SystemTaskStatusSucceeded, result, nil) +} + +func pauseSavingsLifetimeBackfillIfRequested(task *model.SystemTask, runnerID string) (bool, error) { + current, err := model.GetSystemTaskByTaskID(task.TaskID) + if err != nil { + return false, err + } + if current == nil || current.Status != model.SystemTaskStatusPauseRequested { + return false, nil + } + if err := model.CompleteSystemTaskPause(task.TaskID, runnerID); err != nil { + return false, err + } + return true, nil +} + +func validateSavingsLifetimeBackfillPayload(payload SavingsLifetimeBackfillPayload) error { + if payload.QuotaPerUnit <= 0 || payload.USDCNYRateMicros <= 0 { + return errors.New("invalid savings lifetime currency snapshot") + } + pricingJSON, err := common.Marshal(payload.Setting.OfficialPrices) + if err != nil { + return err + } + pricingHash := sha256.Sum256(pricingJSON) + if hex.EncodeToString(pricingHash[:]) != payload.PricingSnapshotHash { + return errors.New("savings lifetime pricing snapshot hash mismatch") + } + return nil +} + +func buildSavingsLifetimeBackfillEvent(row *model.SavingsLifetimeLogRow, estimator savingsLogEstimator, payload SavingsLifetimeBackfillPayload) (model.SavingsLifetimeEvent, bool, error) { + sourceKey, err := savingsLifetimeSourceKey(row) + if err != nil { + return model.SavingsLifetimeEvent{}, false, err + } + result := estimator.estimate(model.SavingsLogRow{ + Id: row.Id, + CreatedAt: row.CreatedAt, + ModelName: row.ModelName, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + Quota: row.Quota, + Other: row.Other, + }) + event := model.SavingsLifetimeEvent{ + EventKey: "log:" + sourceKey + ":base", + SourceKey: sourceKey, + LogID: int64(row.Id), + UserID: row.UserId, + OccurredAt: row.CreatedAt, + DayStartUTC: row.CreatedAt / (24 * 60 * 60) * (24 * 60 * 60), + EventType: model.SavingsLifetimeEventTypeBase, + CoverageState: model.SavingsLifetimeCoverageSkipped, + SkipReason: result.SkipReason, + AggregateVersion: 1, + } + if result.Estimate == nil { + if event.SkipReason == "" { + event.SkipReason = SavingsSkipInvalidSnapshot + } + return event, false, nil + } + estimate := result.Estimate + amountMicros, frozenUsed := savingsLifetimeFrozenAmount(estimate) + if frozenUsed { + event.QuotaPerUnitSnapshot = estimate.QuotaPerUnit + event.USDCNYRateMicros = estimate.USDCNYRateMicros + } else { + converted, err := savingsLifetimeAmountMicros(int64(estimate.SavingsQuota), payload.QuotaPerUnit, payload.USDCNYRateMicros) + if err != nil { + return model.SavingsLifetimeEvent{}, false, err + } + amountMicros = converted + event.QuotaPerUnitSnapshot = payload.QuotaPerUnit + event.USDCNYRateMicros = payload.USDCNYRateMicros + } + event.CoverageState = model.SavingsLifetimeCoverageEstimated + event.SkipReason = "" + if result.CalculationMode == savingsCalculationHistorical { + event.CalculationMode = model.SavingsLifetimeCalculationRebuild + } else { + event.CalculationMode = model.SavingsLifetimeCalculationSnapshot + } + event.OfficialQuota = int64(estimate.OfficialQuota) + event.ActualQuota = int64(estimate.ActualQuota) + event.SavingsQuota = int64(estimate.SavingsQuota) + event.SavingsCNYMicros = amountMicros + event.PriceSnapshotAt = estimate.PriceSnapshotAt + event.PriceFingerprint = estimate.PriceFingerprint + return event, true, nil +} + +func savingsLifetimeSourceKey(row *model.SavingsLifetimeLogRow) (string, error) { + var other savingsLogOther + if common.UnmarshalJsonStr(row.Other, &other) == nil && strings.TrimSpace(other.AggregationKey) != "" { + return other.AggregationKey, nil + } + if row.Id > 0 { + return fmt.Sprintf("db:%d", row.Id), nil + } + data, err := common.Marshal(struct { + RequestID string `json:"request_id"` + UserID int `json:"user_id"` + CreatedAt int64 `json:"created_at"` + ModelName string `json:"model_name"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + Quota int `json:"quota"` + Other string `json:"other"` + }{ + RequestID: row.RequestId, + UserID: row.UserId, + CreatedAt: row.CreatedAt, + ModelName: row.ModelName, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + Quota: row.Quota, + Other: row.Other, + }) + if err != nil { + return "", err + } + hash := sha256.Sum256(data) + return "legacy-ch:" + hex.EncodeToString(hash[:]), nil +} + +func savingsLifetimeAmountMicros(savingsQuota int64, quotaPerUnit int64, rateMicros int64) (int64, error) { + if savingsQuota < 0 || quotaPerUnit <= 0 || rateMicros <= 0 { + return 0, errors.New("invalid savings lifetime currency input") + } + amount := decimal.NewFromInt(savingsQuota). + Mul(decimal.NewFromInt(rateMicros)). + Div(decimal.NewFromInt(quotaPerUnit)). + Round(0) + if amount.GreaterThan(decimal.NewFromInt(math.MaxInt64)) { + return 0, errors.New("savings lifetime currency amount overflow") + } + return amount.IntPart(), nil +} + +func finishSavingsLifetimeBackfill(task *model.SystemTask, runnerID string, status model.SystemTaskStatus, result any, runErr error) { + errorMessage := "" + if runErr != nil { + errorMessage = runErr.Error() + } + if err := model.FinishSystemTask(task.TaskID, runnerID, status, result, errorMessage); err != nil { + common.SysError(fmt.Sprintf("failed to finish savings lifetime backfill task %s: %v", task.TaskID, err)) + } +} diff --git a/service/savings_lifetime_test.go b/service/savings_lifetime_test.go new file mode 100644 index 000000000000..97250c87f685 --- /dev/null +++ b/service/savings_lifetime_test.go @@ -0,0 +1,325 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "math" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/savings_setting" + + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupSavingsLifetimeServiceTest(t *testing.T) { + t.Helper() + previousDB := model.DB + previousType := common.MainDatabaseType() + previousQuotaPerUnit := common.QuotaPerUnit + previousRate := operation_setting.USDExchangeRate + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.SavingsLifetimeEvent{}, + &model.SavingsLifetimeDaily{}, + &model.SavingsLifetimeTotal{}, + &model.SystemTask{}, + )) + model.DB = db + common.SetMainDatabaseType(common.DatabaseTypeSQLite) + common.QuotaPerUnit = 500_000 + operation_setting.USDExchangeRate = 7.25 + require.NoError(t, savings_setting.UpdateSettingByJSONString(`{"enabled":true,"lifetime_enabled":true,"lifetime_show_on_dashboard":false,"lifetime_show_on_wallet":true}`)) + t.Cleanup(func() { + model.DB = previousDB + common.SetMainDatabaseType(previousType) + common.QuotaPerUnit = previousQuotaPerUnit + operation_setting.USDExchangeRate = previousRate + require.NoError(t, savings_setting.UpdateSettingByJSONString("")) + }) +} + +func TestAttachSavingsLifetimeSnapshotFreezesCurrencyInputs(t *testing.T) { + setupSavingsLifetimeServiceTest(t) + estimate := &SavingsEstimate{SavingsQuota: 1_000_000} + + attachSavingsLifetimeSnapshot(estimate, "savg_test") + + assert.Equal(t, "savg_test", estimate.AggregationKey) + assert.Equal(t, int64(500_000), estimate.QuotaPerUnit) + assert.Equal(t, int64(7_250_000), estimate.USDCNYRateMicros) + assert.Equal(t, "14500000", estimate.SavingsCNYMicros) +} + +func TestRecordSavingsLifetimeLogCreatesEstimatedAndSkippedEvents(t *testing.T) { + setupSavingsLifetimeServiceTest(t) + estimate := SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 500, + ActualQuota: 100, + SavingsQuota: 400, + Estimated: true, + AggregationKey: "savg_estimated", + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + SavingsCNYMicros: "5800", + PriceSnapshotAt: 1_700_000_000, + PriceFingerprint: "price-v1", + OfficialConfirmed: true, + } + require.NoError(t, RecordSavingsLifetimeLog(&model.Log{ + Id: 11, + UserId: 7, + CreatedAt: 1_785_081_610, + Other: common.MapToJsonStr(map[string]any{ + "savings_aggregation_key": "savg_estimated", + "savings_estimate": estimate, + }), + })) + require.NoError(t, RecordSavingsLifetimeLog(&model.Log{ + Id: 12, + UserId: 7, + CreatedAt: 1_785_081_620, + Other: common.MapToJsonStr(map[string]any{ + "savings_aggregation_key": "savg_skipped", + "admin_info": map[string]any{ + "savings_skip_reason": SavingsSkipMissingOfficialPrice, + }, + }), + })) + require.NoError(t, RecordSavingsLifetimeLog(&model.Log{ + Id: 11, + UserId: 7, + CreatedAt: 1_785_081_610, + Other: common.MapToJsonStr(map[string]any{ + "savings_aggregation_key": "savg_estimated", + "savings_estimate": estimate, + }), + })) + + var events []model.SavingsLifetimeEvent + require.NoError(t, model.DB.Order("log_id asc").Find(&events).Error) + require.Len(t, events, 2) + assert.Equal(t, model.SavingsLifetimeCoverageEstimated, events[0].CoverageState) + assert.Equal(t, int64(5_800), events[0].SavingsCNYMicros) + assert.Equal(t, model.SavingsLifetimeCoverageSkipped, events[1].CoverageState) + assert.Equal(t, SavingsSkipMissingOfficialPrice, events[1].SkipReason) +} + +func TestGetUserSavingsLifetimeSummarySeparatesFeatureAndPlacementFlags(t *testing.T) { + setupSavingsLifetimeServiceTest(t) + require.NoError(t, model.DB.Create(&model.SavingsLifetimeTotal{ + UserID: 7, + SavingsCNYMicros: 12_345_678, + RequestCount: 4, + EstimatedRequestCount: 3, + StatisticsStartedAt: 1_700_000_000, + LastAggregatedAt: 1_700_000_100, + }).Error) + + summary, err := GetUserSavingsLifetimeSummary(7) + + require.NoError(t, err) + assert.True(t, summary.Enabled) + assert.False(t, summary.ShowOnDashboard) + assert.True(t, summary.ShowOnWallet) + assert.Equal(t, "12345678", summary.SavingsCNYMicros) + assert.Equal(t, 0.75, summary.CoverageRatio) +} + +func TestGetUserSavingsLifetimeSummarySkipsPendingLookupBeforeBackfillCompletes(t *testing.T) { + setupSavingsLifetimeServiceTest(t) + require.NoError(t, model.DB.Migrator().DropTable(&model.SavingsLifetimeEvent{})) + + summary, err := GetUserSavingsLifetimeSummary(7) + + require.NoError(t, err) + assert.Equal(t, "not_started", summary.BackfillStatus) + assert.False(t, summary.IsComplete) +} + +func TestGetUserSavingsLifetimeSummaryChecksPendingEventsAfterBackfillCompletes(t *testing.T) { + setupSavingsLifetimeServiceTest(t) + require.NoError(t, model.DB.Create(&model.SystemTask{ + TaskID: "systask_savings_complete", + Type: model.SystemTaskTypeSavingsBackfill, + Status: model.SystemTaskStatusSucceeded, + }).Error) + + summary, err := GetUserSavingsLifetimeSummary(7) + require.NoError(t, err) + assert.True(t, summary.IsComplete) + + require.NoError(t, model.DB.Create(&model.SavingsLifetimeEvent{ + EventKey: "log:pending:base", + }).Error) + summary, err = GetUserSavingsLifetimeSummary(7) + + require.NoError(t, err) + assert.False(t, summary.IsComplete) +} + +func TestBuildSavingsLifetimeBackfillEventPrefersFrozenCurrencySnapshot(t *testing.T) { + estimate := SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 3, + ActualQuota: 1, + SavingsQuota: 2, + SavingsCNYMicros: "123", + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + } + row := &model.SavingsLifetimeLogRow{ + Id: 1, + UserId: 7, + CreatedAt: 1_785_081_610, + Other: common.MapToJsonStr(map[string]any{ + "savings_estimate": estimate, + }), + } + payload := SavingsLifetimeBackfillPayload{ + QuotaPerUnit: 1, + USDCNYRateMicros: math.MaxInt64, + } + + event, estimated, err := buildSavingsLifetimeBackfillEvent(row, savingsLogEstimator{}, payload) + + require.NoError(t, err) + assert.True(t, estimated) + assert.Equal(t, int64(123), event.SavingsCNYMicros) + assert.Equal(t, estimate.QuotaPerUnit, event.QuotaPerUnitSnapshot) + assert.Equal(t, estimate.USDCNYRateMicros, event.USDCNYRateMicros) +} + +func TestBuildSavingsLifetimeBackfillEventRejectsNegativeFrozenAmount(t *testing.T) { + estimate := SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 3, + ActualQuota: 1, + SavingsQuota: 2, + SavingsCNYMicros: "-123", + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + } + row := &model.SavingsLifetimeLogRow{ + Id: 2, + UserId: 7, + CreatedAt: 1_785_081_620, + Other: common.MapToJsonStr(map[string]any{ + "savings_estimate": estimate, + }), + } + payload := SavingsLifetimeBackfillPayload{ + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + } + + event, estimated, err := buildSavingsLifetimeBackfillEvent(row, savingsLogEstimator{}, payload) + + require.NoError(t, err) + assert.True(t, estimated) + assert.Equal(t, int64(29), event.SavingsCNYMicros) + assert.Equal(t, payload.QuotaPerUnit, event.QuotaPerUnitSnapshot) + assert.Equal(t, payload.USDCNYRateMicros, event.USDCNYRateMicros) +} + +func TestBuildSavingsLifetimeEventRejectsNegativeFrozenAmount(t *testing.T) { + estimate := SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 3, + ActualQuota: 1, + SavingsQuota: 2, + SavingsCNYMicros: "-123", + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + } + event, ok := buildSavingsLifetimeEvent(&model.Log{ + Id: 3, + UserId: 7, + CreatedAt: 1_785_081_630, + Other: common.MapToJsonStr(map[string]any{ + "savings_aggregation_key": "savg_negative", + "savings_estimate": estimate, + }), + }) + + assert.True(t, ok) + assert.Equal(t, model.SavingsLifetimeCoverageSkipped, event.CoverageState) + assert.Equal(t, SavingsSkipInvalidSnapshot, event.SkipReason) + assert.Zero(t, event.SavingsCNYMicros) +} + +func TestValidateSavingsLifetimeBackfillPayloadRejectsChangedSnapshot(t *testing.T) { + prices := map[string]savings_setting.OfficialPrice{ + "gpt-4o-mini": {OfficialConfirmed: true}, + } + data, err := common.Marshal(prices) + require.NoError(t, err) + hash := sha256.Sum256(data) + payload := SavingsLifetimeBackfillPayload{ + Setting: savings_setting.Setting{OfficialPrices: prices}, + PricingSnapshotHash: hex.EncodeToString(hash[:]), + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + } + + require.NoError(t, validateSavingsLifetimeBackfillPayload(payload)) + payload.Setting.OfficialPrices["gpt-4o-mini"] = savings_setting.OfficialPrice{Source: "changed"} + require.ErrorContains(t, validateSavingsLifetimeBackfillPayload(payload), "hash mismatch") + payload.QuotaPerUnit = 0 + require.ErrorContains(t, validateSavingsLifetimeBackfillPayload(payload), "currency snapshot") +} + +func TestSavingsLifetimeCompensationRecoversMissingEventIdempotently(t *testing.T) { + setupSavingsLifetimeServiceTest(t) + previousLogDB := model.LOG_DB + previousLogType := common.LogDatabaseType() + previousCursor := savingsLifetimeCompensationCursor + logDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, logDB.AutoMigrate(&model.Log{})) + model.LOG_DB = logDB + common.SetLogDatabaseType(common.DatabaseTypeSQLite) + savingsLifetimeCompensationCursor = model.SavingsLifetimeLogCursor{} + t.Cleanup(func() { + model.LOG_DB = previousLogDB + common.SetLogDatabaseType(previousLogType) + savingsLifetimeCompensationCursor = previousCursor + }) + + estimate := SavingsEstimate{ + SchemaVersion: savingsEstimateSchemaVersion, + OfficialQuota: 500, + ActualQuota: 100, + SavingsQuota: 400, + Estimated: true, + AggregationKey: "savg_compensation", + QuotaPerUnit: 500_000, + USDCNYRateMicros: 7_250_000, + SavingsCNYMicros: "5800", + OfficialConfirmed: true, + } + require.NoError(t, logDB.Create(&model.Log{ + Id: 100, + UserId: 7, + Type: model.LogTypeConsume, + CreatedAt: common.GetTimestamp(), + RequestId: "req-compensation", + Other: common.MapToJsonStr(map[string]any{ + "savings_aggregation_key": "savg_compensation", + "savings_estimate": estimate, + }), + }).Error) + + require.NoError(t, compensateSavingsLifetimeEvents()) + require.NoError(t, compensateSavingsLifetimeEvents()) + var count int64 + require.NoError(t, model.DB.Model(&model.SavingsLifetimeEvent{}).Count(&count).Error) + assert.Equal(t, int64(1), count) +} diff --git a/service/savings_lifetime_worker.go b/service/savings_lifetime_worker.go new file mode 100644 index 000000000000..cf08e03c62c0 --- /dev/null +++ b/service/savings_lifetime_worker.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/savings_setting" + + "github.com/bytedance/gopkg/util/gopool" +) + +const ( + savingsLifetimeCompensationInterval = time.Minute + savingsLifetimeCompensationLookback = 7 * 24 * time.Hour + savingsLifetimeCompensationMaxBatches = 5 +) + +var ( + savingsLifetimeWorkerOnce sync.Once + savingsLifetimeWakeup = make(chan struct{}, 1) + savingsLifetimeAggregateMu sync.Mutex + savingsLifetimeCompensationCursor model.SavingsLifetimeLogCursor +) + +func StartSavingsLifetimeAggregator() { + savingsLifetimeWorkerOnce.Do(func() { + if !common.IsMasterNode { + return + } + gopool.Go(func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + compensationTicker := time.NewTicker(savingsLifetimeCompensationInterval) + defer compensationTicker.Stop() + for { + runCompensation := false + select { + case <-ticker.C: + case <-savingsLifetimeWakeup: + case <-compensationTicker.C: + runCompensation = true + } + if !savings_setting.LifetimeEnabled() { + continue + } + if runCompensation { + if err := compensateSavingsLifetimeEvents(); err != nil { + logger.LogWarn(context.Background(), fmt.Sprintf("savings lifetime compensation failed: %v", err)) + } + } + for { + processed, err := aggregateSavingsLifetimePending(savings_setting.LifetimeBackfillBatchSize()) + if err != nil { + logger.LogWarn(context.Background(), fmt.Sprintf("savings lifetime aggregation failed: %v", err)) + break + } + if processed == 0 { + break + } + } + } + }) + }) +} + +func compensateSavingsLifetimeEvents() error { + target, _, err := model.GetSavingsLifetimeLogBoundary() + if err != nil { + return err + } + if target.ID == 0 && target.CreatedAt == 0 && target.RequestID == "" { + return nil + } + batchSize := savings_setting.LifetimeBackfillBatchSize() + startTimestamp := common.GetTimestamp() - int64(savingsLifetimeCompensationLookback.Seconds()) + for range savingsLifetimeCompensationMaxBatches { + rows, err := model.GetRecentSavingsLifetimeLogBatch( + startTimestamp, + savingsLifetimeCompensationCursor, + target, + batchSize, + ) + if err != nil { + return err + } + if len(rows) == 0 { + break + } + ambiguousCount, err := model.CountSavingsLifetimeClickHouseCursorAmbiguity(rows) + if err != nil { + return err + } + if ambiguousCount > 0 { + logger.LogWarn(context.Background(), fmt.Sprintf("savings lifetime compensation skipped %d ambiguous ClickHouse rows at cursor (%d, %q)", ambiguousCount, rows[len(rows)-1].CreatedAt, rows[len(rows)-1].RequestId)) + } + events := make([]model.SavingsLifetimeEvent, 0, len(rows)) + for i := range rows { + log := model.Log{ + Id: rows[i].Id, + RequestId: rows[i].RequestId, + UserId: rows[i].UserId, + CreatedAt: rows[i].CreatedAt, + ModelName: rows[i].ModelName, + PromptTokens: rows[i].PromptTokens, + CompletionTokens: rows[i].CompletionTokens, + Quota: rows[i].Quota, + Other: rows[i].Other, + } + if event, ok := buildSavingsLifetimeEvent(&log); ok { + events = append(events, event) + } + } + if err := model.CreateSavingsLifetimeEvents(events); err != nil { + return err + } + last := rows[len(rows)-1] + savingsLifetimeCompensationCursor = model.SavingsLifetimeLogCursor{ + ID: last.Id, + CreatedAt: last.CreatedAt, + RequestID: last.RequestId, + } + if len(rows) < batchSize { + break + } + } + notifySavingsLifetimeAggregator() + return nil +} + +func aggregateSavingsLifetimePending(limit int) (int, error) { + savingsLifetimeAggregateMu.Lock() + defer savingsLifetimeAggregateMu.Unlock() + return model.AggregatePendingSavingsLifetimeEvents(limit) +} + +func notifySavingsLifetimeAggregator() { + select { + case savingsLifetimeWakeup <- struct{}{}: + default: + } +} diff --git a/service/system_task.go b/service/system_task.go index b7182aef1f31..e23bf8495c52 100644 --- a/service/system_task.go +++ b/service/system_task.go @@ -29,8 +29,9 @@ const ( // SystemTaskHandler executes a claimed task of a specific type. Run owns the // task lifecycle from claim to terminal state: it MUST call -// model.FinishSystemTask (succeeded/failed) before returning and MUST honor -// ctx cancellation, which the runner triggers if the per-type lock is lost. +// model.FinishSystemTask (succeeded/failed) before returning, unless it has +// completed a requested pause and released the task lock. It MUST honor ctx +// cancellation, which the runner triggers if the per-type lock is lost. type SystemTaskHandler interface { Type() string Run(ctx context.Context, task *model.SystemTask, runnerID string) @@ -281,7 +282,10 @@ func runSystemTaskScheduler() { for _, scheduled := range scheduledHandlers { latest := latestTasks[scheduled.Type()] if latest != nil { - if latest.Status == model.SystemTaskStatusPending || latest.Status == model.SystemTaskStatusRunning { + if latest.Status == model.SystemTaskStatusPending || + latest.Status == model.SystemTaskStatusRunning || + latest.Status == model.SystemTaskStatusPauseRequested || + latest.Status == model.SystemTaskStatusPaused { continue // an active row already exists } if now-latest.UpdatedAt < int64(scheduled.Interval().Seconds()) { diff --git a/service/text_quota.go b/service/text_quota.go index b7578f732786..34affa2e0844 100644 --- a/service/text_quota.go +++ b/service/text_quota.go @@ -18,6 +18,7 @@ import ( "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/savings_setting" "github.com/bytedance/gopkg/util/gopool" "github.com/gin-gonic/gin" @@ -39,33 +40,34 @@ func appendToolSurchargeLogInfo(other map[string]interface{}, items []ToolSurcha } type textQuotaSummary struct { - PromptTokens int - CompletionTokens int - TotalTokens int - CacheTokens int - CacheCreationTokens int - CacheCreationTokens5m int - CacheCreationTokens1h int - ImageTokens int - AudioTokens int - ModelName string - TokenName string - UseTimeSeconds int64 - CompletionRatio float64 - CacheRatio float64 - ImageRatio float64 - ModelRatio float64 - GroupRatio float64 - ModelPrice float64 - CacheCreationRatio float64 - CacheCreationRatio5m float64 - CacheCreationRatio1h float64 - Quota int - IsClaudeUsageSemantic bool - UsageSemantic string - AudioInputPrice float64 - ToolSurchargeItems []ToolSurchargeItem - ToolCallSurchargeQuota decimal.Decimal + PromptTokens int + CompletionTokens int + TotalTokens int + CacheTokens int + CacheCreationTokens int + CacheCreationTokens5m int + CacheCreationTokens1h int + ImageTokens int + AudioTokens int + ModelName string + TokenName string + UseTimeSeconds int64 + CompletionRatio float64 + CacheRatio float64 + ImageRatio float64 + ModelRatio float64 + GroupRatio float64 + ModelPrice float64 + CacheCreationRatio float64 + CacheCreationRatio5m float64 + CacheCreationRatio1h float64 + Quota int + IsClaudeUsageSemantic bool + UsageSemantic string + AudioInputPrice float64 + ToolSurchargeItems []ToolSurchargeItem + ToolCallSurchargeQuota decimal.Decimal + LegacyClaudeDerivedUsage bool } // hasBillableUsage reports whether this request should incur any charge. @@ -264,6 +266,7 @@ func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInf summary.ImageTokens = usage.PromptTokensDetails.ImageTokens summary.AudioTokens = usage.PromptTokensDetails.AudioTokens legacyClaudeDerived := isLegacyClaudeDerivedOpenAIUsage(relayInfo, usage) + summary.LegacyClaudeDerivedUsage = legacyClaudeDerived isOpenRouterClaudeBilling := relayInfo.ChannelMeta != nil && relayInfo.ChannelType == constant.ChannelTypeOpenRouter && summary.IsClaudeUsageSemantic @@ -521,9 +524,10 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us InjectTieredBillingInfo(other, relayInfo, tieredResult) } + AttachTextSavingsEstimate(ctx, relayInfo, summary, other) attachQuotaSaturation(ctx, relayInfo, other) - model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + consumeLog := model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ ChannelId: relayInfo.ChannelId, PromptTokens: summary.PromptTokens, CompletionTokens: summary.CompletionTokens, @@ -537,6 +541,13 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us Group: relayInfo.UsingGroup, Other: other, }) + if consumeLog != nil && savings_setting.LifetimeEnabled() { + gopool.Go(func() { + if err := RecordSavingsLifetimeLog(consumeLog); err != nil { + common.SysError("failed to record savings lifetime event: " + err.Error()) + } + }) + } gopool.Go(func() { perfmetrics.RecordRelaySample(relayInfo, true, int64(summary.CompletionTokens)) }) diff --git a/setting/savings_setting/config.go b/setting/savings_setting/config.go new file mode 100644 index 000000000000..35d8b1eba288 --- /dev/null +++ b/setting/savings_setting/config.go @@ -0,0 +1,298 @@ +package savings_setting + +import ( + "errors" + "fmt" + "net/url" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" +) + +const OptionKey = "SavingsEstimateSetting" + +type OfficialPrice struct { + QuotaType int `json:"quota_type,omitempty"` + ModelRatio *float64 `json:"model_ratio,omitempty"` + ModelPrice *float64 `json:"model_price,omitempty"` + CompletionRatio *float64 `json:"completion_ratio,omitempty"` + CacheRatio *float64 `json:"cache_ratio,omitempty"` + CreateCacheRatio *float64 `json:"create_cache_ratio,omitempty"` + CacheCreation5mRatio *float64 `json:"cache_creation_ratio_5m,omitempty"` + CacheCreation1hRatio *float64 `json:"cache_creation_ratio_1h,omitempty"` + ImageRatio *float64 `json:"image_ratio,omitempty"` + AudioRatio *float64 `json:"audio_ratio,omitempty"` + AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` + BillingMode string `json:"billing_mode,omitempty"` + BillingExpr string `json:"billing_expr,omitempty"` + Source string `json:"source,omitempty"` + SourceURL string `json:"source_url,omitempty"` + SourceUpdatedAt int64 `json:"source_updated_at,omitempty"` + PriceSnapshotAt int64 `json:"price_snapshot_at,omitempty"` + PriceFingerprint string `json:"price_fingerprint,omitempty"` + OfficialConfirmed bool `json:"official_confirmed,omitempty"` + ConfirmedAt int64 `json:"confirmed_at,omitempty"` + ConfirmedBy string `json:"confirmed_by,omitempty"` +} + +type Setting struct { + Enabled bool `json:"enabled"` + ShowOnDashboard bool `json:"show_on_dashboard"` + ShowOnUsageLogs bool `json:"show_on_usage_logs"` + LocalPricingOfficialConfirmed bool `json:"local_pricing_official_confirmed"` + RebuildLegacyLogs bool `json:"rebuild_legacy_logs"` + RequireOfficialConfirmation bool `json:"require_official_confirmation"` + OfficialPriceStaleDays int `json:"official_price_stale_days"` + MaxSummaryDays int `json:"max_summary_days"` + MaxSummaryLogRows int `json:"max_summary_log_rows"` + LifetimeEnabled bool `json:"lifetime_enabled"` + LifetimeBackfillBatchSize int `json:"lifetime_backfill_batch_size"` + LifetimeShowOnDashboard bool `json:"lifetime_show_on_dashboard"` + LifetimeShowOnWallet bool `json:"lifetime_show_on_wallet"` + UpdatedAt int64 `json:"updated_at"` + OfficialPrices map[string]OfficialPrice `json:"official_prices,omitempty"` +} + +const ( + defaultOfficialPriceStaleDays = 90 + defaultMaxSummaryDays = 31 + defaultMaxSummaryLogRows = 50000 + defaultLifetimeBatchSize = 1000 + maxSummaryDaysLimit = 31 + maxSummaryLogRowsLimit = 50000 + minLifetimeBatchSize = 500 + maxLifetimeBatchSize = 5000 +) + +var ( + settingMu sync.RWMutex + setting = defaultSetting() +) + +func defaultSetting() Setting { + return Setting{ + Enabled: false, + ShowOnDashboard: true, + ShowOnUsageLogs: true, + LocalPricingOfficialConfirmed: true, + RebuildLegacyLogs: true, + RequireOfficialConfirmation: true, + OfficialPriceStaleDays: defaultOfficialPriceStaleDays, + MaxSummaryDays: defaultMaxSummaryDays, + MaxSummaryLogRows: defaultMaxSummaryLogRows, + LifetimeEnabled: false, + LifetimeBackfillBatchSize: defaultLifetimeBatchSize, + LifetimeShowOnDashboard: true, + LifetimeShowOnWallet: false, + OfficialPrices: map[string]OfficialPrice{}, + } +} + +func GetSetting() Setting { + settingMu.RLock() + defer settingMu.RUnlock() + return copySetting(setting) +} + +func IsEnabled() bool { + settingMu.RLock() + defer settingMu.RUnlock() + return setting.Enabled +} + +func ShowOnDashboard() bool { + settingMu.RLock() + defer settingMu.RUnlock() + return setting.Enabled && setting.ShowOnDashboard +} + +func ShowOnUsageLogs() bool { + settingMu.RLock() + defer settingMu.RUnlock() + return setting.Enabled && setting.ShowOnUsageLogs +} + +func MaxSummaryDays() int { + settingMu.RLock() + defer settingMu.RUnlock() + if setting.MaxSummaryDays <= 0 || setting.MaxSummaryDays > maxSummaryDaysLimit { + return defaultMaxSummaryDays + } + return setting.MaxSummaryDays +} + +func MaxSummaryLogRows() int { + settingMu.RLock() + defer settingMu.RUnlock() + if setting.MaxSummaryLogRows <= 0 || setting.MaxSummaryLogRows > maxSummaryLogRowsLimit { + return defaultMaxSummaryLogRows + } + return setting.MaxSummaryLogRows +} + +func OfficialPriceStaleDays() int { + settingMu.RLock() + defer settingMu.RUnlock() + if setting.OfficialPriceStaleDays <= 0 { + return defaultOfficialPriceStaleDays + } + return setting.OfficialPriceStaleDays +} + +func LifetimeEnabled() bool { + settingMu.RLock() + defer settingMu.RUnlock() + return setting.Enabled && setting.LifetimeEnabled +} + +func LifetimeBackfillBatchSize() int { + settingMu.RLock() + defer settingMu.RUnlock() + if setting.LifetimeBackfillBatchSize < minLifetimeBatchSize || setting.LifetimeBackfillBatchSize > maxLifetimeBatchSize { + return defaultLifetimeBatchSize + } + return setting.LifetimeBackfillBatchSize +} + +func ShowLifetimeOnDashboard() bool { + settingMu.RLock() + defer settingMu.RUnlock() + return setting.Enabled && setting.LifetimeEnabled && setting.LifetimeShowOnDashboard +} + +func ShowLifetimeOnWallet() bool { + settingMu.RLock() + defer settingMu.RUnlock() + return setting.Enabled && setting.LifetimeEnabled && setting.LifetimeShowOnWallet +} + +func GetOfficialPrice(model string) (OfficialPrice, bool) { + settingMu.RLock() + defer settingMu.RUnlock() + price, ok := setting.OfficialPrices[model] + return price, ok +} + +func Setting2JSONString() string { + settingMu.RLock() + defer settingMu.RUnlock() + jsonBytes, err := common.Marshal(setting) + if err != nil { + common.SysError("error marshalling savings estimate setting: " + err.Error()) + return "{}" + } + return string(jsonBytes) +} + +func UpdateSettingByJSONString(jsonStr string) error { + next := defaultSetting() + if strings.TrimSpace(jsonStr) != "" { + if err := common.UnmarshalJsonStr(jsonStr, &next); err != nil { + return err + } + } + if err := normalizeSetting(&next); err != nil { + return err + } + + settingMu.Lock() + setting = next + settingMu.Unlock() + return nil +} + +func ValidateSettingJSONString(jsonStr string) error { + next := defaultSetting() + if strings.TrimSpace(jsonStr) != "" { + if err := common.UnmarshalJsonStr(jsonStr, &next); err != nil { + return err + } + } + return normalizeSetting(&next) +} + +func copySetting(src Setting) Setting { + dst := src + dst.OfficialPrices = make(map[string]OfficialPrice, len(src.OfficialPrices)) + for model, price := range src.OfficialPrices { + dst.OfficialPrices[model] = price + } + return dst +} + +func normalizeSetting(s *Setting) error { + if s.Enabled && !s.RequireOfficialConfirmation { + return errors.New("require_official_confirmation must be enabled when savings estimates are enabled") + } + if s.OfficialPriceStaleDays <= 0 { + s.OfficialPriceStaleDays = defaultOfficialPriceStaleDays + } + if s.MaxSummaryDays <= 0 { + s.MaxSummaryDays = defaultMaxSummaryDays + } + if s.MaxSummaryDays > maxSummaryDaysLimit { + return fmt.Errorf("max_summary_days must not exceed %d", maxSummaryDaysLimit) + } + if s.MaxSummaryLogRows <= 0 { + s.MaxSummaryLogRows = defaultMaxSummaryLogRows + } + if s.MaxSummaryLogRows > maxSummaryLogRowsLimit { + return fmt.Errorf("max_summary_log_rows must not exceed %d", maxSummaryLogRowsLimit) + } + if s.LifetimeBackfillBatchSize <= 0 { + s.LifetimeBackfillBatchSize = defaultLifetimeBatchSize + } + if s.LifetimeBackfillBatchSize < minLifetimeBatchSize || s.LifetimeBackfillBatchSize > maxLifetimeBatchSize { + return fmt.Errorf("lifetime_backfill_batch_size must be between %d and %d", minLifetimeBatchSize, maxLifetimeBatchSize) + } + if s.OfficialPrices == nil { + s.OfficialPrices = map[string]OfficialPrice{} + return nil + } + normalizedPrices := make(map[string]OfficialPrice, len(s.OfficialPrices)) + for rawModel, price := range s.OfficialPrices { + model := strings.TrimSpace(rawModel) + if model == "" { + continue + } + if _, exists := normalizedPrices[model]; exists { + return fmt.Errorf("duplicate official price model after normalization: %q", model) + } + price.SourceURL = publicSourceURL(price.SourceURL) + price.Source = strings.TrimSpace(price.Source) + price.BillingMode = strings.TrimSpace(price.BillingMode) + normalizedPrices[model] = price + } + s.OfficialPrices = normalizedPrices + return nil +} + +func publicSourceURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + u.User = nil + u.Fragment = "" + query := u.Query() + for key := range query { + lower := strings.ToLower(key) + if strings.Contains(lower, "token") || + strings.Contains(lower, "key") || + strings.Contains(lower, "secret") || + strings.Contains(lower, "sign") || + strings.Contains(lower, "signature") { + query.Del(key) + } + } + u.RawQuery = query.Encode() + return u.String() +} diff --git a/setting/savings_setting/config_test.go b/setting/savings_setting/config_test.go new file mode 100644 index 000000000000..4ce313a3ba39 --- /dev/null +++ b/setting/savings_setting/config_test.go @@ -0,0 +1,100 @@ +package savings_setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateSettingSanitizesOfficialSourceURL(t *testing.T) { + require.NoError(t, UpdateSettingByJSONString("")) + t.Cleanup(func() { require.NoError(t, UpdateSettingByJSONString("")) }) + + require.NoError(t, UpdateSettingByJSONString(`{ + "enabled": true, + "reference_price_source": "legacy_value_is_ignored", + "official_prices": { + " gpt-4o-mini ": { + "model_ratio": 1, + "completion_ratio": 1, + "source": " OpenAI ", + "source_url": "https://example.com/pricing?token=secret&model=gpt&signature=hidden#private", + "source_updated_at": 1700000000, + "official_confirmed": true + } + } + }`)) + + setting := GetSetting() + price, ok := setting.OfficialPrices["gpt-4o-mini"] + require.True(t, ok) + assert.Equal(t, "OpenAI", price.Source) + assert.Equal(t, "https://example.com/pricing?model=gpt", price.SourceURL) +} + +func TestUpdateSettingUsesLocalPricingAndLegacyRebuildDefaults(t *testing.T) { + require.NoError(t, UpdateSettingByJSONString(`{"enabled":true}`)) + t.Cleanup(func() { require.NoError(t, UpdateSettingByJSONString("")) }) + + setting := GetSetting() + assert.True(t, setting.LocalPricingOfficialConfirmed) + assert.True(t, setting.RebuildLegacyLogs) +} + +func TestValidateSettingRejectsInvalidJSON(t *testing.T) { + require.Error(t, ValidateSettingJSONString("{bad json")) +} + +func TestValidateSettingRequiresOfficialConfirmationWhenEnabled(t *testing.T) { + err := ValidateSettingJSONString(`{ + "enabled": true, + "require_official_confirmation": false + }`) + + require.Error(t, err) + assert.Contains(t, err.Error(), "require_official_confirmation") + require.NoError(t, ValidateSettingJSONString(`{ + "enabled": false, + "require_official_confirmation": false + }`)) +} + +func TestValidateSettingRejectsSummaryLimitsAboveHardMaximum(t *testing.T) { + tests := []struct { + name string + value string + field string + }{ + { + name: "days", + value: `{"max_summary_days":32}`, + field: "max_summary_days", + }, + { + name: "rows", + value: `{"max_summary_log_rows":50001}`, + field: "max_summary_log_rows", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSettingJSONString(tt.value) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.field) + }) + } +} + +func TestValidateSettingRejectsModelKeysThatCollideAfterTrimming(t *testing.T) { + err := ValidateSettingJSONString(`{ + "official_prices": { + "gpt-4o-mini": {"model_ratio": 1}, + " gpt-4o-mini ": {"model_ratio": 2} + } + }`) + + require.Error(t, err) + assert.Contains(t, err.Error(), "gpt-4o-mini") +} diff --git a/web/src/features/dashboard/api.ts b/web/src/features/dashboard/api.ts index 8429da854ab3..3914240d3fec 100644 --- a/web/src/features/dashboard/api.ts +++ b/web/src/features/dashboard/api.ts @@ -21,6 +21,10 @@ import { api } from '@/lib/api' import type { FlowQuotaDataItem, QuotaDataItem, + SavingsSummary, + SavingsLifetimeSummary, + SavingsTrend, + SavingsTrendGranularity, UptimeGroupResult, } from './types' @@ -51,6 +55,38 @@ export async function getUserQuotaDates( return res.data } +export async function getUserSavingsSummary(params: { + start_timestamp: number + end_timestamp: number +}) { + const res = await api.get<{ success: boolean; data: SavingsSummary }>( + '/api/user/savings/summary', + { params } + ) + return res.data +} + +export async function getUserSavingsTrend(params: { + start_timestamp: number + end_timestamp: number + granularity: SavingsTrendGranularity + utc_offset_minutes: number +}) { + const res = await api.get<{ success: boolean; data: SavingsTrend }>( + '/api/user/savings/trend', + { params } + ) + return res.data +} + +export async function getUserSavingsLifetime() { + const res = await api.get<{ + success: boolean + data: SavingsLifetimeSummary + }>('/api/user/savings/lifetime') + return res.data +} + // ---------------------------------------------------------------------------- // System Monitoring // ---------------------------------------------------------------------------- diff --git a/web/src/features/dashboard/components/models/savings-trend-chart.tsx b/web/src/features/dashboard/components/models/savings-trend-chart.tsx new file mode 100644 index 000000000000..4b74c2f79f7b --- /dev/null +++ b/web/src/features/dashboard/components/models/savings-trend-chart.tsx @@ -0,0 +1,490 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { VChart } from '@visactor/react-vchart' +import { BadgeDollarSign, CircleAlert, Info, RefreshCw } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { IconBadge } from '@/components/ui/icon-badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useThemeCustomization } from '@/context/theme-customization-provider' +import { useTheme } from '@/context/theme-provider' +import { getUserSavingsTrend } from '@/features/dashboard/api' +import { getDefaultDays } from '@/features/dashboard/lib' +import { formatSavingsQuotaAsCNY } from '@/features/dashboard/lib/savings' +import { + buildSavingsTrendChartData, + calculateSavingsRate, + normalizeSavingsTrendGranularity, +} from '@/features/dashboard/lib/savings-chart' +import type { DashboardFilters } from '@/features/dashboard/types' +import { toIntlLocale } from '@/i18n/languages' +import { ROLE } from '@/lib/roles' +import { useThemeRadiusPx } from '@/lib/theme-radius' +import { computeTimeRange } from '@/lib/time' +import { VCHART_OPTION } from '@/lib/vchart' +import { useAuthStore } from '@/stores/auth-store' +import { useSystemConfigStore } from '@/stores/system-config-store' + +let themeManagerPromise: Promise< + (typeof import('@visactor/vchart'))['ThemeManager'] +> | null = null + +interface SavingsTrendChartProps { + filters?: DashboardFilters +} + +function formatPercent(value: number, locale: Intl.LocalesArgument): string { + return new Intl.NumberFormat(locale, { + style: 'percent', + maximumFractionDigits: 1, + }).format(value) +} + +export function SavingsTrendChart(props: SavingsTrendChartProps) { + const { t, i18n } = useTranslation() + const { resolvedTheme } = useTheme() + const { customization } = useThemeCustomization() + const chartRadius = useThemeRadiusPx( + '--radius-sm', + `${customization.preset}:${customization.radius}` + ) + const userRole = useAuthStore((state) => state.auth.user?.role) + const currency = useSystemConfigStore((state) => state.config.currency) + const isAdmin = Boolean(userRole && userRole >= ROLE.ADMIN) + const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) + const [themeReady, setThemeReady] = useState(false) + const themeManagerRef = useRef< + (typeof import('@visactor/vchart'))['ThemeManager'] | null + >(null) + + useEffect(() => { + const updateTheme = async () => { + setThemeReady(false) + if (!themeManagerPromise) { + themeManagerPromise = import('@visactor/vchart').then( + (module) => module.ThemeManager + ) + } + const ThemeManager = await themeManagerPromise + themeManagerRef.current = ThemeManager + ThemeManager.setCurrentTheme(resolvedTheme === 'dark' ? 'dark' : 'light') + setThemeReady(true) + } + + void updateTheme() + }, [resolvedTheme]) + + const timeRange = useMemo( + () => + computeTimeRange( + getDefaultDays(props.filters?.time_granularity), + props.filters?.start_timestamp, + props.filters?.end_timestamp + ), + [ + props.filters?.end_timestamp, + props.filters?.start_timestamp, + props.filters?.time_granularity, + ] + ) + const durationSeconds = timeRange.end_timestamp - timeRange.start_timestamp + const granularity = normalizeSavingsTrendGranularity( + props.filters?.time_granularity, + durationSeconds + ) + const utcOffsetMinutes = -new Date().getTimezoneOffset() + const trendQuery = useQuery({ + queryKey: [ + 'dashboard', + 'savings-trend', + timeRange.start_timestamp, + timeRange.end_timestamp, + granularity, + utcOffsetMinutes, + ], + queryFn: () => + getUserSavingsTrend({ + ...timeRange, + granularity, + utc_offset_minutes: utcOffsetMinutes, + }), + staleTime: 60 * 1000, + placeholderData: keepPreviousData, + }) + + const trend = trendQuery.data?.data + const chartData = useMemo( + () => + trend + ? buildSavingsTrendChartData( + trend, + currency.quotaPerUnit, + currency.usdExchangeRate + ) + : [], + [trend, currency.quotaPerUnit, currency.usdExchangeRate] + ) + const formatCNY = useCallback( + (value: number | null | undefined): string => { + if (value == null || !Number.isFinite(value)) return '-' + return new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'CNY', + currencyDisplay: 'narrowSymbol', + maximumFractionDigits: Math.abs(value) >= 1 ? 2 : 4, + }).format(value) + }, + [locale] + ) + const formatCompactCNY = useCallback( + (value: number): string => { + const absoluteValue = Math.abs(value) + let maximumFractionDigits = 3 + if (absoluteValue >= 1) { + maximumFractionDigits = 1 + } else if (absoluteValue >= 0.1) { + maximumFractionDigits = 2 + } + return `¥${new Intl.NumberFormat(locale, { + notation: 'compact', + maximumFractionDigits, + }).format(value)}` + }, + [locale] + ) + + const chartSpec = useMemo( + () => ({ + type: 'common', + data: [{ id: 'savingsTrend', values: chartData }], + series: [ + { + type: 'bar', + dataId: 'savingsTrend', + xField: 'Time', + yField: 'Savings', + bar: { + style: { + fill: '#16a34a', + fillOpacity: 0.24, + cornerRadius: chartRadius, + }, + }, + }, + { + type: 'line', + dataId: 'savingsTrend', + xField: 'Time', + yField: 'Official', + seriesField: 'LineSegment', + invalidType: 'break', + tooltip: { visible: false }, + line: { + style: { + stroke: '#64748b', + lineWidth: 2, + lineDash: [6, 4], + curveType: 'linear', + }, + }, + point: { visible: false }, + }, + { + type: 'line', + dataId: 'savingsTrend', + xField: 'Time', + yField: 'Actual', + seriesField: 'LineSegment', + invalidType: 'break', + tooltip: { visible: false }, + line: { + style: { + stroke: '#0284c7', + lineWidth: 2.5, + curveType: 'linear', + }, + }, + point: { visible: false }, + }, + ], + axes: [ + { + orient: 'left', + label: { + formatMethod: (value: string | number) => + formatCompactCNY(Number(value) || 0), + }, + }, + { + orient: 'bottom', + type: 'band', + label: { autoHide: true, autoRotate: true }, + }, + ], + legends: { visible: false }, + tooltip: { + dimension: { + content: [ + { + key: t('Official price estimate'), + value: (datum: Record) => + formatCNY(Number(datum?.Official)), + }, + { + key: t('Covered request actual cost'), + value: (datum: Record) => + formatCNY(Number(datum?.Actual)), + }, + { + key: t('Estimated savings'), + value: (datum: Record) => + formatCNY(Number(datum?.Savings)), + }, + { + key: t('Covered requests'), + value: (datum: Record) => + `${Number(datum?.EstimatedRequestCount) || 0} / ${ + Number(datum?.RequestCount) || 0 + } (${formatPercent( + Number(datum?.CoverageRatio) || 0, + locale + )})`, + }, + { + key: t('Historical rebuilds'), + value: (datum: Record) => + String(Number(datum?.ReconstructedRequestCount) || 0), + }, + ], + }, + }, + background: 'transparent', + animation: true, + }), + [chartData, chartRadius, formatCNY, formatCompactCNY, locale, t] + ) + + if (trend && !trend.summary.enabled) return null + + const title = isAdmin + ? t('Current account cost comparison') + : t('Cost comparison') + const summary = trend?.summary + const savingsDisplay = summary + ? formatSavingsQuotaAsCNY( + summary.savings_quota, + currency.quotaPerUnit, + currency.usdExchangeRate, + locale + ) + : '-' + const savingsRate = summary ? calculateSavingsRate(summary) : 0 + const hasRequests = Boolean(summary && summary.request_count > 0) + const hasEstimates = Boolean(summary && summary.estimated_request_count > 0) + const isEmpty = !hasRequests || !hasEstimates + const isLoading = trendQuery.isLoading && !trend + const isError = !isLoading && trendQuery.isError + const isPartial = !isLoading && !isError && Boolean(summary?.is_partial) + const showEmpty = !isLoading && !isError && !isPartial && isEmpty + const showChart = !isLoading && !isError && !isPartial && !isEmpty + let emptyMessage = t('No usage records in the selected range') + if (hasRequests) emptyMessage = t('No eligible savings records yet') + + return ( +
+
+
+ + +
+
+

{title}

+ {isAdmin && ( + + {t('Current account only')} + + )} +
+
+ {t('Converted at 1 USD = {{rate}} CNY', { + rate: currency.usdExchangeRate, + })} +
+
+
+ +
+
+ + {t('Estimated savings')} + {' '} + + {savingsDisplay} + +
+
+ {t('Savings rate')}{' '} + + {formatPercent(savingsRate, locale)} + +
+
+ {t('Coverage')}{' '} + + {formatPercent(summary?.coverage_ratio ?? 0, locale)} + +
+
+
+ + {isLoading && ( +
+ +
+ )} + {isError && ( +
+
+ )} + {isPartial && ( +
+ {t('Too many records to summarize')} +
+ )} + {showEmpty && ( +
+ {emptyMessage} +
+ )} + {showChart && ( + <> +
+
+ + {t('Official price estimate')} +
+
+ + {t('Covered request actual cost')} +
+
+ + {t('Estimated savings')} +
+
+
+ {themeReady && ( + + )} +
+ {summary && (summary.reconstructed_request_count ?? 0) > 0 && ( +
+
+ )} + +
+ + + + + + + + + + + + + {chartData.map((point) => ( + + + + + + + + ))} + +
{title}
{t('Time')}{t('Official price estimate')}{t('Covered request actual cost')}{t('Estimated savings')}{t('Coverage')}
{point.Time}{formatCNY(point.Official)}{formatCNY(point.Actual)}{formatCNY(point.Savings)}{formatPercent(point.CoverageRatio, locale)}
+
+ + )} +
+ ) +} diff --git a/web/src/features/dashboard/components/overview/__tests__/summary-cards-layout.test.ts b/web/src/features/dashboard/components/overview/__tests__/summary-cards-layout.test.ts new file mode 100644 index 000000000000..874e10f34187 --- /dev/null +++ b/web/src/features/dashboard/components/overview/__tests__/summary-cards-layout.test.ts @@ -0,0 +1,45 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { summaryCardsLayoutClasses } from '../summary-cards-layout.ts' + +describe('dashboard summary cards layout', () => { + test('uses a wide-screen usage and account column without constraining mobile', () => { + const gridClasses = summaryCardsLayoutClasses.grid.split(' ') + const usageClasses = summaryCardsLayoutClasses.usage.split(' ') + const accountClasses = summaryCardsLayoutClasses.account.split(' ') + + assert.ok(gridClasses.includes('grid')) + assert.ok(gridClasses.includes('xl:grid-cols-[minmax(0,1fr)_19rem]')) + assert.ok(usageClasses.includes('min-w-0')) + assert.ok(accountClasses.includes('min-w-0')) + }) + + test('places savings below both desktop columns instead of stretching either column', () => { + const savingsClasses = summaryCardsLayoutClasses.savings.split(' ') + const accountClasses = summaryCardsLayoutClasses.account.split(' ') + + assert.ok(savingsClasses.includes('xl:col-span-2')) + assert.ok(savingsClasses.includes('border-t')) + assert.ok(accountClasses.includes('xl:border-l')) + assert.ok(accountClasses.includes('xl:border-t-0')) + }) +}) diff --git a/web/src/features/dashboard/components/overview/summary-cards-layout.ts b/web/src/features/dashboard/components/overview/summary-cards-layout.ts new file mode 100644 index 000000000000..77e400fa68d4 --- /dev/null +++ b/web/src/features/dashboard/components/overview/summary-cards-layout.ts @@ -0,0 +1,25 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export const summaryCardsLayoutClasses = { + grid: 'grid xl:grid-cols-[minmax(0,1fr)_19rem]', + usage: 'flex min-w-0 flex-col gap-2.5 p-3 sm:gap-3 sm:p-5', + account: + 'bg-muted/40 flex min-w-0 flex-col justify-between gap-3 border-t p-3 sm:gap-4 sm:p-5 xl:border-t-0 xl:border-l', + savings: 'min-w-0 border-t p-3 sm:p-5 xl:col-span-2', +} as const diff --git a/web/src/features/dashboard/components/overview/summary-cards.tsx b/web/src/features/dashboard/components/overview/summary-cards.tsx index 6fdfc950d696..4f547fe89290 100644 --- a/web/src/features/dashboard/components/overview/summary-cards.tsx +++ b/web/src/features/dashboard/components/overview/summary-cards.tsx @@ -18,23 +18,53 @@ For commercial licensing, please contact support@quantumnous.com */ import { useQuery } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { ArrowRight, Flame, ShieldCheck, TrendingDown } from 'lucide-react' +import { + ArrowRight, + ArrowUpRight, + BadgeDollarSign, + Clock, + Flame, + Info, + RefreshCw, + ShieldCheck, + TrendingDown, +} from 'lucide-react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { StaggerContainer, StaggerItem } from '@/components/page-transition' import { Button } from '@/components/ui/button' -import { getUserQuotaDates } from '@/features/dashboard/api' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + getUserQuotaDates, + getUserSavingsLifetime, + getUserSavingsSummary, +} from '@/features/dashboard/api' import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config' +import { + formatSavingsQuotaAsCNY, + formatSavingsCNYMicros, + formatSavingsPercent, + getRollingSavingsTimeRange, + getLifetimeSavingsViewState, +} from '@/features/dashboard/lib/savings' +import { savingsQueryKeys } from '@/features/dashboard/lib/savings-query-keys' import type { QuotaDataItem } from '@/features/dashboard/types' import { useStatus } from '@/hooks/use-status' +import { toIntlLocale } from '@/i18n/languages' import { getCurrencyLabel, isCurrencyDisplayEnabled } from '@/lib/currency' -import { formatNumber, formatQuota } from '@/lib/format' +import { formatNumber, formatQuota, formatTimestampToDate } from '@/lib/format' import { computeTimeRange } from '@/lib/time' import { cn } from '@/lib/utils' import { useAuthStore } from '@/stores/auth-store' +import { useSystemConfigStore } from '@/stores/system-config-store' import { StatCard } from '../ui/stat-card' +import { summaryCardsLayoutClasses } from './summary-cards-layout' const SUMMARY_SPARKLINE_BUCKETS = 12 @@ -137,8 +167,10 @@ const HEALTH_CONFIG: Record< } export function SummaryCards() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) const user = useAuthStore((state) => state.auth.user) + const currency = useSystemConfigStore((state) => state.config.currency) const { status, loading } = useStatus() const summaryTimeRange = useMemo(() => computeTimeRange(1), []) @@ -163,6 +195,20 @@ export function SummaryCards() { staleTime: 60 * 1000, }) + const savingsSummaryQuery = useQuery({ + queryKey: ['dashboard', 'overview', 'savings-summary', 'rolling-24h'], + queryFn: async () => getUserSavingsSummary(getRollingSavingsTimeRange()), + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }) + + const savingsLifetimeQuery = useQuery({ + queryKey: savingsQueryKeys.lifetime, + queryFn: getUserSavingsLifetime, + staleTime: 60 * 1000, + refetchInterval: 60 * 1000, + }) + const summaryValues = useMemo(() => { return { usedDisplay: formatQuota(usedQuota), @@ -211,6 +257,144 @@ export function SummaryCards() { const runwayDays = getRunwayDays(remainQuota, recentUsage) const todayUsageDisplay = formatQuota(recentUsage) + const savingsSummary = savingsSummaryQuery.data?.data + const savingsAmountDisplay = + savingsSummary == null + ? '' + : formatSavingsQuotaAsCNY( + savingsSummary.savings_quota, + currency.quotaPerUnit, + currency.usdExchangeRate, + locale + ) + const reconstructedSavingsCount = + savingsSummary?.reconstructed_request_count ?? 0 + const showSavingsSummary = savingsSummary?.enabled === true + const savingsLifetime = savingsLifetimeQuery.data?.data + const showSavingsLifetime = + savingsLifetime?.enabled === true && + savingsLifetime.show_on_dashboard === true + const lifetimeAmountDisplay = showSavingsLifetime + ? formatSavingsCNYMicros(savingsLifetime.savings_cny_micros, locale) + : '' + const lifetimeCoverageDisplay = showSavingsLifetime + ? formatSavingsPercent(savingsLifetime.coverage_ratio, locale) + : '' + const lifetimeState = showSavingsLifetime + ? getLifetimeSavingsViewState(savingsLifetime) + : null + const hasLifetimeAmount = Boolean( + showSavingsLifetime && savingsLifetime.estimated_request_count > 0 + ) + const hasPositiveLifetimeSavings = Boolean( + showSavingsLifetime && /^[1-9]\d*$/.test(savingsLifetime.savings_cny_micros) + ) + const lifetimeTitle = + lifetimeState === 'complete' || + lifetimeState === 'empty' || + lifetimeState === 'no_estimates' + ? t('Lifetime savings') + : t('Lifetime savings counted so far') + let lifetimeValue = lifetimeAmountDisplay + let lifetimeDescription = '' + if (showSavingsLifetime && lifetimeState) { + const coverageText = + lifetimeCoverageDisplay === '-' + ? `${t('Coverage')}: -` + : t('{{coverage}} coverage', { + coverage: lifetimeCoverageDisplay, + }) + + if (!hasLifetimeAmount) { + if (lifetimeState === 'empty') { + lifetimeValue = t('No lifetime savings records yet') + } else if (lifetimeState === 'failed') { + lifetimeValue = t('Historical savings backfill failed') + } else { + lifetimeValue = t('No eligible savings records yet') + } + } + + if ( + lifetimeState === 'complete' && + savingsLifetime.statistics_started_at > 0 + ) { + lifetimeDescription = t('Since {{date}} · {{coverage}} coverage', { + date: formatTimestampToDate(savingsLifetime.statistics_started_at), + coverage: lifetimeCoverageDisplay, + }) + } else if (lifetimeState === 'complete') { + lifetimeDescription = coverageText + } else if (lifetimeState === 'no_estimates') { + lifetimeDescription = coverageText + } else if (lifetimeState === 'failed') { + lifetimeDescription = savingsLifetime.request_count + ? `${coverageText} · ${t( + 'Historical savings backfill failed; results are incomplete.' + )}` + : t('Historical savings backfill failed; results are incomplete.') + } else if (lifetimeState === 'paused') { + lifetimeDescription = `${coverageText} · ${t( + 'System historical data counting is paused' + )}` + } else if (lifetimeState === 'not_started') { + lifetimeDescription = savingsLifetime.request_count + ? `${coverageText} · ${t('Historical usage has not been backfilled')}` + : t('Historical usage has not been backfilled') + } else if (lifetimeState === 'processing') { + lifetimeDescription = `${coverageText} · ${t( + 'System historical data is being counted' + )}` + } + } + const hasPositiveSavings = + savingsSummary?.enabled === true && + !savingsSummary.is_partial && + savingsSummary.savings_quota > 0 + const savingsCoverageDisplay = + savingsSummary != null + ? formatSavingsPercent(savingsSummary.coverage_ratio, locale) + : '' + let savingsSummaryDescription = '' + if (savingsSummary) { + if (savingsSummary.is_partial) { + savingsSummaryDescription = t('Too many records to summarize') + } else if (savingsSummary.estimated_request_count === 0) { + savingsSummaryDescription = t('No eligible savings records yet') + } else { + savingsSummaryDescription = + savingsCoverageDisplay === '-' + ? `${t('Coverage')}: -` + : t('{{coverage}} coverage', { + coverage: savingsCoverageDisplay, + }) + } + } + if ( + savingsSummary?.enabled === true && + !savingsSummary.is_partial && + reconstructedSavingsCount > 0 + ) { + savingsSummaryDescription = `${savingsSummaryDescription} · ${t( + 'Historical requests recalculated at current official prices: {{count}}', + { count: reconstructedSavingsCount } + )}` + } + const hasSavingsSummaryAmount = Boolean( + showSavingsSummary && + !savingsSummary.is_partial && + savingsSummary.estimated_request_count > 0 + ) + const savingsRefreshFailed = Boolean( + (showSavingsSummary && savingsSummaryQuery.isRefetchError) || + (showSavingsLifetime && savingsLifetimeQuery.isRefetchError) + ) + let primarySavingsValue = '-' + if (showSavingsLifetime) { + primarySavingsValue = lifetimeValue + } else if (hasSavingsSummaryAmount) { + primarySavingsValue = savingsAmountDisplay + } let runwayDisplay: string if (runwayDays !== null) { if (runwayDays < 1) { @@ -250,9 +434,9 @@ export function SummaryCards() { }) return ( -
-
-
+
+
+

@@ -263,12 +447,9 @@ export function SummaryCards() {

- + {items.map((it) => ( - +
-
+
@@ -307,7 +488,7 @@ export function SummaryCards() {
-
+
-
+
{runwayDays !== null && runwayDays < 3 ? (
+ + {(showSavingsSummary || showSavingsLifetime) && ( +
+
+
+
+
+
+ {showSavingsSummary && ( + + + } + > + + {t('View savings trend')} + + )} +
+ +
+ {primarySavingsValue} +
+ + {(showSavingsLifetime + ? lifetimeDescription + : savingsSummaryDescription) && ( +
+ {showSavingsLifetime + ? lifetimeDescription + : savingsSummaryDescription} +
+ )} +
+ +
+
+ {t('Estimated from official public pricing')} + + + } + > + + + {t( + 'Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.' + )} + + + {showSavingsSummary && ( + + + } + > + + {t('View savings trend')} + + )} +
+ + {showSavingsLifetime && + showSavingsSummary && + savingsSummary && ( +
+
+ + + + {hasSavingsSummaryAmount ? savingsAmountDisplay : '-'} + +
+ {savingsSummaryDescription && ( +
+ {savingsSummaryDescription} +
+ )} +
+ )} + + {showSavingsSummary && + savingsSummary?.official_price_stale && + savingsSummary.source_updated_at > 0 && ( +
+ {t('Official price updated {{time}}', { + time: formatTimestampToDate( + savingsSummary.source_updated_at + ), + })} +
+ )} + + {savingsRefreshFailed && ( +
+ {t('Savings data update failed')} + + { + if (showSavingsSummary) { + void savingsSummaryQuery.refetch() + } + if (showSavingsLifetime) { + void savingsLifetimeQuery.refetch() + } + }} + aria-label={t('Reload savings data')} + /> + } + > + + + {t('Reload savings data')} + + +
+ )} +
+
+
+ )}
) diff --git a/web/src/features/dashboard/index.tsx b/web/src/features/dashboard/index.tsx index 9d814f886a6e..0833647eb188 100644 --- a/web/src/features/dashboard/index.tsx +++ b/web/src/features/dashboard/index.tsx @@ -95,6 +95,12 @@ const LazyConsumptionDistributionChart = lazy(() => })) ) +const LazySavingsTrendChart = lazy(() => + import('./components/models/savings-trend-chart').then((module) => ({ + default: module.SavingsTrendChart, + })) +) + const LazyPerformanceOverview = lazy(() => import('./components/models/performance-overview').then((m) => ({ default: m.PerformanceOverview, @@ -363,6 +369,11 @@ export function Dashboard() { )} + }> + + + + }> - + }> . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { SavingsTrend } from '../../types.ts' +import { + buildSavingsTrendChartData, + calculateSavingsRate, + normalizeSavingsTrendGranularity, +} from '../savings-chart.ts' + +const trend: SavingsTrend = { + granularity: 'day', + utc_offset_minutes: 480, + start_timestamp: 1_785_081_600, + end_timestamp: 1_785_168_000, + summary: { + enabled: true, + savings_quota: 60, + official_quota: 150, + actual_quota: 120, + request_count: 2, + estimated_request_count: 2, + snapshot_request_count: 1, + reconstructed_request_count: 1, + coverage_ratio: 1, + source: 'mixed', + official_confirmed: true, + source_updated_at: 0, + rebuild_price_snapshot_at: 1_785_168_000, + official_price_stale: false, + is_partial: false, + window_days: 1, + }, + buckets: [ + { + start_timestamp: 1_785_081_600, + end_timestamp: 1_785_168_000, + official_quota: 150, + actual_quota: 120, + savings_quota: 60, + request_count: 2, + estimated_request_count: 2, + snapshot_request_count: 1, + reconstructed_request_count: 1, + coverage_ratio: 1, + }, + { + start_timestamp: 1_785_168_000, + end_timestamp: 1_785_254_400, + official_quota: 0, + actual_quota: 0, + savings_quota: 0, + request_count: 0, + estimated_request_count: 0, + snapshot_request_count: 0, + reconstructed_request_count: 0, + coverage_ratio: 0, + }, + { + start_timestamp: 1_785_254_400, + end_timestamp: 1_785_340_800, + official_quota: 80, + actual_quota: 50, + savings_quota: 30, + request_count: 1, + estimated_request_count: 1, + snapshot_request_count: 1, + reconstructed_request_count: 0, + coverage_ratio: 1, + }, + ], +} + +describe('savings trend chart data', () => { + it('uses the server savings value instead of subtracting aggregate lines', () => { + const points = buildSavingsTrendChartData(trend, 500_000, 7.3) + + assert.ok(Math.abs(Number(points[0].Official) - 0.00219) < 1e-12) + assert.ok(Math.abs(Number(points[0].Actual) - 0.001752) < 1e-12) + assert.ok(Math.abs(Number(points[0].Savings) - 0.000876) < 1e-12) + assert.notEqual( + Number(points[0].Official) - Number(points[0].Actual), + points[0].Savings + ) + }) + + it('maps empty buckets to null so lines do not bridge missing periods', () => { + const points = buildSavingsTrendChartData(trend, 500_000, 7.3) + + assert.equal(points[0].LineSegment, 'segment-1') + assert.equal(points[1].Official, null) + assert.equal(points[1].Actual, null) + assert.equal(points[1].Savings, null) + assert.equal(points[1].LineSegment, null) + assert.equal(points[2].LineSegment, 'segment-2') + }) + + it('does not render uncovered requests as zero-cost estimates', () => { + const uncoveredTrend: SavingsTrend = { + ...trend, + buckets: [ + { + ...trend.buckets[0], + official_quota: 0, + actual_quota: 0, + savings_quota: 0, + request_count: 4, + estimated_request_count: 0, + snapshot_request_count: 0, + reconstructed_request_count: 0, + coverage_ratio: 0, + }, + ], + } + + const [point] = buildSavingsTrendChartData(uncoveredTrend, 500_000, 7.3) + + assert.equal(point.Official, null) + assert.equal(point.Actual, null) + assert.equal(point.Savings, null) + assert.equal(point.LineSegment, null) + }) + + it('normalizes weekly dashboard filters to daily savings buckets', () => { + assert.equal(normalizeSavingsTrendGranularity('week'), 'day') + assert.equal(normalizeSavingsTrendGranularity('hour'), 'hour') + assert.equal(normalizeSavingsTrendGranularity('hour', 7 * 24 * 3600), 'day') + }) + + it('calculates savings rate against official quota', () => { + assert.equal(calculateSavingsRate(trend.summary), 0.4) + }) +}) diff --git a/web/src/features/dashboard/lib/__tests__/savings-i18n.test.ts b/web/src/features/dashboard/lib/__tests__/savings-i18n.test.ts new file mode 100644 index 000000000000..06b943955fb9 --- /dev/null +++ b/web/src/features/dashboard/lib/__tests__/savings-i18n.test.ts @@ -0,0 +1,182 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { describe, it } from 'node:test' + +interface LocaleFile { + translation: Record +} + +const require = createRequire(import.meta.url) +const en = require('../../../../i18n/locales/en.json') as LocaleFile +const fr = require('../../../../i18n/locales/fr.json') as LocaleFile +const ja = require('../../../../i18n/locales/ja.json') as LocaleFile +const ru = require('../../../../i18n/locales/ru.json') as LocaleFile +const vi = require('../../../../i18n/locales/vi.json') as LocaleFile +const zhTW = require('../../../../i18n/locales/zh-TW.json') as LocaleFile +const zh = require('../../../../i18n/locales/zh.json') as LocaleFile + +const savingsTranslationKeys = [ + 'About official pricing estimates', + '{{coverage}} coverage', + 'Historical requests recalculated at current official prices: {{count}}', + 'Estimated from official public pricing', + 'Historical savings backfill failed; results are incomplete.', + 'Historical usage has not been backfilled', + 'Last 24 hours', + 'Lifetime savings counted so far', + 'Official price confirmation is required while savings estimates are enabled.', + 'Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.', + 'Reload savings data', + 'Required while savings estimates are enabled.', + 'Savings data update failed', + 'System historical data counting is paused', + 'System historical data is being counted', +] as const + +const localeTranslations: Array<[string, Record]> = [ + ['en', en.translation], + ['fr', fr.translation], + ['ja', ja.translation], + ['ru', ru.translation], + ['vi', vi.translation], + ['zh-TW', zhTW.translation], + ['zh', zh.translation], +] + +describe('savings summary translations', () => { + for (const [locale, translations] of localeTranslations) { + it(`${locale} includes localized dynamic summary text`, () => { + for (const key of savingsTranslationKeys) { + assert.ok(translations[key], `${locale} is missing ${key}`) + if (locale !== 'en') assert.notEqual(translations[key], key) + } + + assert.ok(translations['{{coverage}} coverage'].includes('{{coverage}}')) + assert.ok( + translations[ + 'Historical requests recalculated at current official prices: {{count}}' + ].includes('{{count}}') + ) + assert.equal( + translations[ + '{{count}} historical requests recalculated at current official prices' + ], + undefined + ) + }) + } + + it('preserves review-approved savings terminology', () => { + assert.equal( + ru.translation[ + 'Calculate estimated savings using official model prices.' + ], + 'Рассчитывать оценочную экономию по официальным ценам моделей.' + ) + assert.equal( + ru.translation['Covered request actual cost'], + 'Фактическая стоимость охваченных запросов' + ) + assert.equal(ru.translation['Covered requests'], 'Охваченные запросы') + assert.equal( + ru.translation['Recalculate legacy usage logs'], + 'Пересчитать устаревшие журналы использования' + ) + assert.equal( + vi.translation['Official Price Updated'], + 'Giá chính thức đã được cập nhật' + ) + assert.equal( + vi.translation['Official price updated {{time}}'], + 'Giá chính thức được cập nhật lúc {{time}}' + ) + assert.equal( + zhTW.translation[ + 'Show the savings summary and trend on the user dashboard.' + ], + '在用戶儀表板中顯示節省彙總和趨勢。' + ) + assert.equal( + zhTW.translation[ + 'Treat local model marketplace prices as official reference prices.' + ], + '將本地模型廣場價格視為官方參考價格。' + ) + assert.equal( + zhTW.translation[ + 'Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.' + ], + '預設使用模型廣場中的本地官方定價;official_prices 僅用於覆寫例外模型。' + ) + assert.equal(zh.translation['Estimated: {{count}}'], '已估算:{{count}}') + assert.equal(zhTW.translation['Estimated: {{count}}'], '已估算:{{count}}') + assert.equal( + ru.translation[ + 'Aggregate new usage into a frozen lifetime savings total.' + ], + 'Учитывать новое использование в зафиксированной общей сумме экономии за всё время.' + ) + assert.equal( + ru.translation[ + 'Enable and save lifetime savings before starting a backfill.' + ], + 'Включите и сохраните настройку накопленной экономии перед запуском пересчёта.' + ) + assert.equal( + ru.translation[ + 'Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.' + ], + 'По умолчанию используются локальные официальные цены из каталога моделей; official_prices нужен только для переопределений.' + ) + assert.equal( + ru.translation['Clear usage and balance'], + 'Очистить использование и баланс' + ) + assert.equal( + ru.translation['Enable lifetime savings'], + 'Включить учёт экономии за всё время' + ) + assert.equal( + ru.translation['Lifetime savings counted so far'], + 'Накопленная экономия на данный момент' + ) + assert.equal( + ru.translation['supported billing models'], + 'поддерживаемых моделей тарификации' + ) + assert.equal( + ru.translation['System historical data is being counted'], + 'Идёт подсчёт исторических данных' + ) + assert.equal( + zhTW.translation[ + 'One endpoint, one key, and a clear view of every request.' + ], + '一個端點、一枚金鑰,每次請求都清楚可見。' + ) + assert.equal( + zhTW.translation[ + 'Use one compatible endpoint to access supported models without changing SDKs.' + ], + '透過一個相容端點存取支援的模型,無需更換現有 SDK。' + ) + }) +}) diff --git a/web/src/features/dashboard/lib/__tests__/savings-lifetime.test.ts b/web/src/features/dashboard/lib/__tests__/savings-lifetime.test.ts new file mode 100644 index 000000000000..f0d99eba66e3 --- /dev/null +++ b/web/src/features/dashboard/lib/__tests__/savings-lifetime.test.ts @@ -0,0 +1,108 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import type { SavingsLifetimeSummary } from '../../types.ts' +import { + formatSavingsPercent, + getLifetimeSavingsViewState, +} from '../savings.ts' + +function createSummary( + overrides: Partial = {} +): SavingsLifetimeSummary { + return { + enabled: true, + show_on_dashboard: true, + show_on_wallet: true, + currency: 'CNY', + savings_cny_micros: '1000000', + savings_quota: '1', + official_quota: '2', + actual_quota: '1', + request_count: 10, + estimated_request_count: 8, + snapshot_request_count: 8, + reconstructed_request_count: 0, + coverage_ratio: 0.8, + statistics_started_at: 1_700_000_000, + last_aggregated_at: 1_700_000_100, + backfill_status: 'completed', + backfill_progress: 1, + is_complete: true, + ...overrides, + } +} + +describe('getLifetimeSavingsViewState', () => { + it('uses failure before empty and incomplete states', () => { + assert.equal( + getLifetimeSavingsViewState( + createSummary({ + backfill_status: 'failed', + request_count: 0, + is_complete: false, + }) + ), + 'failed' + ) + }) + + it('distinguishes empty totals from completed totals without estimates', () => { + assert.equal( + getLifetimeSavingsViewState(createSummary({ request_count: 0 })), + 'empty' + ) + assert.equal( + getLifetimeSavingsViewState( + createSummary({ estimated_request_count: 0 }) + ), + 'no_estimates' + ) + }) + + it('keeps globally incomplete totals in a non-final state', () => { + assert.equal( + getLifetimeSavingsViewState( + createSummary({ + is_complete: false, + backfill_status: 'completed', + backfill_progress: 1, + }) + ), + 'processing' + ) + assert.equal( + getLifetimeSavingsViewState( + createSummary({ is_complete: false, backfill_status: 'paused' }) + ), + 'paused' + ) + }) +}) + +describe('formatSavingsPercent', () => { + it('formats valid ratios and rejects invalid values', () => { + assert.equal(formatSavingsPercent(0.823, 'zh-CN'), '82%') + assert.equal(formatSavingsPercent(0.823, 'zh-CN', 1), '82.3%') + assert.equal(formatSavingsPercent(Number.NaN, 'zh-CN'), '-') + assert.equal(formatSavingsPercent(1.1, 'zh-CN'), '-') + }) +}) diff --git a/web/src/features/dashboard/lib/__tests__/savings-time-range.test.ts b/web/src/features/dashboard/lib/__tests__/savings-time-range.test.ts new file mode 100644 index 000000000000..9a035047d455 --- /dev/null +++ b/web/src/features/dashboard/lib/__tests__/savings-time-range.test.ts @@ -0,0 +1,64 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + formatSavingsQuotaAsCNY, + formatSavingsCNYMicros, + getRollingSavingsTimeRange, +} from '../savings.ts' + +describe('getRollingSavingsTimeRange', () => { + it('returns a rolling 24 hour window aligned to minute boundaries', () => { + const first = getRollingSavingsTimeRange(1_700_000_001_000) + const sameMinute = getRollingSavingsTimeRange(1_700_000_019_000) + const nextMinute = getRollingSavingsTimeRange(1_700_000_061_000) + + assert.equal(first.end_timestamp - first.start_timestamp, 24 * 60 * 60) + assert.deepEqual(sameMinute, first) + assert.equal(nextMinute.end_timestamp - first.end_timestamp, 60) + }) +}) + +describe('formatSavingsQuotaAsCNY', () => { + it('converts quota through USD into CNY with the configured exchange rate', () => { + const formatted = formatSavingsQuotaAsCNY(10_537_576, 500_000, 7.3, 'zh-CN') + + assert.equal(formatted, '¥153.85') + }) +}) + +describe('formatSavingsCNYMicros', () => { + it('formats frozen CNY micros without converting the full value to Number', () => { + assert.equal( + formatSavingsCNYMicros('9007199254740993123456', 'zh-CN'), + '¥9,007,199,254,740,993.12' + ) + }) + + it('keeps useful precision for savings below one yuan', () => { + assert.equal(formatSavingsCNYMicros('123456', 'zh-CN'), '¥0.1235') + }) + + it('rejects malformed and signed values', () => { + assert.equal(formatSavingsCNYMicros('-1', 'zh-CN'), '-') + assert.equal(formatSavingsCNYMicros('1.25', 'zh-CN'), '-') + }) +}) diff --git a/web/src/features/dashboard/lib/savings-chart.ts b/web/src/features/dashboard/lib/savings-chart.ts new file mode 100644 index 000000000000..dc51dadbd9cd --- /dev/null +++ b/web/src/features/dashboard/lib/savings-chart.ts @@ -0,0 +1,105 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { + SavingsSummary, + SavingsTrend, + SavingsTrendGranularity, +} from '@/features/dashboard/types' +import type { TimeGranularity } from '@/lib/time' + +import { savingsQuotaToCNY } from './savings.ts' + +export interface SavingsTrendChartPoint { + Time: string + BucketStart: number + LineSegment: string | null + Official: number | null + Actual: number | null + Savings: number | null + RequestCount: number + EstimatedRequestCount: number + ReconstructedRequestCount: number + CoverageRatio: number +} + +export function normalizeSavingsTrendGranularity( + granularity?: TimeGranularity, + durationSeconds = 0 +): SavingsTrendGranularity { + return granularity === 'hour' && durationSeconds <= 48 * 3600 ? 'hour' : 'day' +} + +export function calculateSavingsRate(summary: SavingsSummary): number { + if (summary.official_quota <= 0) return 0 + return Math.min( + 1, + Math.max(0, summary.savings_quota / summary.official_quota) + ) +} + +export function buildSavingsTrendChartData( + trend: SavingsTrend, + quotaPerUnit: number, + usdExchangeRate: number +): SavingsTrendChartPoint[] { + let lineSegment = 0 + let previousBucketHasEstimates = false + + return trend.buckets.map((bucket) => { + const hasEstimates = bucket.estimated_request_count > 0 + if (hasEstimates && !previousBucketHasEstimates) lineSegment++ + previousBucketHasEstimates = hasEstimates + + return { + Time: formatSavingsBucketTime(bucket.start_timestamp, trend.granularity), + BucketStart: bucket.start_timestamp, + LineSegment: hasEstimates ? `segment-${lineSegment}` : null, + Official: hasEstimates + ? savingsQuotaToCNY( + bucket.official_quota, + quotaPerUnit, + usdExchangeRate + ) + : null, + Actual: hasEstimates + ? savingsQuotaToCNY(bucket.actual_quota, quotaPerUnit, usdExchangeRate) + : null, + Savings: hasEstimates + ? savingsQuotaToCNY(bucket.savings_quota, quotaPerUnit, usdExchangeRate) + : null, + RequestCount: bucket.request_count, + EstimatedRequestCount: bucket.estimated_request_count, + ReconstructedRequestCount: bucket.reconstructed_request_count, + CoverageRatio: bucket.coverage_ratio, + } + }) +} + +function formatSavingsBucketTime( + timestamp: number, + granularity: SavingsTrendGranularity +): string { + const date = new Date(timestamp * 1000) + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + if (granularity === 'hour') { + return `${month}-${day} ${String(date.getHours()).padStart(2, '0')}:00` + } + return `${month}-${day}` +} diff --git a/web/src/features/dashboard/lib/savings-query-keys.ts b/web/src/features/dashboard/lib/savings-query-keys.ts new file mode 100644 index 000000000000..dc9ede4698aa --- /dev/null +++ b/web/src/features/dashboard/lib/savings-query-keys.ts @@ -0,0 +1,21 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export const savingsQueryKeys = { + lifetime: ['savings', 'lifetime'] as const, +} diff --git a/web/src/features/dashboard/lib/savings.ts b/web/src/features/dashboard/lib/savings.ts new file mode 100644 index 000000000000..6693e7cec607 --- /dev/null +++ b/web/src/features/dashboard/lib/savings.ts @@ -0,0 +1,157 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { SavingsLifetimeSummary } from '@/features/dashboard/types' + +const SAVINGS_WINDOW_SECONDS = 24 * 60 * 60 +const SECONDS_PER_MINUTE = 60 +const CNY_MICROS_PER_YUAN = 1_000_000n + +export type SavingsLifetimeViewState = + | 'complete' + | 'empty' + | 'failed' + | 'no_estimates' + | 'not_started' + | 'paused' + | 'processing' + +export function getLifetimeSavingsViewState( + summary: SavingsLifetimeSummary +): SavingsLifetimeViewState { + if (summary.backfill_status === 'failed') return 'failed' + if (summary.request_count <= 0) return 'empty' + + if (summary.is_complete) { + return summary.estimated_request_count > 0 ? 'complete' : 'no_estimates' + } + + if ( + summary.backfill_status === 'pause_requested' || + summary.backfill_status === 'paused' + ) { + return 'paused' + } + if (summary.backfill_status === 'not_started') return 'not_started' + return 'processing' +} + +export function formatSavingsPercent( + value: number, + locales?: Intl.LocalesArgument, + maximumFractionDigits = 0 +): string { + if (!Number.isFinite(value) || value < 0 || value > 1) return '-' + + return new Intl.NumberFormat(locales, { + style: 'percent', + maximumFractionDigits, + }).format(value) +} + +export function getRollingSavingsTimeRange(nowMs = Date.now()): { + start_timestamp: number + end_timestamp: number +} { + const endTimestamp = + Math.floor(nowMs / (SECONDS_PER_MINUTE * 1000)) * SECONDS_PER_MINUTE + return { + start_timestamp: endTimestamp - SAVINGS_WINDOW_SECONDS, + end_timestamp: endTimestamp, + } +} + +export function formatSavingsQuotaAsCNY( + quota: number, + quotaPerUnit: number, + usdExchangeRate: number, + locales?: Intl.LocalesArgument +): string { + if (!Number.isFinite(quota)) return '-' + + const amountCNY = savingsQuotaToCNY(quota, quotaPerUnit, usdExchangeRate) + + return new Intl.NumberFormat(locales, { + style: 'currency', + currency: 'CNY', + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: Math.abs(amountCNY) >= 1 ? 2 : 4, + }).format(amountCNY) +} + +export function savingsQuotaToCNY( + quota: number, + quotaPerUnit: number, + usdExchangeRate: number +): number { + if (!Number.isFinite(quota)) return 0 + + const effectiveQuotaPerUnit = + Number.isFinite(quotaPerUnit) && quotaPerUnit > 0 ? quotaPerUnit : 500_000 + const effectiveExchangeRate = + Number.isFinite(usdExchangeRate) && usdExchangeRate > 0 + ? usdExchangeRate + : 1 + return (quota / effectiveQuotaPerUnit) * effectiveExchangeRate +} + +export function formatSavingsCNYMicros( + value: string, + locales?: Intl.LocalesArgument +): string { + if (!/^\d+$/.test(value)) return '-' + + const micros = BigInt(value) + const whole = micros / CNY_MICROS_PER_YUAN + const remainder = micros % CNY_MICROS_PER_YUAN + const maximumFractionDigits = whole > 0n ? 2 : 4 + const fractionScale = 10n ** BigInt(6 - maximumFractionDigits) + const roundedFraction = (remainder + fractionScale / 2n) / fractionScale + const fractionBase = 10n ** BigInt(maximumFractionDigits) + const roundedWhole = whole + roundedFraction / fractionBase + const normalizedFraction = roundedFraction % fractionBase + const decimal = `${roundedWhole}.${normalizedFraction + .toString() + .padStart(maximumFractionDigits, '0')}` + + const formatter = new Intl.NumberFormat(locales, { + style: 'currency', + currency: 'CNY', + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits, + }) + const parts = formatter.formatToParts(0) + const groupSeparator = + formatter.formatToParts(1000).find((part) => part.type === 'group') + ?.value ?? ',' + const decimalSeparator = + formatter.formatToParts(0.1).find((part) => part.type === 'decimal') + ?.value ?? '.' + const groupedWhole = roundedWhole + .toString() + .replaceAll(/\B(?=(\d{3})+(?!\d))/g, groupSeparator) + const trimmedFraction = decimal.split('.')[1].replace(/0+$/, '') + const amount = trimmedFraction + ? `${groupedWhole}${decimalSeparator}${trimmedFraction}` + : groupedWhole + return parts + .map((part) => (part.type === 'integer' ? amount : part.value)) + .join('') +} diff --git a/web/src/features/dashboard/types.ts b/web/src/features/dashboard/types.ts index b8771df2565a..f3b18078f35c 100644 --- a/web/src/features/dashboard/types.ts +++ b/web/src/features/dashboard/types.ts @@ -33,6 +33,77 @@ export interface QuotaDataItem { quota?: number } +export interface SavingsSummary { + enabled: boolean + savings_quota: number + official_quota: number + actual_quota: number + request_count: number + estimated_request_count: number + snapshot_request_count?: number + reconstructed_request_count?: number + coverage_ratio: number + source: string + official_confirmed: boolean + source_updated_at: number + rebuild_price_snapshot_at?: number + official_price_stale: boolean + is_partial: boolean + window_days: number +} + +export interface SavingsLifetimeSummary { + enabled: boolean + show_on_dashboard: boolean + show_on_wallet: boolean + currency: 'CNY' + savings_cny_micros: string + savings_quota: string + official_quota: string + actual_quota: string + request_count: number + estimated_request_count: number + snapshot_request_count: number + reconstructed_request_count: number + coverage_ratio: number + statistics_started_at: number + last_aggregated_at: number + backfill_status: + | 'not_started' + | 'pending' + | 'running' + | 'pause_requested' + | 'paused' + | 'completed' + | 'failed' + backfill_progress: number + is_complete: boolean +} + +export type SavingsTrendGranularity = 'hour' | 'day' + +export interface SavingsTrendBucket { + start_timestamp: number + end_timestamp: number + official_quota: number + actual_quota: number + savings_quota: number + request_count: number + estimated_request_count: number + snapshot_request_count: number + reconstructed_request_count: number + coverage_ratio: number +} + +export interface SavingsTrend { + granularity: SavingsTrendGranularity + utc_offset_minutes: number + start_timestamp: number + end_timestamp: number + summary: SavingsSummary + buckets: SavingsTrendBucket[] +} + export interface FlowQuotaDataItem { user_id?: number username?: string diff --git a/web/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/src/features/models/components/drawers/model-mutate-drawer.tsx index 532b8931bc4d..665229521ac3 100644 --- a/web/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -314,6 +314,7 @@ export function ModelMutateDrawer({ 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'tool_price_setting.prices': '{}', + SavingsEstimateSetting: '', TopupGroupRatio: '', GroupRatio: '', UserUsableGroups: '', diff --git a/web/src/features/system-info/components/system-tasks-panel.tsx b/web/src/features/system-info/components/system-tasks-panel.tsx index 3a49a3fd5fcf..6b3a2c166e5e 100644 --- a/web/src/features/system-info/components/system-tasks-panel.tsx +++ b/web/src/features/system-info/components/system-tasks-panel.tsx @@ -33,6 +33,10 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + isActiveStatus, + isPollingStatus, +} from '@/features/system-info/lib/system-task-status' import { listSystemTasks } from '@/features/system-settings/api' import type { SystemTask, @@ -44,10 +48,13 @@ import { cn } from '@/lib/utils' const TASK_LIMIT = 20 const ACTIVE_POLL_INTERVAL_MS = 8000 +const TASK_SKELETON_KEYS = ['task-1', 'task-2', 'task-3', 'task-4'] as const const STATUS_VARIANT: Record = { pending: 'secondary', running: 'secondary', + pause_requested: 'secondary', + paused: 'secondary', succeeded: 'secondary', failed: 'destructive', } @@ -57,6 +64,9 @@ const STATUS_CLASS_NAME: Record = { 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300', running: 'bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300 [&_span]:bg-sky-500', + pause_requested: + 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300', + paused: 'bg-muted text-muted-foreground', succeeded: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300', failed: '', @@ -65,6 +75,8 @@ const STATUS_CLASS_NAME: Record = { const STATUS_DOT_CLASS_NAME: Record = { pending: 'bg-amber-500', running: 'bg-sky-500', + pause_requested: 'bg-amber-500', + paused: 'bg-muted-foreground', succeeded: 'bg-emerald-500', failed: 'bg-destructive', } @@ -72,6 +84,8 @@ const STATUS_DOT_CLASS_NAME: Record = { const PROGRESS_BAR_CLASS_NAME: Record = { pending: '[&_[data-slot=progress-indicator]]:bg-amber-500', running: '[&_[data-slot=progress-indicator]]:bg-sky-500', + pause_requested: '[&_[data-slot=progress-indicator]]:bg-amber-500', + paused: '[&_[data-slot=progress-indicator]]:bg-muted-foreground', succeeded: '[&_[data-slot=progress-indicator]]:bg-emerald-500', failed: '[&_[data-slot=progress-indicator]]:bg-destructive', } @@ -84,19 +98,19 @@ const TYPE_LABEL: Record = { model_update: 'Batch upstream model update', midjourney_poll: 'Drawing task polling', async_task_poll: 'Async task polling', + savings_lifetime_backfill: 'Savings lifetime backfill', } const TYPE_DISPLAY_ID: Record = { midjourney_poll: 'drawing_task_poll', } -function isActiveStatus(status: SystemTaskStatus) { - return status === 'pending' || status === 'running' -} - function getProgress(task: SystemTask): number | null { const progress = (task.state as { progress?: unknown } | undefined)?.progress if (typeof progress !== 'number' || Number.isNaN(progress)) return null + if (task.type === 'savings_lifetime_backfill' && progress <= 1) { + return Math.min(100, Math.max(0, progress * 100)) + } return Math.min(100, Math.max(0, progress)) } @@ -218,7 +232,7 @@ export function SystemTasksPanel() { staleTime: 30 * 1000, retry: false, refetchInterval: (query) => - query.state.data?.some((task) => isActiveStatus(task.status)) + query.state.data?.some((task) => isPollingStatus(task.status)) ? ACTIVE_POLL_INTERVAL_MS : false, }) @@ -226,9 +240,90 @@ export function SystemTasksPanel() { const tasks = tasksQuery.data ?? [] const loading = tasksQuery.isLoading const refreshing = tasksQuery.isFetching && !tasksQuery.isLoading - const hasActiveTasks = tasks.some((task) => isActiveStatus(task.status)) + const hasPollingTasks = tasks.some((task) => isPollingStatus(task.status)) const activeTasks = tasks.filter((task) => isActiveStatus(task.status)) const historyTasks = tasks.filter((task) => !isActiveStatus(task.status)) + let content + if (loading) { + content = ( +
+ {TASK_SKELETON_KEYS.map((key) => ( + + ))} +
+ ) + } else if (tasksQuery.isError) { + content = ( + { + void tasksQuery.refetch() + }} + className='min-h-[260px]' + /> + ) + } else if (tasks.length === 0) { + content = ( +
+
+
+

+ {t('No system tasks yet.')} +

+
+ ) + } else { + content = ( +
+
+
+
+

{t('Active Tasks')}

+

+ {t('Tasks currently pending, running, or paused.')} +

+
+ {activeTasks.length} +
+ {activeTasks.length > 0 ? ( + + ) : ( +
+ {t('No active system tasks.')} +
+ )} +
+ +
+
+
+

{t('Task History')}

+

+ {t('Recently completed or failed system task runs.')} +

+
+ {historyTasks.length} +
+ {historyTasks.length > 0 ? ( + + ) : ( +
+ {t('No historical system tasks.')} +
+ )} +
+
+ ) + } return (
@@ -256,11 +351,11 @@ export function SystemTasksPanel() {
-
- {loading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : tasksQuery.isError ? ( - { - void tasksQuery.refetch() - }} - className='min-h-[260px]' - /> - ) : tasks.length === 0 ? ( -
-
-
-

- {t('No system tasks yet.')} -

-
- ) : ( -
-
-
-
-

{t('Active Tasks')}

-

- {t('Tasks currently pending or running.')} -

-
- {activeTasks.length} -
- {activeTasks.length > 0 ? ( - - ) : ( -
- {t('No active system tasks.')} -
- )} -
- -
-
-
-

{t('Task History')}

-

- {t('Recently completed or failed system task runs.')} -

-
- {historyTasks.length} -
- {historyTasks.length > 0 ? ( - - ) : ( -
- {t('No historical system tasks.')} -
- )} -
-
- )} -
+
{content}
) } diff --git a/web/src/features/system-info/lib/__tests__/system-task-status.test.ts b/web/src/features/system-info/lib/__tests__/system-task-status.test.ts new file mode 100644 index 000000000000..e26c76423866 --- /dev/null +++ b/web/src/features/system-info/lib/__tests__/system-task-status.test.ts @@ -0,0 +1,40 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { isActiveStatus, isPollingStatus } from '../system-task-status.ts' + +describe('system task status classification', () => { + it('keeps paused tasks active without reporting active polling', () => { + assert.equal(isActiveStatus('paused'), true) + assert.equal(isPollingStatus('paused'), false) + }) + + it('polls runnable states and excludes terminal states', () => { + for (const status of ['pending', 'running', 'pause_requested'] as const) { + assert.equal(isActiveStatus(status), true) + assert.equal(isPollingStatus(status), true) + } + for (const status of ['succeeded', 'failed'] as const) { + assert.equal(isActiveStatus(status), false) + assert.equal(isPollingStatus(status), false) + } + }) +}) diff --git a/web/src/features/system-info/lib/system-task-status.ts b/web/src/features/system-info/lib/system-task-status.ts new file mode 100644 index 000000000000..ae2330087c35 --- /dev/null +++ b/web/src/features/system-info/lib/system-task-status.ts @@ -0,0 +1,34 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { SystemTaskStatus } from '@/features/system-settings/types' + +export function isActiveStatus(status: SystemTaskStatus): boolean { + return ( + status === 'pending' || + status === 'running' || + status === 'pause_requested' || + status === 'paused' + ) +} + +export function isPollingStatus(status: SystemTaskStatus): boolean { + return ( + status === 'pending' || status === 'running' || status === 'pause_requested' + ) +} diff --git a/web/src/features/system-settings/api.ts b/web/src/features/system-settings/api.ts index 9e9c9a7f25e0..cdbc156d8212 100644 --- a/web/src/features/system-settings/api.ts +++ b/web/src/features/system-settings/api.ts @@ -22,6 +22,8 @@ import type { ConfirmPaymentComplianceResponse, FetchUpstreamRatiosRequest, LogCleanupTask, + SavingsLifetimeBackfillTask, + StartSavingsLifetimeBackfillResponse, SystemOptionsResponse, SystemTaskListResponse, SystemTaskResponse, @@ -70,6 +72,61 @@ export async function getCurrentLogCleanupTask() { return res.data } +export async function startSavingsLifetimeBackfill(): Promise { + const res = await api.post( + '/api/system-task/savings-lifetime-backfill' + ) + return res.data +} + +export async function getSavingsLifetimeBackfill(): Promise< + SystemTaskResponse +> { + const res = await api.get< + SystemTaskResponse + >('/api/system-task/savings-lifetime-backfill') + return res.data +} + +export async function pauseSavingsLifetimeBackfill( + taskId: string +): Promise> { + const res = await api.post>( + '/api/system-task/savings-lifetime-backfill/pause', + null, + { + params: { task_id: taskId }, + } + ) + return res.data +} + +export async function resumeSavingsLifetimeBackfill( + taskId: string +): Promise> { + const res = await api.post>( + '/api/system-task/savings-lifetime-backfill/resume', + null, + { + params: { task_id: taskId }, + } + ) + return res.data +} + +export async function retrySavingsLifetimeBackfill( + taskId: string +): Promise> { + const res = await api.post>( + '/api/system-task/savings-lifetime-backfill/retry', + null, + { + params: { task_id: taskId }, + } + ) + return res.data +} + export async function getSystemTask(taskId: string) { const res = await api.get>( `/api/system-task/${taskId}` diff --git a/web/src/features/system-settings/billing/index.tsx b/web/src/features/system-settings/billing/index.tsx index a49bd0a85d85..41c7ef200e40 100644 --- a/web/src/features/system-settings/billing/index.tsx +++ b/web/src/features/system-settings/billing/index.tsx @@ -51,6 +51,7 @@ const defaultBillingSettings: BillingSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'tool_price_setting.prices': '{}', + SavingsEstimateSetting: '', TopupGroupRatio: '', GroupRatio: '', UserUsableGroups: '', diff --git a/web/src/features/system-settings/billing/section-registry.tsx b/web/src/features/system-settings/billing/section-registry.tsx index 43cd0e986b91..a2a8b148d44b 100644 --- a/web/src/features/system-settings/billing/section-registry.tsx +++ b/web/src/features/system-settings/billing/section-registry.tsx @@ -111,7 +111,14 @@ const BILLING_SECTIONS = [ modelDefaults={getModelDefaults(settings)} groupDefaults={getGroupDefaults(settings)} toolPricesDefault={settings['tool_price_setting.prices']} - visibleTabs={['models', 'unset-models', 'tool-prices', 'upstream-sync']} + savingsEstimateDefault={settings.SavingsEstimateSetting} + visibleTabs={[ + 'models', + 'unset-models', + 'tool-prices', + 'upstream-sync', + 'savings', + ]} /> ), }, @@ -124,6 +131,7 @@ const BILLING_SECTIONS = [ modelDefaults={getModelDefaults(settings)} groupDefaults={getGroupDefaults(settings)} toolPricesDefault={settings['tool_price_setting.prices']} + savingsEstimateDefault={settings.SavingsEstimateSetting} visibleTabs={['groups']} /> ), diff --git a/web/src/features/system-settings/lib/__tests__/savings-lifetime-query.test.ts b/web/src/features/system-settings/lib/__tests__/savings-lifetime-query.test.ts new file mode 100644 index 000000000000..856adacdd19c --- /dev/null +++ b/web/src/features/system-settings/lib/__tests__/savings-lifetime-query.test.ts @@ -0,0 +1,40 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { QueryClient } from '@tanstack/react-query' + +import { savingsQueryKeys } from '../../../dashboard/lib/savings-query-keys.ts' +import { invalidateSavingsLifetimeQueries } from '../savings-lifetime-query.ts' + +describe('invalidateSavingsLifetimeQueries', () => { + it('invalidates the shared dashboard and wallet lifetime query', async () => { + const queryClient = new QueryClient() + queryClient.setQueryData(savingsQueryKeys.lifetime, { success: true }) + + await invalidateSavingsLifetimeQueries(queryClient) + + assert.equal( + queryClient.getQueryState(savingsQueryKeys.lifetime)?.isInvalidated, + true + ) + queryClient.clear() + }) +}) diff --git a/web/src/features/system-settings/lib/savings-lifetime-query.ts b/web/src/features/system-settings/lib/savings-lifetime-query.ts new file mode 100644 index 000000000000..a0ab307839cd --- /dev/null +++ b/web/src/features/system-settings/lib/savings-lifetime-query.ts @@ -0,0 +1,27 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import type { QueryClient } from '@tanstack/react-query' + +import { savingsQueryKeys } from '../../dashboard/lib/savings-query-keys.ts' + +export function invalidateSavingsLifetimeQueries( + queryClient: QueryClient +): Promise { + return queryClient.invalidateQueries({ queryKey: savingsQueryKeys.lifetime }) +} diff --git a/web/src/features/system-settings/models/__tests__/savings-estimate-setting.test.ts b/web/src/features/system-settings/models/__tests__/savings-estimate-setting.test.ts new file mode 100644 index 000000000000..747e65a74601 --- /dev/null +++ b/web/src/features/system-settings/models/__tests__/savings-estimate-setting.test.ts @@ -0,0 +1,74 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + DEFAULT_SAVINGS_SETTING, + formatSavingsSetting, + parseSavingsSetting, +} from '../savings-estimate-setting.ts' + +describe('savings estimate setting serialization', () => { + it('applies defaults and removes obsolete fields', () => { + const setting = parseSavingsSetting(`{ + "enabled": true, + "show_on_dashboard": "yes", + "official_price_stale_days": 0.5, + "max_summary_days": 7.9, + "max_summary_log_rows": 0, + "official_prices": [], + "reference_price_source": "legacy", + "include_unpriced_models": true, + "custom_field": "preserved" + }`) + + assert.ok(setting) + assert.equal(setting.enabled, true) + assert.equal( + setting.show_on_dashboard, + DEFAULT_SAVINGS_SETTING.show_on_dashboard + ) + assert.equal( + setting.official_price_stale_days, + DEFAULT_SAVINGS_SETTING.official_price_stale_days + ) + assert.equal(setting.max_summary_days, 7) + assert.equal( + setting.max_summary_log_rows, + DEFAULT_SAVINGS_SETTING.max_summary_log_rows + ) + assert.deepEqual(setting.official_prices, {}) + assert.equal(setting.reference_price_source, undefined) + assert.equal(setting.include_unpriced_models, undefined) + assert.equal(setting.custom_field, 'preserved') + }) + + it('rejects invalid JSON and non-object roots', () => { + assert.equal(parseSavingsSetting('{invalid'), null) + assert.equal(parseSavingsSetting('[]'), null) + }) + + it('formats normalized settings while preserving invalid source text', () => { + const formatted = formatSavingsSetting('{"enabled":true}') + assert.deepEqual(JSON.parse(formatted), parseSavingsSetting(formatted)) + assert.match(formatted, /\n {2}"enabled": true/) + assert.equal(formatSavingsSetting('{invalid'), '{invalid') + }) +}) diff --git a/web/src/features/system-settings/models/index.tsx b/web/src/features/system-settings/models/index.tsx index 0448720a2067..d87e24922d8d 100644 --- a/web/src/features/system-settings/models/index.tsx +++ b/web/src/features/system-settings/models/index.tsx @@ -55,6 +55,7 @@ const defaultModelSettings: ModelSettings = { 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', 'tool_price_setting.prices': '{}', + SavingsEstimateSetting: '', TopupGroupRatio: '', GroupRatio: '', UserUsableGroups: '', diff --git a/web/src/features/system-settings/models/ratio-settings-card.tsx b/web/src/features/system-settings/models/ratio-settings-card.tsx index 0fa2489c4290..84596cac47f4 100644 --- a/web/src/features/system-settings/models/ratio-settings-card.tsx +++ b/web/src/features/system-settings/models/ratio-settings-card.tsx @@ -34,6 +34,7 @@ import { useUpdateOption } from '../hooks/use-update-option' import { positiveIntegerSchema } from '../utils/numeric-field' import { GroupRatioForm } from './group-ratio-form' import { ModelRatioForm } from './model-ratio-form' +import { SavingsEstimateSettings } from './savings-estimate-settings' import { ToolPriceSettings } from './tool-price-settings' import { UpstreamRatioSync } from './upstream-ratio-sync' import { @@ -144,11 +145,13 @@ type RatioTabId = | 'groups' | 'tool-prices' | 'upstream-sync' + | 'savings' type RatioSettingsCardProps = { modelDefaults: ModelFormValues groupDefaults: GroupFormValues toolPricesDefault: string + savingsEstimateDefault?: string titleKey?: string visibleTabs?: RatioTabId[] } @@ -157,6 +160,7 @@ export function RatioSettingsCard({ modelDefaults, groupDefaults, toolPricesDefault, + savingsEstimateDefault = '', titleKey = 'Pricing Ratios', visibleTabs = ['models', 'groups', 'tool-prices', 'upstream-sync'], }: RatioSettingsCardProps) { @@ -408,6 +412,7 @@ export function RatioSettingsCard({ groups: 'Group ratios', 'tool-prices': 'Tool prices', 'upstream-sync': 'Upstream price sync', + savings: 'Savings estimate', } const tabsGridClass = { @@ -445,6 +450,9 @@ export function RatioSettingsCard({ if (tab === 'tool-prices') { return } + if (tab === 'savings') { + return + } return ( . + +For commercial licensing, please contact support@quantumnous.com +*/ +export const DEFAULT_SAVINGS_SETTING = { + enabled: false, + show_on_dashboard: true, + show_on_usage_logs: true, + local_pricing_official_confirmed: true, + rebuild_legacy_logs: true, + require_official_confirmation: true, + official_price_stale_days: 90, + max_summary_days: 31, + max_summary_log_rows: 50000, + lifetime_enabled: false, + lifetime_backfill_batch_size: 1000, + lifetime_show_on_dashboard: true, + lifetime_show_on_wallet: false, + official_prices: {}, +} + +export type SavingsEstimateSetting = Record & + typeof DEFAULT_SAVINGS_SETTING + +export type BooleanSettingKey = + | 'enabled' + | 'show_on_dashboard' + | 'show_on_usage_logs' + | 'local_pricing_official_confirmed' + | 'rebuild_legacy_logs' + | 'require_official_confirmation' + | 'lifetime_enabled' + | 'lifetime_show_on_dashboard' + | 'lifetime_show_on_wallet' + +export type NumberSettingKey = + | 'official_price_stale_days' + | 'max_summary_days' + | 'max_summary_log_rows' + | 'lifetime_backfill_batch_size' + +export function isPlainObject( + value: unknown +): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +export function parseSavingsSetting( + value: string +): SavingsEstimateSetting | null { + try { + const parsed: unknown = JSON.parse(value.trim() || '{}') + if (!isPlainObject(parsed)) return null + + const setting = { + ...DEFAULT_SAVINGS_SETTING, + ...parsed, + } as SavingsEstimateSetting + delete setting.reference_price_source + delete setting.include_unpriced_models + + for (const key of [ + 'enabled', + 'show_on_dashboard', + 'show_on_usage_logs', + 'local_pricing_official_confirmed', + 'rebuild_legacy_logs', + 'require_official_confirmation', + 'lifetime_enabled', + 'lifetime_show_on_dashboard', + 'lifetime_show_on_wallet', + ] satisfies BooleanSettingKey[]) { + if (typeof setting[key] !== 'boolean') { + setting[key] = DEFAULT_SAVINGS_SETTING[key] + } + } + + for (const key of [ + 'official_price_stale_days', + 'max_summary_days', + 'max_summary_log_rows', + 'lifetime_backfill_batch_size', + ] satisfies NumberSettingKey[]) { + const minimum = key === 'lifetime_backfill_batch_size' ? 500 : 1 + const maximum = key === 'lifetime_backfill_batch_size' ? 5000 : Infinity + if ( + typeof setting[key] !== 'number' || + !Number.isFinite(setting[key]) || + setting[key] < minimum || + setting[key] > maximum + ) { + setting[key] = DEFAULT_SAVINGS_SETTING[key] + } else { + setting[key] = Math.floor(setting[key]) + } + } + + if (!isPlainObject(setting.official_prices)) { + setting.official_prices = {} + } + return setting + } catch { + return null + } +} + +export function formatSavingsSetting(value: string): string { + const setting = parseSavingsSetting(value) + return setting ? JSON.stringify(setting, null, 2) : value +} diff --git a/web/src/features/system-settings/models/savings-estimate-settings.tsx b/web/src/features/system-settings/models/savings-estimate-settings.tsx new file mode 100644 index 000000000000..738e3a6fa807 --- /dev/null +++ b/web/src/features/system-settings/models/savings-estimate-settings.tsx @@ -0,0 +1,458 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { Code2, Save } from 'lucide-react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { JsonCodeEditor } from '@/components/json-code-editor' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + +import { + SettingsSwitchField, + SettingsSwitchRow, +} from '../components/settings-form-layout' +import { useUpdateOption } from '../hooks/use-update-option' +import { + DEFAULT_SAVINGS_SETTING, + formatSavingsSetting, + isPlainObject, + parseSavingsSetting, + type BooleanSettingKey, + type NumberSettingKey, + type SavingsEstimateSetting, +} from './savings-estimate-setting' +import { SavingsLifetimeBackfill } from './savings-lifetime-backfill' +import { normalizeJsonString, validateJsonString } from './utils' + +const OPTION_KEY = 'SavingsEstimateSetting' + +type SavingsEstimateSettingsProps = { + defaultValue: string +} + +export const SavingsEstimateSettings = memo(function SavingsEstimateSettings({ + defaultValue, +}: SavingsEstimateSettingsProps) { + const { t } = useTranslation() + const updateOption = useUpdateOption() + const [editMode, setEditMode] = useState<'visual' | 'json'>('visual') + const [setting, setSetting] = useState( + () => parseSavingsSetting(defaultValue) ?? { ...DEFAULT_SAVINGS_SETTING } + ) + const [jsonText, setJsonText] = useState(() => + formatSavingsSetting(defaultValue) + ) + + useEffect(() => { + const nextSetting = parseSavingsSetting(defaultValue) + if (nextSetting) setSetting(nextSetting) + setJsonText(formatSavingsSetting(defaultValue)) + }, [defaultValue]) + + const validation = useMemo( + () => + validateJsonString(jsonText, { + predicate: isPlainObject, + predicateMessage: 'JSON must be an object', + }), + [jsonText] + ) + const savedLifetimeEnabled = useMemo( + () => parseSavingsSetting(defaultValue)?.lifetime_enabled === true, + [defaultValue] + ) + const validationMessage = + validation.message === 'JSON must be an object' + ? t('JSON must be an object') + : validation.message || t('Invalid JSON') + + const updateBoolean = useCallback( + (key: BooleanSettingKey, checked: boolean) => { + setSetting((current) => ({ + ...current, + [key]: checked, + ...(key === 'enabled' && checked + ? { require_official_confirmation: true } + : {}), + })) + }, + [] + ) + + const updateNumber = useCallback((key: NumberSettingKey, value: string) => { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 1) return + setSetting((current) => ({ + ...current, + [key]: Math.floor(parsed), + })) + }, []) + + const handleModeChange = useCallback( + (nextMode: string) => { + if (nextMode === 'json') { + setJsonText(JSON.stringify(setting, null, 2)) + setEditMode('json') + return + } + + const nextSetting = parseSavingsSetting(jsonText) + if (!nextSetting) { + toast.error(validationMessage) + return + } + setSetting(nextSetting) + setJsonText(JSON.stringify(nextSetting, null, 2)) + setEditMode('visual') + }, + [jsonText, setting, validationMessage] + ) + + const handleSave = useCallback(async () => { + const currentSetting = + editMode === 'visual' ? setting : parseSavingsSetting(jsonText) + if (!currentSetting) { + toast.error(validationMessage) + return + } + if ( + currentSetting.enabled && + !currentSetting.require_official_confirmation + ) { + toast.error( + t( + 'Official price confirmation is required while savings estimates are enabled.' + ) + ) + return + } + + const currentText = JSON.stringify(currentSetting) + const normalized = normalizeJsonString(currentText) + const saved = normalizeJsonString(formatSavingsSetting(defaultValue)) + if (normalized === saved) { + toast.info(t('No changes to save')) + return + } + + try { + await updateOption.mutateAsync({ + key: OPTION_KEY, + value: normalized, + }) + } catch { + // useUpdateOption handles the user-facing error toast. + } + }, [ + defaultValue, + editMode, + jsonText, + setting, + t, + updateOption, + validationMessage, + ]) + + const overrideCount = Object.keys(setting.official_prices).length + + return ( +
+ + + {t( + 'Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.' + )} + + + + + + {t('Visual')} + {t('JSON')} + + + +
+

{t('General')}

+
+ updateBoolean('enabled', checked)} + label={t('Enable savings estimates')} + description={t( + 'Calculate estimated savings using official model prices.' + )} + /> + + updateBoolean('show_on_dashboard', checked) + } + label={t('Show on dashboard')} + description={t( + 'Show the savings summary and trend on the user dashboard.' + )} + /> + + updateBoolean('show_on_usage_logs', checked) + } + label={t('Show in usage logs')} + description={t( + 'Show request-level savings estimates in usage logs.' + )} + /> +
+
+ +
+

{t('Official pricing')}

+
+ + updateBoolean('local_pricing_official_confirmed', checked) + } + label={t('Confirm marketplace pricing as official')} + description={t( + 'Treat local model marketplace prices as official reference prices.' + )} + /> + + updateBoolean('require_official_confirmation', checked) + } + label={t('Require official price confirmation')} + description={t( + setting.enabled + ? 'Required while savings estimates are enabled.' + : 'Exclude prices that have not been confirmed as official.' + )} + disabled={setting.enabled} + /> + +
+ +

+ {t( + 'Prices older than this are excluded from savings estimates.' + )} +

+
+ + updateNumber( + 'official_price_stale_days', + event.target.value + ) + } + /> +
+
+
+ +
+
+

{t('Lifetime savings')}

+

+ {t( + 'Keep a stable cumulative savings total without scanning usage logs when users open a page.' + )} +

+
+
+ + updateBoolean('lifetime_enabled', checked) + } + label={t('Enable lifetime savings')} + description={t( + 'Aggregate new usage into a frozen lifetime savings total.' + )} + /> + + updateBoolean('lifetime_show_on_dashboard', checked) + } + label={t('Show lifetime savings on dashboard')} + description={t( + 'Show cumulative savings, coverage, and backfill progress on the user dashboard.' + )} + /> + + updateBoolean('lifetime_show_on_wallet', checked) + } + label={t('Show lifetime savings in wallet')} + description={t( + 'Add the frozen cumulative savings amount to the wallet summary.' + )} + /> + +
+ +

+ {t('Process between 500 and 5000 usage logs per batch.')} +

+
+ + updateNumber( + 'lifetime_backfill_batch_size', + event.target.value + ) + } + /> +
+
+ +
+ +
+

+ {t('Historical estimates')} +

+
+ + updateBoolean('rebuild_legacy_logs', checked) + } + label={t('Recalculate legacy usage logs')} + description={t( + 'Estimate historical logs without a saved official price snapshot.' + )} + /> + +
+ +

+ {t('Limit the date range of each savings summary query.')} +

+
+ + updateNumber('max_summary_days', event.target.value) + } + /> +
+ +
+ +

+ {t('Limit the number of usage logs scanned per summary.')} +

+
+ + updateNumber('max_summary_log_rows', event.target.value) + } + /> +
+
+
+ +
+
+

{t('Price overrides')}

+

+ {t('{{count}} model price overrides', { count: overrideCount })} +

+
+ +
+
+ + + + {!validation.valid && ( +

{validationMessage}

+ )} +
+
+ +
+ +
+
+ ) +}) diff --git a/web/src/features/system-settings/models/savings-lifetime-backfill.tsx b/web/src/features/system-settings/models/savings-lifetime-backfill.tsx new file mode 100644 index 000000000000..e030cafa8016 --- /dev/null +++ b/web/src/features/system-settings/models/savings-lifetime-backfill.tsx @@ -0,0 +1,284 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { DatabaseZap, Pause, Play, RefreshCw, RotateCcw } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Progress } from '@/components/ui/progress' +import { formatTimestampToDate } from '@/lib/format' + +import { + getSavingsLifetimeBackfill, + pauseSavingsLifetimeBackfill, + resumeSavingsLifetimeBackfill, + retrySavingsLifetimeBackfill, + startSavingsLifetimeBackfill, +} from '../api' +import { invalidateSavingsLifetimeQueries } from '../lib/savings-lifetime-query' +import type { SavingsLifetimeBackfillTask } from '../types' + +const QUERY_KEY = ['system-settings', 'savings-lifetime-backfill'] as const + +function isActiveTask(task: SavingsLifetimeBackfillTask | null | undefined) { + return ( + task?.status === 'pending' || + task?.status === 'running' || + task?.status === 'pause_requested' + ) +} + +function isUnfinishedTask( + task: SavingsLifetimeBackfillTask | null | undefined +) { + return isActiveTask(task) || task?.status === 'paused' +} + +const STATUS_LABEL_KEYS = { + pending: 'Pending', + running: 'Running', + pause_requested: 'Pausing', + paused: 'Paused', + succeeded: 'Completed', + failed: 'Failed', +} as const + +type SavingsLifetimeBackfillProps = { + enabled: boolean +} + +export function SavingsLifetimeBackfill(props: SavingsLifetimeBackfillProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const taskQuery = useQuery({ + queryKey: QUERY_KEY, + queryFn: getSavingsLifetimeBackfill, + refetchInterval: (query) => + isActiveTask(query.state.data?.data) ? 2000 : false, + }) + const startMutation = useMutation({ + mutationFn: startSavingsLifetimeBackfill, + onSuccess: (response) => { + if (!response.success || !response.data) { + toast.error( + response.message || t('Failed to start historical backfill') + ) + return + } + queryClient.setQueryData(QUERY_KEY, { + success: true, + message: '', + data: response.data.task, + }) + void invalidateSavingsLifetimeQueries(queryClient) + toast.success( + response.data.created + ? t('Historical savings backfill started') + : t('Historical savings backfill is already active') + ) + }, + onError: () => toast.error(t('Failed to start historical backfill')), + }) + const pauseMutation = useMutation({ + mutationFn: pauseSavingsLifetimeBackfill, + onSuccess: (response) => { + if (!response.success || !response.data) { + toast.error( + response.message || t('Failed to pause historical backfill') + ) + return + } + queryClient.setQueryData(QUERY_KEY, response) + void invalidateSavingsLifetimeQueries(queryClient) + toast.success(t('Historical savings backfill pause requested')) + }, + onError: () => toast.error(t('Failed to pause historical backfill')), + }) + const resumeMutation = useMutation({ + mutationFn: resumeSavingsLifetimeBackfill, + onSuccess: (response) => { + if (!response.success || !response.data) { + toast.error( + response.message || t('Failed to resume historical backfill') + ) + return + } + queryClient.setQueryData(QUERY_KEY, response) + void invalidateSavingsLifetimeQueries(queryClient) + toast.success(t('Historical savings backfill resumed')) + }, + onError: () => toast.error(t('Failed to resume historical backfill')), + }) + const retryMutation = useMutation({ + mutationFn: retrySavingsLifetimeBackfill, + onSuccess: (response) => { + if (!response.success || !response.data) { + toast.error( + response.message || t('Failed to retry historical backfill') + ) + return + } + queryClient.setQueryData(QUERY_KEY, response) + void invalidateSavingsLifetimeQueries(queryClient) + toast.success(t('Historical savings backfill retry started')) + }, + onError: () => toast.error(t('Failed to retry historical backfill')), + }) + + const task = taskQuery.data?.data + const active = isActiveTask(task) + const unfinished = isUnfinishedTask(task) + const state = task?.state + const processed = state?.processed_count ?? task?.result?.processed_count ?? 0 + const estimated = state?.estimated_count ?? task?.result?.estimated_count ?? 0 + const skipped = state?.skipped_count ?? task?.result?.skipped_count ?? 0 + const ambiguous = + state?.ambiguous_cursor_count ?? task?.result?.ambiguous_cursor_count ?? 0 + const total = task?.payload?.target_count ?? 0 + const priceSnapshotAt = task?.payload?.price_snapshot_at ?? 0 + const progress = Math.min( + 100, + Math.max( + 0, + (state?.progress ?? (task?.status === 'succeeded' ? 1 : 0)) * 100 + ) + ) + const statusLabel = task + ? t(STATUS_LABEL_KEYS[task.status]) + : t('Not Started') + + return ( +
+
+
+
+
+

+ {t( + 'Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.' + )} +

+
+
+ {(task?.status === 'pending' || task?.status === 'running') && ( + + )} + {task?.status === 'paused' && ( + + )} + {task?.status === 'failed' && ( + + )} + {task?.status !== 'failed' && ( + + )} +
+
+ + {!props.enabled && ( +

+ {t('Enable and save lifetime savings before starting a backfill.')} +

+ )} + + {task && ( +
+
+ + {t('{{processed}} of {{total}} usage logs processed', { + processed, + total, + })} + + {Math.round(progress)}% +
+ +
+ {t('Estimated: {{count}}', { count: estimated })} + {t('Skipped: {{count}}', { count: skipped })} + {priceSnapshotAt > 0 && ( + + {t('Prices frozen at {{time}}', { + time: formatTimestampToDate(priceSnapshotAt), + })} + + )} +
+ {task.status === 'failed' && task.error && ( +

{task.error}

+ )} + {ambiguous > 0 && ( +

+ {t('Ambiguous ClickHouse rows skipped: {{count}}', { + count: ambiguous, + })} +

+ )} +
+ )} +
+ ) +} diff --git a/web/src/features/system-settings/types.ts b/web/src/features/system-settings/types.ts index 6bb6f2dbc436..3fa34a77054a 100644 --- a/web/src/features/system-settings/types.ts +++ b/web/src/features/system-settings/types.ts @@ -50,7 +50,13 @@ export type ConfirmPaymentComplianceResponse = { } } -export type SystemTaskStatus = 'pending' | 'running' | 'succeeded' | 'failed' +export type SystemTaskStatus = + | 'pending' + | 'running' + | 'pause_requested' + | 'paused' + | 'succeeded' + | 'failed' export type SystemTask< TPayload = Record, @@ -94,6 +100,55 @@ export type LogCleanupTask = SystemTask< LogCleanupTaskResult > +export type SavingsLifetimeBackfillPayload = { + target: { + id: number + created_at: number + request_id: string + } + target_count: number + batch_size: number + price_snapshot_at: number + pricing_snapshot_hash: string + quota_per_unit_snapshot: number + usd_cny_rate_micros: number +} + +export type SavingsLifetimeBackfillState = { + cursor: { + id: number + created_at: number + request_id: string + } + processed_count: number + estimated_count: number + skipped_count: number + ambiguous_cursor_count: number + progress: number +} + +export type SavingsLifetimeBackfillResult = { + processed_count: number + estimated_count: number + skipped_count: number + ambiguous_cursor_count: number +} + +export type SavingsLifetimeBackfillTask = SystemTask< + SavingsLifetimeBackfillPayload, + SavingsLifetimeBackfillState, + SavingsLifetimeBackfillResult +> + +export type StartSavingsLifetimeBackfillResponse = { + success: boolean + message: string + data?: { + created: boolean + task: SavingsLifetimeBackfillTask + } +} + export type SystemTaskResponse = { success: boolean message: string @@ -218,6 +273,7 @@ export type ModelSettings = { 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string 'tool_price_setting.prices': string + SavingsEstimateSetting: string TopupGroupRatio: string GroupRatio: string UserUsableGroups: string @@ -273,6 +329,7 @@ export type BillingSettings = { 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string 'tool_price_setting.prices': string + SavingsEstimateSetting: string TopupGroupRatio: string GroupRatio: string UserUsableGroups: string diff --git a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx index 8f57ac6ba337..9f93cd53ac24 100644 --- a/web/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -41,6 +41,7 @@ import { Route, Settings2, AlertTriangle, + BadgeDollarSign, Headphones, Monitor, Cloud, @@ -60,7 +61,12 @@ import { Label } from '@/components/ui/label' import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { formatBillingCurrencyFromUSD } from '@/lib/currency' -import { formatLogQuota, formatTokens, formatUseTime } from '@/lib/format' +import { + formatLogQuota, + formatTimestampToDate, + formatTokens, + formatUseTime, +} from '@/lib/format' import { cn } from '@/lib/utils' import type { UsageLog } from '../../data/schema' @@ -403,6 +409,65 @@ function BillingBreakdown(props: { ) } +function SavingsEstimateSection(props: { other: LogOtherData }) { + const { t } = useTranslation() + const estimate = props.other.savings_estimate + + if (!estimate || estimate.savings_quota <= 0) return null + + const sourceValue = estimate.source_url ? ( + + {estimate.source || estimate.source_url} + + ) : ( + estimate.source + ) + + return ( + + ) +} + function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) { const { t } = useTranslation() const { log, other } = props @@ -1073,6 +1138,10 @@ export function DetailsDialog(props: DetailsDialogProps) { /> )} + {isConsume && other && !isViolation && ( + + )} + {/* Tiered pricing breakdown (when billing_mode is tiered_expr) */} {isTieredBilling && other?.expr_b64 && ( diff --git a/web/src/features/usage-logs/lib/format.ts b/web/src/features/usage-logs/lib/format.ts index c2d64887a6c1..3b4e2efb5da2 100644 --- a/web/src/features/usage-logs/lib/format.ts +++ b/web/src/features/usage-logs/lib/format.ts @@ -388,6 +388,7 @@ const AUDIT_TEMPLATES: Record = { 'user.oauth_unbind': 'Removed an OAuth binding for the user', // System settings 'option.update': 'Updated system setting {{key}}', + 'savings.official_price_update': 'Updated savings official price setting', 'option.payment_compliance': 'Confirmed payment compliance', 'option.reset_ratio': 'Reset model ratios', 'option.clear_affinity_cache': 'Cleared channel affinity cache', diff --git a/web/src/features/usage-logs/types.ts b/web/src/features/usage-logs/types.ts index 2cd43e13761c..9dadb6ff9365 100644 --- a/web/src/features/usage-logs/types.ts +++ b/web/src/features/usage-logs/types.ts @@ -112,6 +112,24 @@ export interface ToolSurchargeItem { price: number } +export interface SavingsEstimate { + schema_version: number + calculator: string + official_quota: number + actual_quota: number + savings_quota: number + source: string + source_url?: string + source_updated_at: number + price_snapshot_at?: number + price_fingerprint?: string + official_confirmed: boolean + matched_model: string + pricing_mode: string + calculation_mode?: 'snapshot' | 'historical_rebuild' + estimated: boolean +} + export interface LogOtherData { admin_info?: { is_multi_key?: boolean @@ -226,6 +244,7 @@ export interface LogOtherData { violation_fee_code?: string violation_fee_marker?: string fee_quota?: number + savings_estimate?: SavingsEstimate // Reject / intercept reason (admin) reject_reason?: string // Task-related fields (for refund logs, type=6) diff --git a/web/src/features/wallet/components/wallet-stats-card.tsx b/web/src/features/wallet/components/wallet-stats-card.tsx index 74ff0a834de2..8ddc894f1433 100644 --- a/web/src/features/wallet/components/wallet-stats-card.tsx +++ b/web/src/features/wallet/components/wallet-stats-card.tsx @@ -16,12 +16,35 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { Activity, BarChart3, WalletCards } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' +import { + Activity, + BadgeDollarSign, + BarChart3, + Info, + RefreshCw, + WalletCards, +} from 'lucide-react' import { useTranslation } from 'react-i18next' +import { Button } from '@/components/ui/button' import { IconBadge, type IconBadgeTone } from '@/components/ui/icon-badge' import { Skeleton } from '@/components/ui/skeleton' -import { formatQuota } from '@/lib/format' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { getUserSavingsLifetime } from '@/features/dashboard/api' +import { + formatSavingsCNYMicros, + formatSavingsPercent, + getLifetimeSavingsViewState, +} from '@/features/dashboard/lib/savings' +import { savingsQueryKeys } from '@/features/dashboard/lib/savings-query-keys' +import { toIntlLocale } from '@/i18n/languages' +import { formatQuota, formatTimestampToDate } from '@/lib/format' +import { cn } from '@/lib/utils' import type { UserWalletData } from '../types' @@ -31,7 +54,13 @@ interface WalletStatsCardProps { } export function WalletStatsCard(props: WalletStatsCardProps) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() + const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) + const lifetimeQuery = useQuery({ + queryKey: savingsQueryKeys.lifetime, + queryFn: getUserSavingsLifetime, + staleTime: 60 * 1000, + }) if (props.loading) { return (
@@ -76,27 +105,182 @@ export function WalletStatsCard(props: WalletStatsCardProps) { }, ] + const lifetime = lifetimeQuery.data?.data + const showLifetime = lifetime?.enabled && lifetime.show_on_wallet + const lifetimeState = lifetime ? getLifetimeSavingsViewState(lifetime) : null + const hasLifetimeAmount = Boolean( + lifetime && lifetime.estimated_request_count > 0 + ) + const hasPositiveLifetimeSavings = Boolean( + lifetime && /^[1-9]\d*$/.test(lifetime.savings_cny_micros) + ) + const lifetimeTitle = + lifetimeState === 'complete' || + lifetimeState === 'empty' || + lifetimeState === 'no_estimates' + ? t('Lifetime savings') + : t('Lifetime savings counted so far') + let lifetimeValue = '' + let lifetimeDescription = '' + + if (lifetime && lifetimeState) { + const coverage = formatSavingsPercent(lifetime.coverage_ratio, locale) + const coverageText = + coverage === '-' + ? `${t('Coverage')}: -` + : t('{{coverage}} coverage', { coverage }) + + if (hasLifetimeAmount) { + lifetimeValue = formatSavingsCNYMicros( + lifetime.savings_cny_micros, + locale + ) + } else if (lifetimeState === 'empty') { + lifetimeValue = t('No lifetime savings records yet') + } else if (lifetimeState === 'failed') { + lifetimeValue = t('Historical savings backfill failed') + } else { + lifetimeValue = t('No eligible savings records yet') + } + + if (lifetimeState === 'complete') { + lifetimeDescription = + lifetime.statistics_started_at > 0 + ? t('Since {{date}} · {{coverage}} coverage', { + date: formatTimestampToDate(lifetime.statistics_started_at), + coverage, + }) + : coverageText + } else if (lifetimeState === 'no_estimates') { + lifetimeDescription = coverageText + } else if (lifetimeState === 'failed') { + lifetimeDescription = lifetime.request_count + ? `${coverageText} · ${t( + 'Historical savings backfill failed; results are incomplete.' + )}` + : t('Historical savings backfill failed; results are incomplete.') + } else if (lifetimeState === 'paused') { + lifetimeDescription = `${coverageText} · ${t( + 'System historical data counting is paused' + )}` + } else if (lifetimeState === 'not_started') { + lifetimeDescription = lifetime.request_count + ? `${coverageText} · ${t('Historical usage has not been backfilled')}` + : t('Historical usage has not been backfilled') + } else if (lifetimeState === 'processing') { + lifetimeDescription = `${coverageText} · ${t( + 'System historical data is being counted' + )}` + } + } + return ( -
- {stats.map((item) => ( -
-
- - - -
- {item.label} +
+
+ {stats.map((item) => ( +
+
+ + + +
+ {item.label} +
-
-
- {item.value} +
+ {item.value} +
+
+ {item.description} +
-
- {item.description} + ))} +
+ {showLifetime && ( +
+
+
+
+ + + {lifetimeTitle} +
+
+ {lifetimeValue} +
+
+ +
+
+ {t('Estimated from official public pricing')} + + + } + > + + + {t( + 'Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.' + )} + + +
+ {lifetimeDescription && ( +
+ {lifetimeDescription} +
+ )} + {lifetimeQuery.isRefetchError && ( +
+ {t('Savings data update failed')} + + void lifetimeQuery.refetch()} + aria-label={t('Reload savings data')} + /> + } + > + + {t('Reload savings data')} + +
+ )} +
- ))} + )}
) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b48096a7fcfa..cdd3a9d03d89 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} IP(s)", "{{count}} log entries removed.": "{{count}} log entries removed.", "{{count}} minutes ago": "{{count}} minutes ago", + "{{count}} model price overrides": "{{count}} model price overrides", "{{count}} models": "{{count}} models", "{{count}} months ago": "{{count}} months ago", "{{count}} override": "{{count}} override", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} Uptime Kuma groups will be removed from the list.", "{{count}} vendors": "{{count}} vendors", "{{count}} weeks ago": "{{count}} weeks ago", + "{{coverage}} coverage": "{{coverage}} coverage", "{{field}} updated to {{value}}": "{{field}} updated to {{value}}", "{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} updated to {{value}} for tag: {{tag}}", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "{{modality}} supported", "{{n}} model(s) selected": "{{n}} model(s) selected", "{{processed}} of {{total}} log entries processed.": "{{processed}} of {{total}} log entries processed.", + "{{processed}} of {{total}} usage logs processed": "{{processed}} of {{total}} usage logs processed", "{{success}} succeeded, {{failed}} failed": "{{success}} succeeded, {{failed}} failed", "{{target}} test failed": "{{target}} test failed", "{{target}} test succeeded": "{{target}} test succeeded", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "A focused home for keys, balance, routing, and service health.", "About": "About", "About {{days}} days left": "About {{days}} days left", + "About historical savings estimates": "About historical savings estimates", + "About official pricing estimates": "About official pricing estimates", "Accept Unpriced Models": "Accept Unpriced Models", "Accepts a JSON array of model identifiers that support the Imagine API.": "Accepts a JSON array of model identifiers that support the Imagine API.", "Accepts comma-separated status codes and inclusive ranges.": "Accepts comma-separated status codes and inclusive ranges.", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.", "Access Denied Message": "Access Denied Message", "Access Forbidden": "Access Forbidden", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.", "Access Policy (JSON)": "Access Policy (JSON)", "Access previous conversations and start new ones.": "Access previous conversations and start new ones.", "Access Token": "Access Token", "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey", + "Account": "Account", + "Account & Security": "Account & Security", "Account Binding Management": "Account Binding Management", "Account Bindings": "Account Bindings", "Account created! Please sign in": "Account created! Please sign in", @@ -152,6 +160,7 @@ "Active Tasks": "Active Tasks", "active users": "active users", "Actual Amount": "Actual Amount", + "Actual Cost": "Actual Cost", "Actual Model": "Actual Model", "Actual Model:": "Actual Model:", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.", @@ -212,6 +221,7 @@ "Add split": "Add split", "Add subscription": "Add subscription", "Add tags...": "Add tags...", + "Add the frozen cumulative savings amount to the wallet summary.": "Add the frozen cumulative savings amount to the wallet summary.", "Add tier": "Add tier", "Add time condition": "Add time condition", "Add time rule group": "Add time rule group", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "After enabling, the plan will be shown to users. Continue?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?", "Agent ID *": "Agent ID *", + "Aggregate new usage into a frozen lifetime savings total.": "Aggregate new usage into a frozen lifetime savings total.", "Aggregate tokens delivered across the platform": "Aggregate tokens delivered across the platform", "Aggregate traffic across every category": "Aggregate traffic across every category", "Aggregated across enabled groups": "Aggregated across enabled groups", @@ -342,6 +353,7 @@ "Allowed Ports": "Allowed Ports", "Already have an account?": "Already have an account?", "Always matches (default tier).": "Always matches (default tier).", + "Ambiguous ClickHouse rows skipped: {{count}}": "Ambiguous ClickHouse rows skipped: {{count}}", "Amount": "Amount", "Amount cannot be changed when editing.": "Amount cannot be changed when editing.", "Amount discount": "Amount discount", @@ -531,6 +543,7 @@ "Available Models": "Available Models", "Available reset credits": "Available reset credits", "Available Rewards": "Available Rewards", + "available service channels": "available service channels", "Average latency": "Average latency", "Average latency, TTFT, and success rate by group": "Average latency, TTFT, and success rate by group", "Average latency, TTFT, TPS, and success rate": "Average latency, TTFT, TPS, and success rate", @@ -551,6 +564,7 @@ "Back to login": "Back to login", "Back to Models": "Back to Models", "Backed up": "Backed up", + "Backfill running": "Backfill running", "Background job tracker for queued work.": "Background job tracker for queued work.", "Backup Code": "Backup Code", "Backup code must be in format XXXX-XXXX": "Backup code must be in format XXXX-XXXX", @@ -700,6 +714,7 @@ "Cache write price": "Cache write price", "Cached": "Cached", "Cached input": "Cached input", + "Calculate estimated savings using official model prices.": "Calculate estimated savings using official model prices.", "Calculated price: ${{price}} per 1M tokens": "Calculated price: ${{price}} per 1M tokens", "Calculated ratio: {{ratio}}": "Calculated ratio: {{ratio}}", "Calculating...": "Calculating...", @@ -801,6 +816,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "Chinese", + "Choose a supported model and send your first request.": "Choose a supported model and send your first request.", "Choose a username": "Choose a username", "Choose an amount and payment method": "Choose an amount and payment method", "Choose and order the groups this API key will try.": "Choose and order the groups this API key will try.", @@ -847,6 +863,7 @@ "Clear search": "Clear search", "Clear selection": "Clear selection", "Clear selection (Escape)": "Clear selection (Escape)", + "Clear usage and balance": "Clear usage and balance", "Cleared": "Cleared", "Cleared {{bindingType}} binding for user {{username}}": "Cleared {{bindingType}} binding for user {{username}}", "Cleared all models": "Cleared all models", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "Configure model, caching, and group ratios used for billing", "Configure monitoring status page groups for the dashboard": "Configure monitoring status page groups for the dashboard", "Configure NODE_NAME": "Configure NODE_NAME", + "Configure official pricing snapshots for user savings estimates.": "Configure official pricing snapshots for user savings estimates.", "Configure per-model ratio for image inputs or outputs.": "Configure per-model ratio for image inputs or outputs.", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.", "Configure pricing ratios for a specific model.": "Configure pricing ratios for a specific model.", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "Confirm Invalidate", "Confirm log cleanup": "Confirm log cleanup", "Confirm log file cleanup?": "Confirm log file cleanup?", + "Confirm marketplace pricing as official": "Confirm marketplace pricing as official", "Confirm New Password": "Confirm New Password", "Confirm password": "Confirm password", "Confirm Payment": "Confirm Payment", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "Convert reasoning_content to tag in content", "Convert string to lowercase": "Convert string to lowercase", "Convert string to uppercase": "Convert string to uppercase", + "Converted at 1 USD = {{rate}} CNY": "Converted at 1 USD = {{rate}} CNY", "Converter": "Converter", "Converter does not match incoming path": "Converter does not match incoming path", "Converter is not registered": "Converter is not registered", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "Cost = 10 × 0.8 = 8", "Cost = 10 × 1.0 = 10": "Cost = 10 × 1.0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.", + "Cost comparison": "Cost comparison", "Cost in USD per request, regardless of tokens used.": "Cost in USD per request, regardless of tokens used.", "Cost Tracking": "Cost Tracking", "Count must be between {{min}} and {{max}}": "Count must be between {{min}} and {{max}}", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "Counted so far · {{coverage}} coverage · {{progress}} backfilled", + "Coverage": "Coverage", + "Covered request actual cost": "Covered request actual cost", + "Covered requests": "Covered requests", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU Threshold (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "Create request parameter override rules with a visual editor or raw JSON.", "Create request parameter override rules without editing raw JSON.": "Create request parameter override rules without editing raw JSON.", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.", + "Create separate keys for your projects and keep credentials under your control.": "Create separate keys for your projects and keep credentials under your control.", "Create succeeded": "Create succeeded", "Create Vendor": "Create Vendor", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.", @@ -1187,6 +1213,8 @@ "Currency": "Currency", "Currency & Display": "Currency & Display", "Current": "Current", + "Current account cost comparison": "Current account cost comparison", + "Current account only": "Current account only", "Current Balance": "Current Balance", "Current Billing": "Current Billing", "Current Cache Size": "Current Cache Size", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "Enable {{parameter}}", "Enable 2FA": "Enable 2FA", "Enable All": "Enable All", + "Enable and save lifetime savings before starting a backfill.": "Enable and save lifetime savings before starting a backfill.", "Enable check-in feature": "Enable check-in feature", "Enable Data Dashboard": "Enable Data Dashboard", "Enable demo mode with limited functionality": "Enable demo mode with limited functionality", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "Enable if this is an OpenRouter enterprise account with special response format", "Enable io.net deployments": "Enable io.net deployments", "Enable io.net model deployment service in console": "Enable io.net model deployment service in console", + "Enable lifetime savings": "Enable lifetime savings", "Enable LinuxDO OAuth": "Enable LinuxDO OAuth", "Enable model performance metrics": "Enable model performance metrics", "Enable OIDC": "Enable OIDC", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "Enable Performance Monitoring", "Enable rate limiting": "Enable rate limiting", "Enable Request Passthrough": "Enable Request Passthrough", + "Enable savings estimates": "Enable savings estimates", "Enable selected channels": "Enable selected channels", "Enable selected models": "Enable selected models", "Enable SSL/TLS": "Enable SSL/TLS", @@ -1727,11 +1758,18 @@ "Error Message (required)": "Error Message (required)", "Error parsing response data": "Error parsing response data", "Error Type (optional)": "Error Type (optional)", + "Estimate historical logs without a saved official price snapshot.": "Estimate historical logs without a saved official price snapshot.", "Estimated cost": "Estimated cost", + "Estimated from official pricing": "Estimated from official pricing", + "Estimated from official public pricing": "Estimated from official public pricing", "Estimated quota cost": "Estimated quota cost", + "Estimated savings": "Estimated savings", + "Estimated Savings": "Estimated Savings", + "Estimated: {{count}}": "Estimated: {{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.", "Every other device will lose access immediately. This device will remain signed in.": "Every other device will lose access immediately. This device will remain signed in.", "Everything configured for this group, in one place.": "Everything configured for this group, in one place.", + "Everything you need to start calling models": "Everything you need to start calling models", "Exact": "Exact", "Exact Match": "Exact Match", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.", @@ -1744,6 +1782,7 @@ "Excellent": "Excellent", "Exchange rate is required": "Exchange rate is required", "Exchange rate must be greater than 0": "Exchange rate must be greater than 0", + "Exclude prices that have not been confirmed as official.": "Exclude prices that have not been confirmed as official.", "Execute code in a sandbox during the response": "Execute code in a sandbox during the response", "Executor": "Executor", "Exhausted": "Exhausted", @@ -1871,6 +1910,7 @@ "Failed to load users": "Failed to load users", "Failed to parse group items": "Failed to parse group items", "Failed to parse JSON file: {{name}}": "Failed to parse JSON file: {{name}}", + "Failed to pause historical backfill": "Failed to pause historical backfill", "Failed to query balance": "Failed to query balance", "Failed to refresh cache stats": "Failed to refresh cache stats", "Failed to refresh credential": "Failed to refresh credential", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "Failed to reset model ratios", "Failed to reset Passkey": "Failed to reset Passkey", "Failed to reset usage": "Failed to reset usage", + "Failed to resume historical backfill": "Failed to resume historical backfill", + "Failed to retry historical backfill": "Failed to retry historical backfill", "Failed to save": "Failed to save", "Failed to save announcements": "Failed to save announcements", "Failed to save API info": "Failed to save API info", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "Failed to start {{provider}} login", "Failed to start Discord login": "Failed to start Discord login", "Failed to start GitHub login": "Failed to start GitHub login", + "Failed to start historical backfill": "Failed to start historical backfill", "Failed to start LinuxDO login": "Failed to start LinuxDO login", "Failed to start OIDC login": "Failed to start OIDC login", "Failed to start Passkey login": "Failed to start Passkey login", @@ -2067,6 +2110,7 @@ "Frames per second": "Frames per second", "Free": "Free", "Free: {{free}} / Total: {{total}}": "Free: {{free}} / Total: {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.", "Frequency Penalty": "Frequency Penalty", "Friendly name to identify this channel": "Friendly name to identify this channel", "From Address": "From Address", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "Hidden from {{group}}", "Hide": "Hide", "Hide API key": "Hide API key", + "Hide password": "Hide password", "Hide sensitive data": "Hide sensitive data", "Hide setup guide": "Hide setup guide", "High Performance": "High Performance", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "I voluntarily accept the system stability risks, including severe client timeouts and possible service crashes, and take responsibility for any resulting request backlog or service outage.", "High-risk status code retry risk disclaimer": "### ⚠️ High-risk operation: 504/524 status code retry risk notice and disclaimer\n\nBy default, this project does not retry status codes `400` (bad request), `504` (gateway timeout), or `524` (a timeout occurred). Status codes 504 and 524 usually mean that **the request successfully reached the upstream AI service and upstream processing had begun, but the connection was closed because upstream processing took too long**. This usually points to an upstream service bottleneck.\n\nEnabling redirection/retry for these timeout status codes is an **extremely high-risk operation**. Before enabling it, you must carefully read and understand the following consequences:\n\n#### 1. Core risks (read carefully)\n\n1. 💸 Duplicate or multiple billing: Most upstream AI providers **still charge** for requests that started processing but were interrupted by a network timeout (504/524). A retry sends a brand-new upstream request and can result in **duplicate or multiple charges**.\n2. ⏳ Severe client timeout: Once a request has already timed out, adding retries can multiply total latency and cause severe or unacceptable timeouts for the final client or caller.\n3. 💥 Request backlog and service crash: Forced retries keep threads and connections occupied for longer. Under high concurrency, this can cause a serious **request backlog**, exhaust system resources, trigger cascading failures, and crash the proxy service.\n\n#### 2. Risk acknowledgement\n\nIf you still choose to enable this feature, you acknowledge all of the following:", "Higher priority channels are selected first": "Higher priority channels are selected first", + "Historical backfill": "Historical backfill", + "Historical backfill batch size": "Historical backfill batch size", + "Historical estimates": "Historical estimates", + "Historical rebuilds": "Historical rebuilds", + "Historical requests recalculated at current official prices: {{count}}": "Historical requests recalculated at current official prices: {{count}}", + "Historical savings backfill failed": "Historical savings backfill failed", + "Historical savings backfill failed; results are incomplete.": "Historical savings backfill failed; results are incomplete.", + "Historical savings backfill is already active": "Historical savings backfill is already active", + "Historical savings backfill pause requested": "Historical savings backfill pause requested", + "Historical savings backfill resumed": "Historical savings backfill resumed", + "Historical savings backfill retry started": "Historical savings backfill retry started", + "Historical savings backfill started": "Historical savings backfill started", "Historical Usage": "Historical Usage", + "Historical usage has not been backfilled": "Historical usage has not been backfilled", + "Historical usage is recalculated using current official prices": "Historical usage is recalculated using current official prices", "History of MjProxy-style image tasks.": "History of MjProxy-style image tasks.", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "Hit criteria: If cached tokens exist in usage, it counts as a hit.", "Hit Rate": "Hit Rate", @@ -2299,6 +2358,7 @@ "Important": "Important", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.", "In Progress": "In Progress", + "In the last 24 hours, RAPI saved you about {{amount}}": "In the last 24 hours, RAPI saved you about {{amount}}", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.", "In:": "In:", "incident": "incident", @@ -2413,6 +2473,7 @@ "Just now": "Just now", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "Keep a stable cumulative savings total without scanning usage logs when users open a page.", "Keep affinity when channel is disabled": "Keep affinity when channel is disabled", "Keep enabled if you need to proxy requests for different upstream accounts.": "Keep enabled if you need to proxy requests for different upstream accounts.", "Keep enough balance before production traffic": "Keep enough balance before production traffic", @@ -2437,6 +2498,8 @@ "Language preference saved": "Language preference saved", "Language Preferences": "Language Preferences", "Language preferences sync across your signed-in devices and affect API error messages.": "Language preferences sync across your signed-in devices and affect API error messages.", + "Last 24 hours": "Last 24 hours", + "Last 24h savings estimate": "Last 24h savings estimate", "Last 24h usage": "Last 24h usage", "Last 30 days uptime": "Last 30 days uptime", "Last active {{time}} · Expires {{expires}}": "Last active {{time}} · Expires {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "Less than or equal", "Less Than or Equal": "Less Than or Equal", "License": "License", + "Lifetime savings": "Lifetime savings", + "Lifetime savings counted so far": "Lifetime savings counted so far", + "Lifetime savings counted so far: {{amount}}": "Lifetime savings counted so far: {{amount}}", "Light": "Light", "Lightning Fast": "Lightning Fast", "Limit period": "Limit period", "Limit Reached": "Limit Reached", + "Limit the date range of each savings summary query.": "Limit the date range of each savings summary query.", + "Limit the number of usage logs scanned per summary.": "Limit the number of usage logs scanned per summary.", "Limit which models can be used with this key": "Limit which models can be used with this key", "Limited": "Limited", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.", @@ -2565,6 +2633,7 @@ "Manage Bindings": "Manage Bindings", "Manage catalog visibility and pricing.": "Manage catalog visibility and pricing.", "Manage custom OAuth providers for user authentication": "Manage custom OAuth providers for user authentication", + "Manage in JSON": "Manage in JSON", "Manage Keys": "Manage Keys", "Manage local models for:": "Manage local models for:", "Manage multi-key status and configuration for this channel": "Manage multi-key status and configuration for this channel", @@ -2596,6 +2665,7 @@ "Match Value": "Match Value", "Match Value (optional)": "Match Value (optional)", "Matched": "Matched", + "Matched Model": "Matched Model", "Matched models": "Matched models", "Matched Tier": "Matched Tier", "Matches models not claimed by earlier splits.": "Matches models not claimed by earlier splits.", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.", "Maximum number of tokens in the response": "Maximum number of tokens in the response", "Maximum quota amount awarded for check-in": "Maximum quota amount awarded for check-in", + "Maximum scanned log rows": "Maximum scanned log rows", + "Maximum summary range (days)": "Maximum summary range (days)", "Maximum tokens including hidden reasoning tokens": "Maximum tokens including hidden reasoning tokens", "Maximum tokens per response": "Maximum tokens per response", "Maximum tokens per user": "Maximum tokens per user", @@ -2905,6 +2977,7 @@ "No description available.": "No description available.", "No discount tiers configured. Click \"Add discount tier\" to get started.": "No discount tiers configured. Click \"Add discount tier\" to get started.", "No duplicate keys found": "No duplicate keys found", + "No eligible savings records yet": "No eligible savings records yet", "No enabled tokens available": "No enabled tokens available", "No encryption": "No encryption", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "No endpoints configured. Switch to JSON mode or add rows to define endpoints.", @@ -2926,6 +2999,7 @@ "No Inviter": "No Inviter", "No keys found": "No keys found", "No latency data available": "No latency data available", + "No lifetime savings records yet": "No lifetime savings records yet", "No log entries matched the selected time.": "No log entries matched the selected time.", "No logs": "No logs", "No Logs Found": "No Logs Found", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "No Uptime Kuma groups yet. Click \"Add Group\" to create one.", "No uptime monitoring configured": "No uptime monitoring configured", "No usage logs available. Logs will appear here once API calls are made.": "No usage logs available. Logs will appear here once API calls are made.", + "No usage records in the selected range": "No usage records in the selected range", "No user information available": "No user information available", "No user selected": "No user selected", "No users": "No users", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "Official OpenAI Embeddings", "Official OpenAI Images": "Official OpenAI Images", "Official OpenAI Responses": "Official OpenAI Responses", + "Official price confirmation is required while savings estimates are enabled.": "Official price confirmation is required while savings estimates are enabled.", + "Official price estimate": "Official price estimate", + "Official Price Estimate": "Official Price Estimate", + "Official Price Updated": "Official Price Updated", + "Official price updated {{time}}": "Official price updated {{time}}", + "Official price validity (days)": "Official price validity (days)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.", + "Official pricing": "Official pricing", + "Official pricing estimate": "Official pricing estimate", "Official Repository": "Official Repository", "Official Sync": "Official Sync", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "One API", "One domain per line": "One domain per line", "One domain per line (only used when domain restriction is enabled)": "One domain per line (only used when domain restriction is enabled)", + "One endpoint, one key, and a clear view of every request.": "One endpoint, one key, and a clear view of every request.", "One IP or CIDR range per line": "One IP or CIDR range per line", "One IP per line (empty for no restriction)": "One IP per line (empty for no restriction)", "one keyword per line": "one keyword per line", @@ -3304,6 +3389,11 @@ "Path not set": "Path not set", "Path Regex (one per line)": "Path Regex (one per line)", "Path:": "Path:", + "Pause backfill": "Pause backfill", + "pause_requested": "pausing", + "paused": "paused", + "Paused": "Paused", + "Pausing": "Pausing", "Pay": "Pay", "Pay with Balance": "Pay with Balance", "Pay-as-you-go with real-time usage monitoring": "Pay-as-you-go with real-time usage monitoring", @@ -3507,11 +3597,14 @@ "Price estimation description": "After completing the hardware type, deployment location, replica count, etc., the price will be automatically calculated.", "Price ID": "Price ID", "Price mode (USD per 1M tokens)": "Price mode (USD per 1M tokens)", + "Price overrides": "Price overrides", "Price summary": "Price summary", "price_xxx": "price_xxx", "Price:": "Price:", "Price: High to Low": "Price: High to Low", "Price: Low to High": "Price: Low to High", + "Prices frozen at {{time}}": "Prices frozen at {{time}}", + "Prices older than this are excluded from savings estimates.": "Prices older than this are excluded from savings estimates.", "Prices shown per": "Prices shown per", "Prices synced successfully": "Prices synced successfully", "Prices vary by usage tier and request conditions": "Prices vary by usage tier and request conditions", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "Priority order for tokens in the auto group. The system tries groups from top to bottom.", "Privacy Policy": "Privacy Policy", "Private Deployment URL": "Private Deployment URL", + "Process between 500 and 5000 usage logs per batch.": "Process between 500 and 5000 usage logs per batch.", "Processing OAuth response...": "Processing OAuth response...", "Processing...": "Processing...", "Product": "Product", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "Randomly select a key from the pool for each request", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.", "Rankings": "Rankings", + "RAPI has saved you about {{amount}} in total": "RAPI has saved you about {{amount}} in total", + "RAPI saved you about {{amount}}": "RAPI saved you about {{amount}}", "Rate Limit Windows": "Rate Limit Windows", "Rate Limited": "Rate Limited", "Rate Limiting": "Rate Limiting", @@ -3658,6 +3754,7 @@ "Reason:": "Reason:", "Reasoning": "Reasoning", "Reasoning Effort": "Reasoning Effort", + "Recalculate legacy usage logs": "Recalculate legacy usage logs", "Receive Upstream Model Update Notifications": "Receive Upstream Model Update Notifications", "Received": "Received", "Received amount": "Received amount", @@ -3732,6 +3829,8 @@ "Reject Reason": "Reject Reason", "Release details": "Release details", "Released": "Released", + "reliability controls": "reliability controls", + "Reload savings data": "Reload savings data", "Relying Party Display Name": "Relying Party Display Name", "Relying Party ID": "Relying Party ID", "Remaining": "Remaining", @@ -3790,6 +3889,7 @@ "Request Body Field": "Request Body Field", "Request Body Memory Cache": "Request Body Memory Cache", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.", + "Request completed": "Request completed", "Request conversion": "Request conversion", "Request Conversion": "Request Conversion", "Request Count": "Request Count", @@ -3813,6 +3913,7 @@ "Requests": "Requests", "Requests (24h)": "Requests (24h)", "Requests / 24h": "Requests / 24h", + "Requests are routed across available services to improve call stability.": "Requests are routed across available services to improve call stability.", "Requests per minute": "Requests per minute", "requests served": "requests served", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Requests will be forwarded to this worker. Trailing slashes are removed automatically.", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "Require job success before follow-up actions", "Require login to view models": "Require login to view models", "Require login to view rankings": "Require login to view rankings", + "Require official price confirmation": "Require official price confirmation", "required": "required", "Required": "Required", "Required events:": "Required events:", "Required provider, authentication, model, and group settings": "Required provider, authentication, model, and group settings", "Required to expose MjProxy-style image generation to end users.": "Required to expose MjProxy-style image generation to end users.", + "Required while savings estimates are enabled.": "Required while savings estimates are enabled.", "Rerank": "Rerank", "Reroll": "Reroll", "Research, analysis, scientific reasoning": "Research, analysis, scientific reasoning", @@ -3880,11 +3983,13 @@ "Restore global Auto": "Restore global Auto", "Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)", "Result": "Result", + "Resume backfill": "Resume backfill", "Retain last N days": "Retain last N days", "Retain last N files": "Retain last N files", "Retention days": "Retention days", "Retry": "Retry", "Retry Chain": "Retry Chain", + "Retry from saved progress": "Retry from saved progress", "Retry Settings": "Retry Settings", "Retry Suggestion": "Retry Suggestion", "Retry Times": "Retry Times", @@ -3986,6 +4091,7 @@ "Save Preferences": "Save Preferences", "Save preview": "Save preview", "Save rate limits": "Save rate limits", + "Save savings estimate settings": "Save savings estimate settings", "Save sensitive words": "Save sensitive words", "Save Settings": "Save Settings", "Save sidebar modules": "Save sidebar modules", @@ -4000,6 +4106,11 @@ "Save Worker settings": "Save Worker settings", "Saved successfully": "Saved successfully", "Saving...": "Saving...", + "Savings data update failed": "Savings data update failed", + "Savings estimate": "Savings estimate", + "Savings estimate is not enabled": "Savings estimate is not enabled", + "Savings lifetime backfill": "Savings lifetime backfill", + "Savings rate": "Savings rate", "Scan QR Code": "Scan QR Code", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "Scan the QR code to follow the official account and send the message “验证码” to receive your verification code.", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)", @@ -4200,13 +4311,21 @@ "Show": "Show", "Show All": "Show All", "Show all providers including unbound": "Show all providers including unbound", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "Show cumulative savings, coverage, and backfill progress on the user dashboard.", + "Show in usage logs": "Show in usage logs", + "Show lifetime savings in wallet": "Show lifetime savings in wallet", + "Show lifetime savings on dashboard": "Show lifetime savings on dashboard", + "Show on dashboard": "Show on dashboard", "Show only bound providers": "Show only bound providers", "Show or hide flow columns": "Show or hide flow columns", + "Show password": "Show password", "Show preview": "Show preview", "Show prices in currency instead of quota.": "Show prices in currency instead of quota.", + "Show request-level savings estimates in usage logs.": "Show request-level savings estimates in usage logs.", "Show sensitive data": "Show sensitive data", "Show setup guide": "Show setup guide", "Show source": "Show source", + "Show the savings summary and trend on the user dashboard.": "Show the savings summary and trend on the user dashboard.", "Show token usage statistics in the UI": "Show token usage statistics in the UI", "Showcase core capabilities with demo credentials and limited access.": "Showcase core capabilities with demo credentials and limited access.", "Showing": "Showing", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "Signed in with Passkey", "Signed out": "Signed out", "Significant outages detected": "Significant outages detected", + "Signing in...": "Signing in...", "Signing you in with {{provider}}": "Signing you in with {{provider}}", "SiliconFlow": "SiliconFlow", "Simple": "Simple", "Simple mode only returns message; status code and error type use system defaults.": "Simple mode only returns message; status code and error type use system defaults.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Simple mode: prune objects by type, e.g. redacted_thinking.", + "Since {{date}} · {{coverage}} coverage": "Since {{date}} · {{coverage}} coverage", "Single Key": "Single Key", "Site & Branding": "Site & Branding", "Site Key": "Site Key", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "Skip retry on failure", "Skip SMTP TLS certificate verification": "Skip SMTP TLS certificate verification", "Skip to Main": "Skip to Main", + "Skipped: {{count}}": "Skipped: {{count}}", "Slug": "Slug", "Slug can only contain letters, numbers, hyphens, and underscores": "Slug can only contain letters, numbers, hyphens, and underscores", "Slug is required": "Slug is required", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF Protection", + "Stable model calls": "Stable model calls", "stale": "stale", "Standard": "Standard", "Standard price": "Standard price", "Start": "Start", "Start a conversation to see messages here": "Start a conversation to see messages here", "Start a playground chat": "Start a playground chat", + "Start calling supported models with RAPI": "Start calling supported models with RAPI", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.", "Start for free with generous limits. No credit card required.": "Start for free with generous limits. No credit card required.", + "Start historical backfill": "Start historical backfill", "Start Time": "Start Time", + "Start with the familiar OpenAI-compatible workflow.": "Start with the familiar OpenAI-compatible workflow.", "Started": "Started", "STARTTLS": "STARTTLS", "Static page describing the platform.": "Static page describing the platform.", @@ -4379,6 +4505,7 @@ "Super Large": "Super Large", "Support for high concurrency with automatic load balancing": "Support for high concurrency with automatic load balancing", "Supported Applications": "Supported Applications", + "supported billing models": "supported billing models", "Supported Imagine Models": "Supported Imagine Models", "Supported modalities": "Supported modalities", "Supported parameters": "Supported parameters", @@ -4411,6 +4538,8 @@ "System Behavior": "System Behavior", "System data statistics": "System data statistics", "System default": "System default", + "System historical data counting is paused": "System historical data counting is paused", + "System historical data is being counted": "System historical data is being counted", "System Info": "System Info", "System Information": "System Information", "System initialized successfully! Redirecting…": "System initialized successfully! Redirecting…", @@ -4457,6 +4586,7 @@ "Task logs": "Task logs", "Task Logs": "Task Logs", "Tasks currently pending or running.": "Tasks currently pending or running.", + "Tasks currently pending, running, or paused.": "Tasks currently pending, running, or paused.", "Team Collaboration": "Team Collaboration", "Technical Support": "Technical Support", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "Three calls made by the same vip user. Assume the base price of one call is 10.", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "Three groups; the override matrix has exactly one cell filled in (highlighted).", "Three steps to get started": "Three steps to get started", + "Three steps to your first model request": "Three steps to your first model request", "Throughput": "Throughput", "Throughput by group": "Throughput by group", "Throughput short": "TPS", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.", "Too many files. Some were not added.": "Too many files. Some were not added.", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.", + "Too many records to summarize": "Too many records to summarize", "Too many requests": "Too many requests", "Tool / function declarations the model may call": "Tool / function declarations the model may call", "Tool identifier": "Tool identifier", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "Transfer to Balance", "Translation": "Translation", "Transparent Billing": "Transparent Billing", + "Treat local model marketplace prices as official reference prices.": "Treat local model marketplace prices as official reference prices.", "Trend": "Trend", "Trending down": "Trending down", "Trending up": "Trending up", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "Unable to load login sessions", "Unable to load rankings": "Unable to load rankings", "Unable to load rankings data": "Unable to load rankings data", + "Unable to load savings trend": "Unable to load savings trend", "Unable to open chat": "Unable to open chat", "Unable to parse structured pricing": "Unable to parse structured pricing", "Unable to prepare chat link. Please ensure you have an enabled API key.": "Unable to prepare chat link. Please ensure you have an enabled API key.", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "Understand image inputs alongside text", "Unexpected release payload": "Unexpected release payload", "Unified API Gateway for": "Unified API Gateway for", + "Unified model API service": "Unified model API service", "Unique identifier for this group.": "Unique identifier for this group.", "Unit price (local currency / USD)": "Unit price (local currency / USD)", "Unit price (USD)": "Unit price (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "Updated a vendor", "Updated channel {{name}} (ID: {{id}})": "Updated channel {{name}} (ID: {{id}})", "Updated daily": "Updated daily", + "Updated savings official price setting": "Updated savings official price setting", "Updated successfully": "Updated successfully", "Updated system setting {{key}}": "Updated system setting {{key}}", "Updated user {{username}} (ID: {{id}})": "Updated user {{username}} (ID: {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "URL is required", "URL to your logo image (optional)": "URL to your logo image (optional)", "Usage": "Usage", + "Usage Analysis": "Usage Analysis", "Usage at a glance": "Usage at a glance", "Usage guide": "Usage guide", "Usage logs": "Usage logs", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "Use external tools to extend capabilities", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.", + "Use one compatible endpoint to access supported models without changing SDKs.": "Use one compatible endpoint to access supported models without changing SDKs.", "Use our unified OpenAI-compatible endpoint in your applications": "Use our unified OpenAI-compatible endpoint in your applications", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Use Passkey or 2FA to confirm your identity before revealing this channel key.", "Use Passkey to sign in without entering your password.": "Use Passkey to sign in without entering your password.", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "Users of vip, when billed as premium, pay ratio", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.", "uses": "uses", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.", "Using the complete global Auto order ({{count}} groups)": "Using the complete global Auto order ({{count}} groups)", "Validity": "Validity", "Validity Period": "Validity Period", @@ -5077,6 +5216,7 @@ "View mode": "View mode", "View model statistics and charts": "View model statistics and charts", "View Pricing": "View Pricing", + "View savings trend": "View savings trend", "View the complete details for this": "View the complete details for this", "View the complete details for this log entry": "View the complete details for this log entry", "View the complete error message and details": "View the complete error message and details", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "Worker instances do not run master-only background tasks.", "Worker Proxy": "Worker Proxy", "Worker URL": "Worker URL", + "Workspace": "Workspace", "Workspaces": "Workspaces", "Write value to the target field": "Write value to the target field", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "You have unsaved changes. Are you sure you want to leave?", "You Pay": "You Pay", "You save": "You save", + "You saved about {{amount}}": "You saved about {{amount}}", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.", "You will be redirected to Telegram to complete the binding process.": "You will be redirected to Telegram to complete the binding process.", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 638cf9abadd1..5a178f1c23e1 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} IP", "{{count}} log entries removed.": "{{count}} entrées de journal supprimées.", "{{count}} minutes ago": "il y a {{count}} minutes", + "{{count}} model price overrides": "{{count}} remplacements de tarifs de modèle", "{{count}} models": "{{count}} modèles", "{{count}} months ago": "il y a {{count}} mois", "{{count}} override": "{{count}} remplacement", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} groupes Uptime Kuma seront retirés de la liste.", "{{count}} vendors": "{{count}} fournisseurs", "{{count}} weeks ago": "il y a {{count}} semaines", + "{{coverage}} coverage": "Couverture {{coverage}}", "{{field}} updated to {{value}}": "{{field}} mis à jour en {{value}}", "{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} mis à jour en {{value}} pour le tag : {{tag}}", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "{{modality}} pris en charge", "{{n}} model(s) selected": "{{n}} modèle(s) sélectionné(s)", "{{processed}} of {{total}} log entries processed.": "{{processed}} sur {{total}} entrées de journal traitées.", + "{{processed}} of {{total}} usage logs processed": "{{processed}} journaux traités sur {{total}}", "{{success}} succeeded, {{failed}} failed": "{{success}} réussi(s), {{failed}} échoué(s)", "{{target}} test failed": "Échec du test de {{target}}", "{{target}} test succeeded": "Test de {{target}} réussi", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "Un accueil dédié aux clés, au solde, au routage et à l'état du service.", "About": "À propos", "About {{days}} days left": "Environ {{days}} jours restants", + "About historical savings estimates": "À propos de l'estimation des économies historiques", + "About official pricing estimates": "À propos des estimations basées sur les tarifs officiels", "Accept Unpriced Models": "Accepter les modèles non tarifés", "Accepts a JSON array of model identifiers that support the Imagine API.": "Accepte un tableau JSON d'identifiants de modèles qui prennent en charge l'API Imagine.", "Accepts comma-separated status codes and inclusive ranges.": "Accepte les codes de statut séparés par des virgules et les plages inclusives.", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Accédez à une vaste sélection de modèles via un protocole API standard et unifié. Propulsez les applications d'IA, gérez les actifs numériques et connectez le futur.", "Access Denied Message": "Message d'accès refusé", "Access Forbidden": "Accès interdit", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "Accédez à plusieurs services de modèles via une API compatible. Utilisez une clé unique et suivez clairement l'usage, le solde et les requêtes, du développement à la production.", "Access Policy (JSON)": "Politique d'accès (JSON)", "Access previous conversations and start new ones.": "Accéder aux conversations précédentes et en démarrer de nouvelles.", "Access Token": "Jeton d'accès", "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey", + "Account": "Compte", + "Account & Security": "Compte et sécurité", "Account Binding Management": "Gestion des liaisons de compte", "Account Bindings": "Associations de compte", "Account created! Please sign in": "Compte créé ! Veuillez vous connecter", @@ -152,6 +160,7 @@ "Active Tasks": "Tâches actives", "active users": "utilisateurs actifs", "Actual Amount": "Montant réel", + "Actual Cost": "Coût réel", "Actual Model": "Modèle réel", "Actual Model:": "Modèle réel :", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Adapter les requêtes avec le suffixe `-thinking` au comportement de pensée natif d’Anthropic tout en gardant une facturation prévisible.", @@ -212,6 +221,7 @@ "Add split": "Ajouter une branche", "Add subscription": "Ajouter un abonnement", "Add tags...": "Ajouter des étiquettes...", + "Add the frozen cumulative savings amount to the wallet summary.": "Ajouter le montant cumulé figé au résumé du portefeuille.", "Add tier": "Ajouter un palier", "Add time condition": "Ajouter une condition temporelle", "Add time rule group": "Ajouter un groupe de règles temporelles", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "Après activation, le plan sera affiché aux utilisateurs. Continuer ?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "Après l'invalidation, cet abonnement sera immédiatement désactivé. Les enregistrements historiques ne sont pas affectés. Continuer ?", "Agent ID *": "ID d'agent *", + "Aggregate new usage into a frozen lifetime savings total.": "Agréger les nouveaux usages dans un total d'économies cumulé et figé.", "Aggregate tokens delivered across the platform": "Jetons cumulés livrés sur la plateforme", "Aggregate traffic across every category": "Trafic cumulé sur toutes les catégories", "Aggregated across enabled groups": "Agrégé sur les groupes activés", @@ -342,6 +353,7 @@ "Allowed Ports": "Ports autorisés", "Already have an account?": "Vous avez déjà un compte ?", "Always matches (default tier).": "Toujours appliqué (palier par défaut).", + "Ambiguous ClickHouse rows skipped: {{count}}": "Lignes ClickHouse ambiguës ignorées : {{count}}", "Amount": "Montant", "Amount cannot be changed when editing.": "Le montant ne peut pas être modifié lors de la modification.", "Amount discount": "Remise sur le montant", @@ -531,6 +543,7 @@ "Available Models": "Modèles disponibles", "Available reset credits": "Crédits de réinitialisation disponibles", "Available Rewards": "Récompenses disponibles", + "available service channels": "canaux de service disponibles", "Average latency": "Latence moyenne", "Average latency, TTFT, and success rate by group": "Latence moyenne, TTFT et taux de réussite par groupe", "Average latency, TTFT, TPS, and success rate": "Latence moyenne, TTFT, TPS et taux de réussite", @@ -551,6 +564,7 @@ "Back to login": "Retour à la connexion", "Back to Models": "Retour aux modèles", "Backed up": "Sauvegardé", + "Backfill running": "Recalcul en cours", "Background job tracker for queued work.": "Suivi des tâches en arrière-plan pour les travaux en file d'attente.", "Backup Code": "Code de secours", "Backup code must be in format XXXX-XXXX": "Le code de sauvegarde doit être au format XXXX-XXXX", @@ -700,6 +714,7 @@ "Cache write price": "Prix d’écriture du cache", "Cached": "Cache", "Cached input": "Entrée mise en cache", + "Calculate estimated savings using official model prices.": "Calculez les économies estimées à partir des tarifs officiels des modèles.", "Calculated price: ${{price}} per 1M tokens": "Prix calculé : ${{price}} par 1M tokens", "Calculated ratio: {{ratio}}": "Ratio calculé : {{ratio}}", "Calculating...": "Calcul en cours...", @@ -801,6 +816,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "Chinois", + "Choose a supported model and send your first request.": "Choisissez un modèle pris en charge et envoyez votre première requête.", "Choose a username": "Choisir un nom d'utilisateur", "Choose an amount and payment method": "Choisir un montant et un mode de paiement", "Choose and order the groups this API key will try.": "Sélectionnez et ordonnez les groupes que cette clé API essaiera.", @@ -847,6 +863,7 @@ "Clear search": "Effacer la recherche", "Clear selection": "Effacer la sélection", "Clear selection (Escape)": "Effacer la sélection (Échap)", + "Clear usage and balance": "Usage et solde transparents", "Cleared": "Vidé", "Cleared {{bindingType}} binding for user {{username}}": "Liaison {{bindingType}} de l'utilisateur {{username}} supprimée", "Cleared all models": "Tous les modèles effacés", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "Configurer les ratios de modèle, de mise en cache et de groupe utilisés pour la facturation", "Configure monitoring status page groups for the dashboard": "Configurer les groupes de pages d'état de surveillance pour le tableau de bord", "Configure NODE_NAME": "Configurer NODE_NAME", + "Configure official pricing snapshots for user savings estimates.": "Configurez les instantanés de tarifs officiels pour l'estimation des économies utilisateur.", "Configure per-model ratio for image inputs or outputs.": "Configurer le ratio par modèle pour les entrées ou sorties d'images.", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Définissez le prix unitaire de chaque outil ($/1K appels). Les modèles facturés à la requête n'entraînent pas de frais d'outils supplémentaires.", "Configure pricing ratios for a specific model.": "Configurer les ratios de tarification pour un modèle spécifique.", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "Confirmer l'invalidation", "Confirm log cleanup": "Confirmer le nettoyage des journaux", "Confirm log file cleanup?": "Confirmer le nettoyage des fichiers journaux ?", + "Confirm marketplace pricing as official": "Confirmer les tarifs de la place de marché comme officiels", "Confirm New Password": "Confirmer le nouveau mot de passe", "Confirm password": "Confirmer le mot de passe", "Confirm Payment": "Confirmer le paiement", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "Convertir reasoning_content en balise dans content", "Convert string to lowercase": "Convertir la chaîne en minuscules", "Convert string to uppercase": "Convertir la chaîne en majuscules", + "Converted at 1 USD = {{rate}} CNY": "Conversion : 1 USD = {{rate}} CNY", "Converter": "Convertisseur", "Converter does not match incoming path": "Le convertisseur ne correspond pas au chemin entrant", "Converter is not registered": "Le convertisseur n’est pas enregistre", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "Coût = 10 × 0,8 = 8", "Cost = 10 × 1.0 = 10": "Coût = 10 × 1,0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Coût = prix du modèle × ce seul taux. Rien d’autre dans les réglages de groupes n’entre dans la formule.", + "Cost comparison": "Comparaison des coûts", "Cost in USD per request, regardless of tokens used.": "Coût en USD par requête, quel que soit le nombre de jetons utilisés.", "Cost Tracking": "Suivi des coûts", "Count must be between {{min}} and {{max}}": "Le nombre doit être compris entre {{min}} et {{max}}", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "Comptabilisé à ce jour · couverture {{coverage}} · recalcul {{progress}}", + "Coverage": "Couverture", + "Covered request actual cost": "Coût réel des requêtes couvertes", + "Covered requests": "Requêtes couvertes", "Coze": "Coze", "CPU": "Processeur", "CPU Threshold (%)": "Seuil CPU (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "Créer des règles de substitution de paramètres avec l'éditeur visuel ou le JSON brut.", "Create request parameter override rules without editing raw JSON.": "Créez des règles de remplacement des paramètres de requête sans modifier le JSON brut.", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "Créez des ensembles réutilisables de modèles, de balises, de points de terminaison et de groupes d'utilisateurs pour accélérer la configuration ailleurs dans la console.", + "Create separate keys for your projects and keep credentials under your control.": "Créez une clé par projet et gardez le contrôle de vos identifiants.", "Create succeeded": "Création réussie", "Create Vendor": "Créer un fournisseur", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "Créez votre premier groupe pour réutiliser les sélections de modèles, de balises ou de points de terminaison n'importe où dans le tableau de bord.", @@ -1187,6 +1213,8 @@ "Currency": "Devise", "Currency & Display": "Devise et affichage", "Current": "Actuelle", + "Current account cost comparison": "Coûts du compte actuel", + "Current account only": "Compte actuel uniquement", "Current Balance": "Solde actuel", "Current Billing": "Facturation actuelle", "Current Cache Size": "Taille actuelle du cache", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "Activer {{parameter}}", "Enable 2FA": "Activer 2FA", "Enable All": "Tout activer", + "Enable and save lifetime savings before starting a backfill.": "Activez et enregistrez les économies cumulées avant de lancer le recalcul.", "Enable check-in feature": "Activer la fonction de connexion", "Enable Data Dashboard": "Activer le tableau de bord des données", "Enable demo mode with limited functionality": "Activer le mode démo avec des fonctionnalités limitées", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "Activer si c'est un compte d'entreprise OpenRouter avec un format de réponse spécial", "Enable io.net deployments": "Activer les déploiements io.net", "Enable io.net model deployment service in console": "Activer le service de déploiement de modèles io.net dans la console", + "Enable lifetime savings": "Activer les économies cumulées", "Enable LinuxDO OAuth": "Activer LinuxDO OAuth", "Enable model performance metrics": "Activer les indicateurs de performance des modèles", "Enable OIDC": "Activer OIDC", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "Activer la surveillance des performances", "Enable rate limiting": "Activer la limitation de débit", "Enable Request Passthrough": "Activer le Passthrough de requêtes", + "Enable savings estimates": "Activer l'estimation des économies", "Enable selected channels": "Activer les canaux sélectionnés", "Enable selected models": "Activer les modèles sélectionnés", "Enable SSL/TLS": "Activer SSL/TLS", @@ -1727,11 +1758,18 @@ "Error Message (required)": "Message d'erreur (requis)", "Error parsing response data": "Erreur lors de l’analyse des données de réponse", "Error Type (optional)": "Type d'erreur (optionnel)", + "Estimate historical logs without a saved official price snapshot.": "Estimez les journaux historiques sans instantané de tarif officiel enregistré.", "Estimated cost": "Coût estimé", + "Estimated from official pricing": "Estimé à partir des tarifs officiels", + "Estimated from official public pricing": "Estimé à partir des tarifs publics officiels", "Estimated quota cost": "Coût de quota estimé", + "Estimated savings": "Économies estimées", + "Estimated Savings": "Économies estimées", + "Estimated: {{count}}": "Estimées : {{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Chaque nom de groupe du tableau tarifaire peut être utilisé à deux endroits : sur un utilisateur (groupe d’utilisateurs, attribué par les admins) et sur un jeton (groupe de jetons, choisi à la création du jeton). Même ensemble de noms, deux rôles différents.", "Every other device will lose access immediately. This device will remain signed in.": "Tous les autres appareils perdront immédiatement l’accès. Cet appareil restera connecté.", "Everything configured for this group, in one place.": "Toute la configuration de ce groupe, au même endroit.", + "Everything you need to start calling models": "Tout pour commencer à appeler des modèles", "Exact": "Exact", "Exact Match": "Correspondance exacte", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Correspondance exacte uniquement, sensible à la casse. Les préfixes, regex et jokers * ne sont pas pris en charge.", @@ -1744,6 +1782,7 @@ "Excellent": "Excellent", "Exchange rate is required": "Le taux de change est requis", "Exchange rate must be greater than 0": "Le taux de change doit être supérieur à 0", + "Exclude prices that have not been confirmed as official.": "Excluez les tarifs qui n'ont pas été confirmés comme officiels.", "Execute code in a sandbox during the response": "Exécuter du code dans un bac à sable pendant la réponse", "Executor": "Exécuteur", "Exhausted": "Épuisé", @@ -1871,6 +1910,7 @@ "Failed to load users": "Échec du chargement des utilisateurs", "Failed to parse group items": "Échec de l'analyse des éléments de groupe", "Failed to parse JSON file: {{name}}": "Échec de l'analyse du fichier JSON : {{name}}", + "Failed to pause historical backfill": "Impossible de suspendre le recalcul historique", "Failed to query balance": "Échec de la requête de solde", "Failed to refresh cache stats": "Échec de l'actualisation des statistiques de cache", "Failed to refresh credential": "Échec de l’actualisation des identifiants", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "Échec de la réinitialisation des ratios du modèle", "Failed to reset Passkey": "Échec de la réinitialisation de la Passkey", "Failed to reset usage": "Échec de la réinitialisation de l’utilisation", + "Failed to resume historical backfill": "Impossible de reprendre le recalcul historique", + "Failed to retry historical backfill": "Impossible de relancer le recalcul historique", "Failed to save": "Échec de la sauvegarde", "Failed to save announcements": "Échec de la sauvegarde des annonces", "Failed to save API info": "Échec de l'enregistrement des informations API", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "Échec du démarrage de la connexion {{provider}}", "Failed to start Discord login": "Échec du démarrage de la connexion Discord", "Failed to start GitHub login": "Échec du démarrage de la connexion GitHub", + "Failed to start historical backfill": "Échec du lancement du recalcul historique", "Failed to start LinuxDO login": "Échec du démarrage de la connexion LinuxDO", "Failed to start OIDC login": "Échec du démarrage de la connexion OIDC", "Failed to start Passkey login": "Impossible de démarrer la connexion Passkey", @@ -2067,6 +2110,7 @@ "Frames per second": "Images par seconde", "Free": "Libre", "Free: {{free}} / Total: {{total}}": "Disponible : {{free}} / Total : {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "Figer les tarifs officiels et le taux de change actuels, puis calculer les économies cumulées à partir des journaux existants.", "Frequency Penalty": "Pénalité de fréquence", "Friendly name to identify this channel": "Nom convivial pour identifier ce canal", "From Address": "De l'adresse", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "Masqué pour {{group}}", "Hide": "Masquer", "Hide API key": "Masquer la clé API", + "Hide password": "Masquer le mot de passe", "Hide sensitive data": "Masquer les données sensibles", "Hide setup guide": "Masquer le guide de configuration", "High Performance": "Hautes performances", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "J'accepte volontairement les risques pour la stabilité du système, notamment les délais d'attente sévères côté client et les pannes possibles du service, et j'assume toute accumulation de requêtes ou indisponibilité qui en résulterait.", "High-risk status code retry risk disclaimer": "### ⚠️ Opération à haut risque : avertissement et clause de non-responsabilité pour la relance des codes 504/524\n\nPar défaut, ce projet ne relance pas les codes `400` (requête incorrecte), `504` (délai d'attente de la passerelle) et `524` (délai d'attente dépassé). Les codes 504 et 524 signifient généralement que **la requête est bien parvenue au service IA en amont et que le traitement avait commencé, mais que la connexion s'est fermée parce que le traitement en amont a pris trop de temps**. Cela indique généralement un goulot d'étranglement du service en amont.\n\nActiver la redirection ou la relance pour ces codes de délai d'attente est une **opération à risque extrêmement élevé**. Avant de l'activer, vous devez lire attentivement et comprendre les conséquences suivantes :\n\n#### 1. Risques principaux (à lire attentivement)\n\n1. 💸 Facturation double ou multiple : la plupart des fournisseurs d'IA en amont **facturent quand même** les requêtes dont le traitement a commencé mais qui ont été interrompues par un délai réseau (504/524). Une relance envoie une toute nouvelle requête en amont et peut entraîner une **facturation double ou multiple**.\n2. ⏳ Délai d'attente sévère côté client : lorsqu'une requête a déjà expiré, les relances peuvent multiplier la latence totale et provoquer des délais sévères ou inacceptables pour le client final.\n3. 💥 Accumulation de requêtes et panne du service : les relances forcées occupent plus longtemps les threads et les connexions. En cas de forte concurrence, cela peut provoquer une importante **accumulation de requêtes**, épuiser les ressources, déclencher des défaillances en cascade et faire tomber le proxy.\n\n#### 2. Acceptation des risques\n\nSi vous choisissez malgré tout d'activer cette fonction, vous reconnaissez les éléments suivants :", "Higher priority channels are selected first": "Les canaux de priorité plus élevée sont sélectionnés en premier", + "Historical backfill": "Recalcul historique", + "Historical backfill batch size": "Taille des lots du recalcul historique", + "Historical estimates": "Estimations historiques", + "Historical rebuilds": "Recalculs historiques", + "Historical requests recalculated at current official prices: {{count}}": "Nombre de requêtes historiques recalculées aux tarifs officiels actuels : {{count}}", + "Historical savings backfill failed": "Le recalcul des économies historiques a échoué", + "Historical savings backfill failed; results are incomplete.": "Le recalcul des économies historiques a échoué ; les résultats sont incomplets.", + "Historical savings backfill is already active": "Le recalcul des économies historiques est déjà actif", + "Historical savings backfill pause requested": "Suspension du recalcul historique demandée", + "Historical savings backfill resumed": "Recalcul des économies historiques repris", + "Historical savings backfill retry started": "Reprise du recalcul historique depuis la progression enregistrée", + "Historical savings backfill started": "Recalcul des économies historiques démarré", "Historical Usage": "Utilisation historique", + "Historical usage has not been backfilled": "L'utilisation historique n'a pas encore été recalculée", + "Historical usage is recalculated using current official prices": "L'usage historique est recalculé selon les tarifs officiels actuels", "History of MjProxy-style image tasks.": "Historique des tâches d'images style MjProxy.", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "Critère de hit : si des tokens en cache existent dans l'utilisation, cela compte comme un hit.", "Hit Rate": "Taux de succès", @@ -2299,6 +2358,7 @@ "Important": "Important", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "En JSON, le groupe d’utilisateurs est la clé externe et le groupe de facturation la clé interne. L’exemple ci-dessous signifie : les utilisateurs vip paient 0,8 sous standard et 0,3 sous premium.", "In Progress": "En cours", + "In the last 24 hours, RAPI saved you about {{amount}}": "Au cours des dernières 24 h, RAPI vous a fait économiser environ {{amount}}", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "Dans l’éditeur visuel, ces règles apparaissent comme « Visible en plus » et « Masqué ». En JSON, +: (ou aucun préfixe) ajoute un groupe et -: en retire un.", "In:": "Entrée :", "incident": "incident", @@ -2413,6 +2473,7 @@ "Just now": "À l'instant", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "Conserver un total cumulé stable sans analyser les journaux à l'ouverture d'une page.", "Keep affinity when channel is disabled": "Conserver l'affinité lorsque le canal est désactivé", "Keep enabled if you need to proxy requests for different upstream accounts.": "Gardez activé si vous devez proxifier les requêtes pour différents comptes en amont.", "Keep enough balance before production traffic": "Gardez un solde suffisant avant le trafic de production", @@ -2437,6 +2498,8 @@ "Language preference saved": "Préférence de langue enregistrée", "Language Preferences": "Préférences de langue", "Language preferences sync across your signed-in devices and affect API error messages.": "Les préférences de langue se synchronisent sur vos appareils connectés et affectent les messages d'erreur de l'API.", + "Last 24 hours": "Dernières 24 heures", + "Last 24h savings estimate": "Estimation des économies sur 24 h", "Last 24h usage": "Utilisation 24h", "Last 30 days uptime": "Disponibilité 30 derniers jours", "Last active {{time}} · Expires {{expires}}": "Dernière activité {{time}} · Expire le {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "Inférieur ou égal", "Less Than or Equal": "Inférieur ou égal", "License": "Licence", + "Lifetime savings": "Économies cumulées", + "Lifetime savings counted so far": "Économies cumulées comptabilisées", + "Lifetime savings counted so far: {{amount}}": "Économies cumulées comptabilisées : {{amount}}", "Light": "Clair", "Lightning Fast": "Extrêmement rapide", "Limit period": "Période de limite", "Limit Reached": "Limite atteinte", + "Limit the date range of each savings summary query.": "Limitez la période de chaque requête de synthèse des économies.", + "Limit the number of usage logs scanned per summary.": "Limitez le nombre de journaux d'usage analysés par synthèse.", "Limit which models can be used with this key": "Limiter les modèles pouvant être utilisés avec cette clé", "Limited": "Limité", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Limite uniquement les instantanés Auto propres aux jetons. L’héritage Auto global reste illimité.", @@ -2565,6 +2633,7 @@ "Manage Bindings": "Gérer les liaisons", "Manage catalog visibility and pricing.": "Gérer la visibilité du catalogue et les prix.", "Manage custom OAuth providers for user authentication": "Gérer les fournisseurs OAuth personnalisés pour l'authentification des utilisateurs", + "Manage in JSON": "Gérer en JSON", "Manage Keys": "Gérer les clés", "Manage local models for:": "Gérer les modèles locaux pour :", "Manage multi-key status and configuration for this channel": "Gérer le statut multi-clés et la configuration pour ce canal", @@ -2596,6 +2665,7 @@ "Match Value": "Valeur de correspondance", "Match Value (optional)": "Valeur de correspondance (optionnel)", "Matched": "Correspondant", + "Matched Model": "Modèle correspondant", "Matched models": "Modèles associés", "Matched Tier": "Palier correspondant", "Matches models not claimed by earlier splits.": "Correspond aux modèles non pris par les branches précédentes.", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Nombre maximum de jetons que chaque utilisateur peut créer. Par défaut 1000. Une valeur trop élevée peut affecter les performances.", "Maximum number of tokens in the response": "Nombre maximum de jetons dans la réponse", "Maximum quota amount awarded for check-in": "Montant maximum de quota attribué pour la connexion", + "Maximum scanned log rows": "Nombre maximal de journaux analysés", + "Maximum summary range (days)": "Période maximale de synthèse (jours)", "Maximum tokens including hidden reasoning tokens": "Jetons maximum, y compris les jetons de raisonnement masqués", "Maximum tokens per response": "Nombre maximal de jetons par réponse", "Maximum tokens per user": "Nombre maximum de jetons par utilisateur", @@ -2905,6 +2977,7 @@ "No description available.": "Aucune description disponible.", "No discount tiers configured. Click \"Add discount tier\" to get started.": "Aucun niveau de réduction configuré. Cliquez sur « Ajouter un niveau de réduction » pour commencer.", "No duplicate keys found": "Aucune clé dupliquée trouvée", + "No eligible savings records yet": "Aucun enregistrement d'économies éligible pour le moment", "No enabled tokens available": "Aucun token activé disponible", "No encryption": "Aucun chiffrement", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "Aucun point de terminaison configuré. Passez en mode JSON ou ajoutez des lignes pour définir les points de terminaison.", @@ -2926,6 +2999,7 @@ "No Inviter": "Pas d'inviteur", "No keys found": "Aucune clé trouvée", "No latency data available": "Aucune donnée de latence disponible", + "No lifetime savings records yet": "Aucune économie cumulée enregistrée pour l'instant", "No log entries matched the selected time.": "Aucune entrée de journal ne correspond à l'heure sélectionnée.", "No logs": "Aucun journal", "No Logs Found": "Aucun journal trouvé", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Aucun groupe Uptime Kuma pour l'instant. Cliquez sur « Ajouter un groupe » pour en créer un.", "No uptime monitoring configured": "Aucune surveillance de disponibilité configurée", "No usage logs available. Logs will appear here once API calls are made.": "Aucun journal d'utilisation. Les journaux apparaîtront ici une fois les appels API effectués.", + "No usage records in the selected range": "Aucune donnée d'usage sur la période sélectionnée", "No user information available": "Aucune information utilisateur disponible", "No user selected": "Aucun utilisateur sélectionné", "No users": "Aucun utilisateur", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "Embeddings OpenAI officiels", "Official OpenAI Images": "Images OpenAI officielles", "Official OpenAI Responses": "Responses OpenAI officiel", + "Official price confirmation is required while savings estimates are enabled.": "La confirmation des tarifs officiels est obligatoire lorsque les estimations d’économies sont activées.", + "Official price estimate": "Estimation au tarif officiel", + "Official Price Estimate": "Estimation au tarif officiel", + "Official Price Updated": "Tarif officiel mis à jour", + "Official price updated {{time}}": "Tarif officiel mis à jour le {{time}}", + "Official price validity (days)": "Validité du tarif officiel (jours)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "Les tarifs officiels sont des instantanés confirmés de la place de marché des modèles ; ces estimations servent uniquement à comparer les coûts.", + "Official pricing": "Tarification officielle", + "Official pricing estimate": "Estimation des tarifs officiels", "Official Repository": "Dépôt officiel", "Official Sync": "Synchronisation Officielle", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "One API", "One domain per line": "Un domaine par ligne", "One domain per line (only used when domain restriction is enabled)": "Un domaine par ligne (utilisé uniquement lorsque la restriction de domaine est activée)", + "One endpoint, one key, and a clear view of every request.": "Un endpoint, une clé et une vue claire de chaque requête.", "One IP or CIDR range per line": "Une IP ou plage CIDR par ligne", "One IP per line (empty for no restriction)": "Une IP par ligne (laisser vide pour aucune restriction)", "one keyword per line": "un mot-clé par ligne", @@ -3304,6 +3389,11 @@ "Path not set": "Chemin non défini", "Path Regex (one per line)": "Regex du chemin (un par ligne)", "Path:": "Chemin :", + "Pause backfill": "Suspendre le recalcul", + "pause_requested": "suspension", + "paused": "suspendu", + "Paused": "Suspendu", + "Pausing": "Suspension", "Pay": "Pay", "Pay with Balance": "Payer avec le solde", "Pay-as-you-go with real-time usage monitoring": "Paiement à l'usage avec suivi de la consommation en temps réel", @@ -3507,11 +3597,14 @@ "Price estimation description": "Après avoir configuré le type de matériel, l'emplacement de déploiement, le nombre de réplicas, etc., le prix sera calculé automatiquement.", "Price ID": "ID du prix", "Price mode (USD per 1M tokens)": "Mode de tarification (USD par 1M de jetons)", + "Price overrides": "Remplacements de tarifs", "Price summary": "Résumé des prix", "price_xxx": "price_xxx", "Price:": "Prix :", "Price: High to Low": "Prix : Du plus élevé au plus bas", "Price: Low to High": "Prix : Du plus bas au plus élevé", + "Prices frozen at {{time}}": "Tarifs figés le {{time}}", + "Prices older than this are excluded from savings estimates.": "Les tarifs plus anciens sont exclus de l'estimation des économies.", "Prices shown per": "Prix affichés par", "Prices synced successfully": "Prix synchronisés avec succès", "Prices vary by usage tier and request conditions": "Les prix varient selon le palier d’utilisation et les conditions de requête", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "Ordre de priorité pour les jetons du groupe auto. Le système essaie les groupes de haut en bas.", "Privacy Policy": "Politique de confidentialité", "Private Deployment URL": "URL de déploiement privé", + "Process between 500 and 5000 usage logs per batch.": "Traiter entre 500 et 5 000 journaux d'usage par lot.", "Processing OAuth response...": "Traitement de la réponse OAuth...", "Processing...": "Traitement...", "Product": "Produit", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "Sélectionner aléatoirement une clé du pool pour chaque requête", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "Les données de classement sont actuellement simulées à des fins d'aperçu et seront remplacées par des analyses en direct une fois l'intégration backend livrée.", "Rankings": "Classements", + "RAPI has saved you about {{amount}} in total": "RAPI vous a fait économiser environ {{amount}} au total", + "RAPI saved you about {{amount}}": "RAPI vous a fait économiser environ {{amount}}", "Rate Limit Windows": "Fenêtres de limitation", "Rate Limited": "Limitation de débit", "Rate Limiting": "Limitation du débit", @@ -3658,6 +3754,7 @@ "Reason:": "Raison :", "Reasoning": "Raisonnement", "Reasoning Effort": "Effort de raisonnement", + "Recalculate legacy usage logs": "Recalculer les anciens journaux d'usage", "Receive Upstream Model Update Notifications": "Recevoir les notifications de mise à jour des modèles en amont", "Received": "Reçu", "Received amount": "Montant reçu", @@ -3732,6 +3829,8 @@ "Reject Reason": "Raison du rejet", "Release details": "Détails de la version", "Released": "Sorti", + "reliability controls": "mécanismes de fiabilité", + "Reload savings data": "Recharger les économies", "Relying Party Display Name": "Nom d'affichage de la partie de confiance", "Relying Party ID": "ID de la partie de confiance", "Remaining": "Restant", @@ -3790,6 +3889,7 @@ "Request Body Field": "Champ du corps de requête", "Request Body Memory Cache": "Cache mémoire du corps de requête", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "Le passage du corps de requête est activé. Le corps sera envoyé directement en amont sans conversion.", + "Request completed": "Requête terminée", "Request conversion": "Conversion de requête", "Request Conversion": "Conversion de requête", "Request Count": "Nombre de requêtes", @@ -3813,6 +3913,7 @@ "Requests": "Requêtes", "Requests (24h)": "Requêtes (24 h)", "Requests / 24h": "Requêtes / 24 h", + "Requests are routed across available services to improve call stability.": "Les requêtes sont acheminées entre les services disponibles pour améliorer la stabilité.", "Requests per minute": "Requêtes par minute", "requests served": "requêtes traitées", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Les requêtes seront transmises à ce worker. Les barres obliques finales sont automatiquement supprimées.", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "Exiger le succès de la tâche avant les actions de suivi", "Require login to view models": "Exiger la connexion pour voir les modèles", "Require login to view rankings": "Exiger la connexion pour voir les classements", + "Require official price confirmation": "Exiger la confirmation du tarif officiel", "required": "requis", "Required": "Requis", "Required events:": "Événements requis :", "Required provider, authentication, model, and group settings": "Paramètres requis de fournisseur, authentification, modèles et groupes", "Required to expose MjProxy-style image generation to end users.": "Requis pour exposer la génération d'images style MjProxy aux utilisateurs finaux.", + "Required while savings estimates are enabled.": "Requis lorsque les estimations d’économies sont activées.", "Rerank": "Reclasser", "Reroll": "Relancer", "Research, analysis, scientific reasoning": "Recherche, analyse, raisonnement scientifique", @@ -3880,11 +3983,13 @@ "Restore global Auto": "Restaurer l’Auto global", "Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)", "Result": "Résultat", + "Resume backfill": "Reprendre le recalcul", "Retain last N days": "Conserver les N derniers jours", "Retain last N files": "Conserver les N derniers fichiers", "Retention days": "Jours de rétention", "Retry": "Réessayer", "Retry Chain": "Chaîne de tentatives", + "Retry from saved progress": "Reprendre depuis la progression enregistrée", "Retry Settings": "Paramètres de relance", "Retry Suggestion": "Suggestion de relance", "Retry Times": "Nombre de tentatives", @@ -3986,6 +4091,7 @@ "Save Preferences": "Enregistrer les préférences", "Save preview": "Aperçu de l’enregistrement", "Save rate limits": "Enregistrer les limites de débit", + "Save savings estimate settings": "Enregistrer les paramètres d'économies estimées", "Save sensitive words": "Enregistrer les mots sensibles", "Save Settings": "Enregistrer les paramètres", "Save sidebar modules": "Enregistrer les modules de la barre latérale", @@ -4000,6 +4106,11 @@ "Save Worker settings": "Enregistrer les paramètres Worker", "Saved successfully": "Enregistré avec succès", "Saving...": "Enregistrement en cours...", + "Savings data update failed": "Échec de la mise à jour des économies", + "Savings estimate": "Économies estimées", + "Savings estimate is not enabled": "L'estimation des économies n'est pas activée", + "Savings lifetime backfill": "Recalcul des économies cumulées", + "Savings rate": "Taux d'économie", "Scan QR Code": "Scanner le code QR", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "Scannez le code QR pour suivre le compte officiel et répondez par « 验证码 » pour recevoir votre code de vérification.", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Scannez ce code QR avec votre application d'authentification (Google Authenticator, Microsoft Authenticator, etc.)", @@ -4200,13 +4311,21 @@ "Show": "Afficher", "Show All": "Tout afficher", "Show all providers including unbound": "Afficher tous les fournisseurs (y compris non liés)", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "Afficher les économies cumulées, la couverture et la progression du recalcul sur le tableau de bord.", + "Show in usage logs": "Afficher dans les journaux d'usage", + "Show lifetime savings in wallet": "Afficher les économies cumulées dans le portefeuille", + "Show lifetime savings on dashboard": "Afficher les économies cumulées sur le tableau de bord", + "Show on dashboard": "Afficher sur le tableau de bord", "Show only bound providers": "Afficher uniquement les fournisseurs liés", "Show or hide flow columns": "Afficher ou masquer les colonnes du flux", + "Show password": "Afficher le mot de passe", "Show preview": "Afficher l'apercu", "Show prices in currency instead of quota.": "Afficher les prix en devise au lieu du quota.", + "Show request-level savings estimates in usage logs.": "Affichez l'estimation des économies par requête dans les journaux d'usage.", "Show sensitive data": "Afficher les données sensibles", "Show setup guide": "Afficher le guide de configuration", "Show source": "Afficher la source", + "Show the savings summary and trend on the user dashboard.": "Affichez la synthèse et la tendance des économies sur le tableau de bord utilisateur.", "Show token usage statistics in the UI": "Afficher les statistiques d'utilisation des jetons dans l'interface utilisateur", "Showcase core capabilities with demo credentials and limited access.": "Présenter les fonctionnalités principales avec des identifiants de démonstration et un accès limité.", "Showing": "Affichage de", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "Connecté avec Passkey", "Signed out": "Déconnecté", "Significant outages detected": "Pannes significatives détectées", + "Signing in...": "Connexion...", "Signing you in with {{provider}}": "Connexion avec {{provider}}", "SiliconFlow": "SiliconFlow", "Simple": "Simple", "Simple mode only returns message; status code and error type use system defaults.": "Le mode simple ne retourne que le message ; le code de statut et le type d'erreur utilisent les valeurs par défaut.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Mode simple : nettoyer les objets par type, ex. redacted_thinking.", + "Since {{date}} · {{coverage}} coverage": "Depuis le {{date}} · couverture {{coverage}}", "Single Key": "Clé unique", "Site & Branding": "Site et marque", "Site Key": "Clé du site", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "Ne pas réessayer en cas d'échec", "Skip SMTP TLS certificate verification": "Ignorer la vérification du certificat TLS SMTP", "Skip to Main": "Aller au contenu principal", + "Skipped: {{count}}": "Ignorées : {{count}}", "Slug": "Slug", "Slug can only contain letters, numbers, hyphens, and underscores": "Le slug ne peut contenir que des lettres, des chiffres, des tirets et des underscores", "Slug is required": "Le slug est requis", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite stocke toutes les données dans un seul fichier. Assurez-vous que ce fichier est persisté lors de l'exécution dans des conteneurs.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "Protection SSRF", + "Stable model calls": "Appels de modèles stables", "stale": "expiré", "Standard": "Standard", "Standard price": "Prix standard", "Start": "Début", "Start a conversation to see messages here": "Démarrez une conversation pour voir les messages ici", "Start a playground chat": "Démarrer une conversation dans le playground", + "Start calling supported models with RAPI": "Appelez les modèles pris en charge avec RAPI", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Commencez à encaisser des paiements dans le monde entier sans créer de société. Conçu pour les développeurs indépendants, les entrepreneurs individuels OPC et les startups. Waffo Pancake agit comme Merchant of Record et prend en charge la conformité liée à l’encaissement mondial : taxes à la consommation, facturation, gestion des abonnements, remboursements et rétrofacturations. Les développeurs solo peuvent lancer rapidement leur produit et rester concentrés sur celui-ci plutôt que sur la conformité. Intégration en quelques minutes, d’une seule invite à une intégration complète.", "Start for free with generous limits. No credit card required.": "Commencez gratuitement avec des limites généreuses. Aucune carte de crédit requise.", + "Start historical backfill": "Démarrer le recalcul historique", "Start Time": "Heure de début", + "Start with the familiar OpenAI-compatible workflow.": "Démarrez avec le flux familier compatible OpenAI.", "Started": "Démarré", "STARTTLS": "STARTTLS", "Static page describing the platform.": "Page statique décrivant la plateforme.", @@ -4379,6 +4505,7 @@ "Super Large": "Très grand", "Support for high concurrency with automatic load balancing": "Prise en charge de la haute concurrence avec équilibrage de charge automatique", "Supported Applications": "Applications prises en charge", + "supported billing models": "modèles de facturation pris en charge", "Supported Imagine Models": "Modèles Imagine pris en charge", "Supported modalities": "Modalités prises en charge", "Supported parameters": "Paramètres pris en charge", @@ -4411,6 +4538,8 @@ "System Behavior": "Comportement du système", "System data statistics": "Statistiques des données système", "System default": "Système par défaut", + "System historical data counting is paused": "Le traitement des données historiques du système est en pause", + "System historical data is being counted": "Traitement des données historiques du système en cours", "System Info": "Infos système", "System Information": "Informations système", "System initialized successfully! Redirecting…": "Système initialisé avec succès ! Redirection…", @@ -4457,6 +4586,7 @@ "Task logs": "Journaux des tâches", "Task Logs": "Journaux de tâches", "Tasks currently pending or running.": "Tâches actuellement en attente ou en cours d’exécution.", + "Tasks currently pending, running, or paused.": "Tâches actuellement en attente, en cours ou suspendues.", "Team Collaboration": "Collaboration d'équipe", "Technical Support": "Support technique", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "Trois appels du même utilisateur vip. Supposons que le prix de base d’un appel est 10.", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "Trois groupes ; la matrice de remplacement n’a qu’une seule cellule remplie (surlignée).", "Three steps to get started": "Trois étapes pour commencer", + "Three steps to your first model request": "Trois étapes vers votre première requête de modèle", "Throughput": "Débit", "Throughput by group": "Débit par groupe", "Throughput short": "TPS", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Le nombre maximal de sessions de connexion actives est atteint. Sur un appareil déjà connecté, ouvrez « Sessions de connexion » et utilisez « Déconnecter les autres sessions » pour les révoquer. Si vous n’avez accès à aucun appareil connecté, réinitialisez votre mot de passe pour fermer toutes les sessions.", "Too many files. Some were not added.": "Trop de fichiers. Certains n'ont pas été ajoutés.", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Trop de sessions de connexion ont été créées récemment. Attendez la fin de la fenêtre glissante, puis réessayez.", + "Too many records to summarize": "Trop d'enregistrements à résumer", "Too many requests": "Trop de requêtes", "Tool / function declarations the model may call": "Déclarations d'outils / fonctions que le modèle peut appeler", "Tool identifier": "Identifiant d’outil", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "Transférer vers le solde", "Translation": "Traduction", "Transparent Billing": "Facturation transparente", + "Treat local model marketplace prices as official reference prices.": "Considérez les tarifs locaux de la place de marché comme tarifs officiels de référence.", "Trend": "Tendance", "Trending down": "En baisse", "Trending up": "En hausse", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "Impossible de charger les sessions de connexion", "Unable to load rankings": "Impossible de charger les classements", "Unable to load rankings data": "Impossible de charger les données des classements", + "Unable to load savings trend": "Impossible de charger la tendance des économies", "Unable to open chat": "Impossible d'ouvrir la discussion", "Unable to parse structured pricing": "Impossible d'analyser la tarification structurée", "Unable to prepare chat link. Please ensure you have an enabled API key.": "Impossible de préparer le lien de chat. Veuillez vous assurer d'avoir une clé API activée.", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "Comprendre des entrées image en complément du texte", "Unexpected release payload": "Format de version inattendu", "Unified API Gateway for": "Passerelle API unifiée pour", + "Unified model API service": "Service d'API de modèles unifié", "Unique identifier for this group.": "Identifiant unique pour ce groupe.", "Unit price (local currency / USD)": "Prix unitaire (devise locale / USD)", "Unit price (USD)": "Prix unitaire (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "Fournisseur mis à jour", "Updated channel {{name}} (ID: {{id}})": "Canal {{name}} mis à jour (ID : {{id}})", "Updated daily": "Mis à jour quotidiennement", + "Updated savings official price setting": "Configuration des tarifs officiels d'économies mise à jour", "Updated successfully": "Mise à jour réussie", "Updated system setting {{key}}": "Paramètre système {{key}} mis à jour", "Updated user {{username}} (ID: {{id}})": "Utilisateur {{username}} mis à jour (ID : {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "L'URL est requise", "URL to your logo image (optional)": "URL de votre image de logo (facultatif)", "Usage": "Utilisation", + "Usage Analysis": "Analyse d’utilisation", "Usage at a glance": "Vue d'ensemble de l'utilisation", "Usage guide": "Guide d'utilisation", "Usage logs": "Journaux d'utilisation", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "Utiliser des outils externes pour étendre les capacités", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Utilise un crédit de réinitialisation disponible pour ce canal. La demande n’est envoyée qu’après confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Utilise un crédit de réinitialisation disponible pour actualiser les fenêtres d’utilisation Codex actuelles.", + "Use one compatible endpoint to access supported models without changing SDKs.": "Accédez aux modèles pris en charge via un endpoint compatible, sans changer de SDK.", "Use our unified OpenAI-compatible endpoint in your applications": "Utilisez notre point de terminaison unifié compatible OpenAI dans vos applications", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Utilisez une Passkey ou la 2FA pour confirmer votre identité avant d’afficher cette clé de canal.", "Use Passkey to sign in without entering your password.": "Utilisez une clé d'accès (Passkey) pour vous connecter sans saisir votre mot de passe.", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "Les utilisateurs de vip, facturés sous premium, paient le taux", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Les utilisateurs ne voient que les groupes marqués comme sélectionnables. Les groupes non sélectionnables peuvent toujours être attribués par les administrateurs.", "uses": "utilisations", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "Utilise par défaut les tarifs officiels locaux de la place de marché des modèles ; official_prices sert uniquement aux exceptions.", "Using the complete global Auto order ({{count}} groups)": "Utilisation de l’ordre Auto global complet ({{count}} groupes)", "Validity": "Validité", "Validity Period": "Période de validité", @@ -5077,6 +5216,7 @@ "View mode": "Mode d'affichage", "View model statistics and charts": "Afficher les statistiques et graphiques des modèles", "View Pricing": "Voir les tarifs", + "View savings trend": "Voir la tendance des économies", "View the complete details for this": "Voir les détails complets de ce", "View the complete details for this log entry": "Voir les détails complets de cette entrée de journal", "View the complete error message and details": "Voir le message d'erreur et les détails complets", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "Les instances worker n’exécutent pas les tâches d’arrière-plan réservées au master.", "Worker Proxy": "Proxy Worker", "Worker URL": "URL du Worker", + "Workspace": "Espace de travail", "Workspaces": "Espaces de travail", "Write value to the target field": "Écrire la valeur dans le champ cible", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter ?", "You Pay": "Vous payez", "You save": "Vous économisez", + "You saved about {{amount}}": "Vous avez économisé environ {{amount}}", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "Vous comprenez et assumez indépendamment la responsabilité juridique découlant du déploiement, de l’exploitation et de la facturation.", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Vous comprenez que ce rappel de conformité est uniquement un avis de risque et ne constitue ni un conseil juridique, ni une conclusion d’examen de conformité, ni une garantie de la légalité de votre utilisation de ce système ; vous devez consulter des conseillers juridiques ou conformité professionnels selon votre situation réelle.", "You will be redirected to Telegram to complete the binding process.": "Vous serez redirigé vers Telegram pour terminer le processus de liaison.", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 3b9d4ce693cc..455ac244c7dd 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} IP", "{{count}} log entries removed.": "{{count}} 件のログエントリを削除しました。", "{{count}} minutes ago": "{{count}} 分前", + "{{count}} model price overrides": "モデル価格の上書き {{count}} 件", "{{count}} models": "{{count}} モデル", "{{count}} months ago": "{{count}} ヶ月前", "{{count}} override": "{{count}} 個のオーバーライド", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} 件の Uptime Kuma グループがリストから削除されます。", "{{count}} vendors": "{{count}} ベンダー", "{{count}} weeks ago": "{{count}} 週間前", + "{{coverage}} coverage": "カバー率 {{coverage}}", "{{field}} updated to {{value}}": "{{field}} を {{value}} に更新しました", "{{field}} updated to {{value}} for tag: {{tag}}": "タグ「{{tag}}」の {{field}} を {{value}} に更新しました", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "{{modality}} をサポート", "{{n}} model(s) selected": "{{n}} 件のモデルを選択済み", "{{processed}} of {{total}} log entries processed.": "{{total}} 件中 {{processed}} 件のログを処理しました。", + "{{processed}} of {{total}} usage logs processed": "{{total}}件中{{processed}}件の利用ログを処理済み", "{{success}} succeeded, {{failed}} failed": "{{success}} 件成功、{{failed}} 件失敗", "{{target}} test failed": "{{target}} のテストに失敗しました", "{{target}} test succeeded": "{{target}} のテストに成功しました", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "キー、残高、ルーティング、サービス状態を集約したホームです。", "About": "このサービスについて", "About {{days}} days left": "約 {{days}} 日分", + "About historical savings estimates": "過去の節約見積もりについて", + "About official pricing estimates": "公式料金による推定について", "Accept Unpriced Models": "価格設定されていないモデルを許可", "Accepts a JSON array of model identifiers that support the Imagine API.": "Imagine APIをサポートするモデル識別子のJSON配列を受け入れます。", "Accepts comma-separated status codes and inclusive ranges.": "カンマ区切りのステータスコードと包含範囲を受け入れます。", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "標準的で統一されたAPIプロトコルを介して、膨大なモデルにアクセス。AIアプリケーションを強化し、デジタル資産を管理し、未来へと繋げます。", "Access Denied Message": "アクセス拒否メッセージ", "Access Forbidden": "アクセス禁止", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "1つの互換APIから複数のモデルサービスに接続。共通キーで、開発から本番まで利用量・残高・リクエストを明確に把握できます。", "Access Policy (JSON)": "アクセスポリシー (JSON)", "Access previous conversations and start new ones.": "以前の会話にアクセスし、新しい会話を開始します。", "Access Token": "アクセストークン", "AccessKey / SecretAccessKey": "AccessKey/SecretAccessKey", + "Account": "アカウント", + "Account & Security": "アカウントとセキュリティ", "Account Binding Management": "アカウント連携管理", "Account Bindings": "アカウントバインディング", "Account created! Please sign in": "アカウントが作成されました!ログインしてください", @@ -152,6 +160,7 @@ "Active Tasks": "進行中のタスク", "active users": "アクティブユーザー", "Actual Amount": "実際の金額", + "Actual Cost": "実際のコスト", "Actual Model": "実際のモデル", "Actual Model:": "実際のモデル:", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "`-thinking` サフィックス付きリクエストを Anthropic ネイティブの思考動作に適配し、課金を予測可能に保ちます。", @@ -212,6 +221,7 @@ "Add split": "分岐を追加", "Add subscription": "サブスクリプションを追加", "Add tags...": "タグを追加...", + "Add the frozen cumulative savings amount to the wallet summary.": "固定された累計節約額をウォレット概要に表示します。", "Add tier": "ティアを追加", "Add time condition": "時間条件を追加", "Add time rule group": "時間ルールグループを追加", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "有効化するとユーザーに表示されます。続行しますか?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "無効化するとこのサブスクリプションは即座に停止されます。履歴記録には影響しません。続行しますか?", "Agent ID *": "エージェントID *", + "Aggregate new usage into a frozen lifetime savings total.": "新しい利用分を固定された累計節約額に集計します。", "Aggregate tokens delivered across the platform": "プラットフォーム全体で提供された累計トークン", "Aggregate traffic across every category": "全カテゴリの合計トラフィック", "Aggregated across enabled groups": "有効なグループで集計", @@ -342,6 +353,7 @@ "Allowed Ports": "許可するポート", "Already have an account?": "アカウントをお持ちの方?", "Always matches (default tier).": "常に一致(デフォルト ティア)。", + "Ambiguous ClickHouse rows skipped: {{count}}": "区別できないClickHouse行をスキップ:{{count}}", "Amount": "金額", "Amount cannot be changed when editing.": "編集時は金額を変更できません。", "Amount discount": "割引額", @@ -531,6 +543,7 @@ "Available Models": "利用可能なモデル", "Available reset credits": "利用可能なリセット回数", "Available Rewards": "利用可能な報酬", + "available service channels": "利用可能なサービスチャネル", "Average latency": "平均レイテンシ", "Average latency, TTFT, and success rate by group": "グループ別の平均レイテンシ、TTFT、成功率", "Average latency, TTFT, TPS, and success rate": "平均レイテンシ、TTFT、TPS、成功率", @@ -551,6 +564,7 @@ "Back to login": "ログインに戻る", "Back to Models": "モデルに戻る", "Backed up": "バックアップ済み", + "Backfill running": "再計算中", "Background job tracker for queued work.": "キューされた作業のためのバックグラウンドジョブトラッカー。", "Backup Code": "バックアップコード", "Backup code must be in format XXXX-XXXX": "バックアップコードはXXXX-XXXX形式で入力してください", @@ -700,6 +714,7 @@ "Cache write price": "キャッシュ書き込み価格", "Cached": "キャッシュ", "Cached input": "キャッシュ入力", + "Calculate estimated savings using official model prices.": "モデルの公式価格を使用して推定節約額を計算します。", "Calculated price: ${{price}} per 1M tokens": "計算価格:${{price}} / 1M トークン", "Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}", "Calculating...": "計算中...", @@ -801,6 +816,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "中国語", + "Choose a supported model and send your first request.": "対応モデルを選び、最初のリクエストを送信します。", "Choose a username": "ユーザー名を選択", "Choose an amount and payment method": "金額と支払い方法を選択してください", "Choose and order the groups this API key will try.": "この API キーが試行するグループを選択して並べ替えます。", @@ -847,6 +863,7 @@ "Clear search": "検索をクリア", "Clear selection": "選択をクリア", "Clear selection (Escape)": "選択をクリア (Escape)", + "Clear usage and balance": "利用量と残高を明確に把握", "Cleared": "クリア済み", "Cleared {{bindingType}} binding for user {{username}}": "ユーザー {{username}} の {{bindingType}} 連携を解除しました", "Cleared all models": "すべてのモデルをクリアしました", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "請求に使用されるモデル、キャッシュ、およびグループ比率を設定します。", "Configure monitoring status page groups for the dashboard": "ダッシュボードの監視ステータスページグループを設定します。", "Configure NODE_NAME": "NODE_NAME を設定", + "Configure official pricing snapshots for user savings estimates.": "ユーザーの節約見積もりに使う公式価格スナップショットを設定します。", "Configure per-model ratio for image inputs or outputs.": "画像の入力または出力のモデルごとの比率を設定します。", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "ツールごとの単価($/1K 回)を設定します。リクエスト課金モデルでは追加工具料金はかかりません。", "Configure pricing ratios for a specific model.": "特定のモデルの料金比率を設定します。", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "無効化を確認", "Confirm log cleanup": "ログクリーンアップの確認", "Confirm log file cleanup?": "ログファイルをクリーンアップしますか?", + "Confirm marketplace pricing as official": "モデル広場の価格を公式として確認", "Confirm New Password": "新しいパスワードの確認", "Confirm password": "パスワードの確認", "Confirm Payment": "支払いの確認", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "content内のreasoning_contentをタグに変換", "Convert string to lowercase": "文字列を小文字に変換", "Convert string to uppercase": "文字列を大文字に変換", + "Converted at 1 USD = {{rate}} CNY": "1 USD = {{rate}} CNY で換算", "Converter": "コンバーター", "Converter does not match incoming path": "コンバーターが受信パスと一致しません", "Converter is not registered": "コンバーターが登録されていません", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "費用 = 10 × 0.8 = 8", "Cost = 10 × 1.0 = 10": "費用 = 10 × 1.0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = モデル価格 × この1つの倍率。グループ設定の他の項目は計算式に入りません。", + "Cost comparison": "コスト比較", "Cost in USD per request, regardless of tokens used.": "使用されたトークンに関係なく、リクエストあたりのUSDでのコスト。", "Cost Tracking": "コスト追跡", "Count must be between {{min}} and {{max}}": "カウントは{{min}}から{{max}}の間である必要があります", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "現在までの集計 · カバー率 {{coverage}} · 再計算 {{progress}}", + "Coverage": "カバー率", + "Covered request actual cost": "対象リクエストの実コスト", + "Covered requests": "対象リクエスト", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 閾値 (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "ビジュアルエディタまたは生のJSONでリクエストパラメータオーバーライドルールを作成します。", "Create request parameter override rules without editing raw JSON.": "生の JSON を編集せずにリクエストパラメータ上書きルールを作成します。", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "モデル、タグ、エンドポイント、およびユーザーグループの再利用可能なバンドルを作成し、コンソールの他の場所での設定を高速化します。", + "Create separate keys for your projects and keep credentials under your control.": "プロジェクトごとにキーを作成し、認証情報を自分で管理できます。", "Create succeeded": "作成に成功しました", "Create Vendor": "ベンダーを作成", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "ダッシュボードのどこでもモデル、タグ、またはエンドポイントの選択を再利用するために、最初のグループを作成します。", @@ -1187,6 +1213,8 @@ "Currency": "通貨", "Currency & Display": "通貨と表示", "Current": "現在", + "Current account cost comparison": "現在のアカウントのコスト比較", + "Current account only": "現在のアカウントのみ", "Current Balance": "現在の残高", "Current Billing": "現在の請求", "Current Cache Size": "現在のキャッシュサイズ", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "{{parameter}}を有効化", "Enable 2FA": "2FA を有効にする", "Enable All": "すべて有効にする", + "Enable and save lifetime savings before starting a backfill.": "履歴再計算を開始する前に、累計節約額を有効にして保存してください。", "Enable check-in feature": "チェックイン機能を有効にする", "Enable Data Dashboard": "データダッシュボードを有効にする", "Enable demo mode with limited functionality": "機能が制限されたデモモードを有効にする", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "特別な応答形式を持つOpenRouterエンタープライズアカウントである場合に有効にします", "Enable io.net deployments": "io.net デプロイを有効化", "Enable io.net model deployment service in console": "コンソールで io.net モデルデプロイサービスを有効化", + "Enable lifetime savings": "累計節約額を有効化", "Enable LinuxDO OAuth": "LinuxDO OAuthを有効にする", "Enable model performance metrics": "モデル性能メトリクスを有効化", "Enable OIDC": "OIDCを有効にする", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "パフォーマンス監視を有効にする", "Enable rate limiting": "レート制限を有効にする", "Enable Request Passthrough": "リクエストパススルーを有効にする", + "Enable savings estimates": "節約見積もりを有効化", "Enable selected channels": "選択したチャネルを有効にする", "Enable selected models": "選択したモデルを有効にする", "Enable SSL/TLS": "SSL/TLSを有効にする", @@ -1727,11 +1758,18 @@ "Error Message (required)": "エラーメッセージ(必須)", "Error parsing response data": "レスポンスデータの解析に失敗しました", "Error Type (optional)": "エラータイプ(任意)", + "Estimate historical logs without a saved official price snapshot.": "保存済みの公式価格スナップショットがない過去ログを見積もります。", "Estimated cost": "推定コスト", + "Estimated from official pricing": "公式価格から推定", + "Estimated from official public pricing": "公式公開料金に基づく推定", "Estimated quota cost": "想定クォートコスト", + "Estimated savings": "推定節約額", + "Estimated Savings": "推定節約額", + "Estimated: {{count}}": "推定済み:{{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "料金表の各グループ名は2つの場所で使えます。ユーザー側(ユーザーグループ、管理者が割り当て)とトークン側(トークングループ、トークン作成時に選択)です。同じ名前プールで、役割は2つです。", "Every other device will lose access immediately. This device will remain signed in.": "他のすべてのデバイスは直ちにアクセスできなくなります。このデバイスはログイン状態を維持します。", "Everything configured for this group, in one place.": "このグループのすべての設定を一か所で確認できます。", + "Everything you need to start calling models": "モデル利用を始めるために必要なすべて", "Exact": "完全一致", "Exact Match": "完全一致", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "完全一致のみで、大文字と小文字も区別します。プレフィックス、正規表現、* ワイルドカードは使えません。", @@ -1744,6 +1782,7 @@ "Excellent": "優秀", "Exchange rate is required": "為替レートは必須です", "Exchange rate must be greater than 0": "為替レートは 0 より大きくする必要があります", + "Exclude prices that have not been confirmed as official.": "公式として確認されていない価格を除外します。", "Execute code in a sandbox during the response": "応答中にサンドボックスでコードを実行", "Executor": "実行ノード", "Exhausted": "使い切り", @@ -1871,6 +1910,7 @@ "Failed to load users": "ユーザーの読み込みに失敗しました", "Failed to parse group items": "グループアイテムの解析に失敗しました", "Failed to parse JSON file: {{name}}": "JSONファイルの解析に失敗しました: __ PH_0 __", + "Failed to pause historical backfill": "履歴再計算を一時停止できませんでした", "Failed to query balance": "残高の取得に失敗しました", "Failed to refresh cache stats": "キャッシュ統計の更新に失敗", "Failed to refresh credential": "認証情報の更新に失敗しました", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "モデル比率のリセットに失敗しました", "Failed to reset Passkey": "パスキーのリセットに失敗しました", "Failed to reset usage": "使用量のリセットに失敗しました", + "Failed to resume historical backfill": "履歴再計算を再開できませんでした", + "Failed to retry historical backfill": "履歴再計算を再試行できませんでした", "Failed to save": "保存に失敗", "Failed to save announcements": "お知らせの保存に失敗しました", "Failed to save API info": "API情報の保存に失敗しました", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "{{provider}} ログインの開始に失敗しました", "Failed to start Discord login": "Discordログインの開始に失敗しました", "Failed to start GitHub login": "GitHubログインの開始に失敗しました", + "Failed to start historical backfill": "履歴再計算を開始できませんでした", "Failed to start LinuxDO login": "LinuxDOログインの開始に失敗しました", "Failed to start OIDC login": "OIDCログインの開始に失敗しました", "Failed to start Passkey login": "Passkeyログインの開始に失敗しました", @@ -2067,6 +2110,7 @@ "Frames per second": "フレームレート", "Free": "空き", "Free: {{free}} / Total: {{total}}": "空き容量: {{free}} / 合計: {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "現在の公式価格と為替レートを固定し、既存の利用ログから累計節約額を計算します。", "Frequency Penalty": "頻度ペナルティ", "Friendly name to identify this channel": "このチャネルを識別するための表示名", "From Address": "差出人アドレス", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "{{group}} から非表示", "Hide": "非表示にする", "Hide API key": "APIキーを非表示", + "Hide password": "パスワードを非表示", "Hide sensitive data": "機密データを非表示", "Hide setup guide": "セットアップガイドを非表示", "High Performance": "高パフォーマンス", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "深刻なクライアントタイムアウトやサービス停止を含むシステム安定性のリスクを自発的に受け入れ、その結果生じるリクエスト滞留やサービス停止の責任を負います。", "High-risk status code retry risk disclaimer": "### ⚠️ 高リスク操作:504/524 ステータスコードのリトライに関するリスク通知と免責事項\n\n本プロジェクトは既定で、`400`(不正なリクエスト)、`504`(ゲートウェイタイムアウト)、`524`(タイムアウト発生)をリトライしません。504 と 524 は通常、**リクエストが上流 AI サービスに正常に到達して上流側で処理が始まっているものの、上流処理に時間がかかりすぎて接続が切断された**ことを意味します。多くの場合、これは上流サービス側のボトルネックです。\n\nこれらのタイムアウトコードでリダイレクトまたはリトライを有効にすることは、**極めて高リスクな操作**です。有効にする前に、次の重大な影響を必ず読み、理解してください。\n\n#### 1. 主なリスク(必ずお読みください)\n\n1. 💸 二重・多重課金:多くの上流 AI プロバイダーは、処理開始後にネットワークタイムアウト(504/524)で中断されたリクエストにも**通常どおり課金します**。リトライでは新しい上流リクエストが送信されるため、**二重または多重課金**になる可能性があります。\n2. ⏳ 深刻なクライアントタイムアウト:すでにタイムアウトしたリクエストにリトライを重ねると、総待ち時間が何倍にもなり、最終クライアントで深刻または許容できないタイムアウトが発生する可能性があります。\n3. 💥 リクエスト滞留とサービス停止:強制リトライはスレッドと接続を長時間占有します。高負荷時には深刻な**リクエスト滞留**、リソース枯渇、連鎖障害を招き、プロキシサービスが停止する可能性があります。\n\n#### 2. リスクの確認\n\nそれでもこの機能を有効にする場合は、以下のすべてを確認したものとみなされます。", "Higher priority channels are selected first": "優先度の高いチャネルが先に選択されます", + "Historical backfill": "履歴再計算", + "Historical backfill batch size": "履歴再計算のバッチサイズ", + "Historical estimates": "過去データの見積もり", + "Historical rebuilds": "過去データの再計算", + "Historical requests recalculated at current official prices: {{count}}": "現在の公式価格で再計算した過去のリクエスト数:{{count}}", + "Historical savings backfill failed": "履歴節約額の再計算に失敗しました", + "Historical savings backfill failed; results are incomplete.": "過去分の節約額の再計算に失敗したため、結果は未完了です。", + "Historical savings backfill is already active": "履歴節約額の再計算はすでに実行中です", + "Historical savings backfill pause requested": "履歴節約額の再計算に一時停止を要求しました", + "Historical savings backfill resumed": "履歴節約額の再計算を再開しました", + "Historical savings backfill retry started": "保存済みの進捗から履歴節約額の再計算を再試行しました", + "Historical savings backfill started": "履歴節約額の再計算を開始しました", "Historical Usage": "履歴使用状況", + "Historical usage has not been backfilled": "過去の利用分は未再計算です", + "Historical usage is recalculated using current official prices": "過去の使用量は現在の公式価格で再計算されます", "History of MjProxy-style image tasks.": "MjProxyスタイルの画像タスクの履歴。", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "ヒット判定:usage に cached tokens が存在すればヒットとみなします。", "Hit Rate": "ヒット率", @@ -2299,6 +2358,7 @@ "Important": "重要", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "JSONでは外側のキーがユーザーグループ、内側のキーが課金グループです。以下の例は、vip ユーザーが standard として課金されると 0.8、premium として課金されると 0.3 を意味します。", "In Progress": "処理中", + "In the last 24 hours, RAPI saved you about {{amount}}": "過去24時間でRAPIにより約{{amount}}節約できました", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "ビジュアルエディタでは「追加表示」と「非表示」として表示されます。JSONでは +:(または接頭辞なし)でグループを追加し、-: で削除します。", "In:": "入力:", "incident": "件", @@ -2413,6 +2473,7 @@ "Just now": "たった今", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "ページ表示時に利用ログを走査せず、安定した累計節約額を保持します。", "Keep affinity when channel is disabled": "チャネル無効時にアフィニティを保持", "Keep enabled if you need to proxy requests for different upstream accounts.": "異なる上流アカウントのリクエストをプロキシする必要がある場合は有効にしたままにしてください。", "Keep enough balance before production traffic": "本番トラフィック前に十分な残高を確保", @@ -2437,6 +2498,8 @@ "Language preference saved": "言語設定を保存しました", "Language Preferences": "言語設定", "Language preferences sync across your signed-in devices and affect API error messages.": "言語設定はログイン中のすべてのデバイスで同期され、API のエラーメッセージ言語にも反映されます。", + "Last 24 hours": "過去 24 時間", + "Last 24h savings estimate": "過去24時間の節約見積もり", "Last 24h usage": "直近24時間の使用量", "Last 30 days uptime": "直近 30 日の稼働率", "Last active {{time}} · Expires {{expires}}": "最終利用 {{time}} · 有効期限 {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "以下", "Less Than or Equal": "以下", "License": "ライセンス", + "Lifetime savings": "累計節約額", + "Lifetime savings counted so far": "現在までに集計した累計節約額", + "Lifetime savings counted so far: {{amount}}": "現在までの累計節約額:{{amount}}", "Light": "ライト", "Lightning Fast": "超高速", "Limit period": "制限期間", "Limit Reached": "上限に達しました", + "Limit the date range of each savings summary query.": "節約サマリーの各クエリの日付範囲を制限します。", + "Limit the number of usage logs scanned per summary.": "サマリーごとに走査する使用ログ数を制限します。", "Limit which models can be used with this key": "このキーで使用できるモデルを制限する", "Limited": "制限", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "トークン固有の Auto スナップショットだけを制限します。グローバル Auto の継承には上限がありません。", @@ -2565,6 +2633,7 @@ "Manage Bindings": "バインド管理", "Manage catalog visibility and pricing.": "カタログの表示と価格設定を管理。", "Manage custom OAuth providers for user authentication": "ユーザー認証用のカスタムOAuthプロバイダーの管理", + "Manage in JSON": "JSON で管理", "Manage Keys": "キーの管理", "Manage local models for:": "次のローカルモデルを管理します。", "Manage multi-key status and configuration for this channel": "このチャネルのマルチキーのステータスと構成を管理する", @@ -2596,6 +2665,7 @@ "Match Value": "マッチ値", "Match Value (optional)": "マッチ値(任意)", "Matched": "一致", + "Matched Model": "一致モデル", "Matched models": "一致モデル", "Matched Tier": "一致した階層", "Matches models not claimed by earlier splits.": "前の分岐で使われていないモデルに一致します。", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "各ユーザーが作成できる最大トークン数。デフォルトは 1000。大きすぎる値はパフォーマンスに影響を与える可能性があります。", "Maximum number of tokens in the response": "レスポンスの最大トークン数", "Maximum quota amount awarded for check-in": "チェックインで付与される最大クォータ量", + "Maximum scanned log rows": "走査するログの最大行数", + "Maximum summary range (days)": "サマリーの最大期間(日)", "Maximum tokens including hidden reasoning tokens": "隠れ推論トークンを含む最大トークン数", "Maximum tokens per response": "1 回の応答あたりの最大トークン数", "Maximum tokens per user": "ユーザーあたりの最大トークン数", @@ -2905,6 +2977,7 @@ "No description available.": "説明はありません。", "No discount tiers configured. Click \"Add discount tier\" to get started.": "割引ティアは設定されていません。「割引ティアを追加」をクリックして開始してください。", "No duplicate keys found": "重複キーが見つかりませんでした", + "No eligible savings records yet": "対象となる節約記録はまだありません", "No enabled tokens available": "有効なトークンがありません", "No encryption": "暗号化なし", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "エンドポイントが設定されていません。JSONモードに切り替えるか、エンドポイントを定義するために行を追加してください。", @@ -2926,6 +2999,7 @@ "No Inviter": "招待者なし", "No keys found": "キーが見つかりません", "No latency data available": "レイテンシデータがありません", + "No lifetime savings records yet": "累計節約額の記録はまだありません", "No log entries matched the selected time.": "選択した時間に一致するログエントリはありません。", "No logs": "ログがありません", "No Logs Found": "ログが見つかりません", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Uptime Kumaグループはまだありません。「グループを追加」をクリックして作成してください。", "No uptime monitoring configured": "アップタイム監視が設定されていません", "No usage logs available. Logs will appear here once API calls are made.": "使用ログはありません。API呼び出し後にログがここに表示されます。", + "No usage records in the selected range": "選択した期間に使用記録はありません", "No user information available": "ユーザー情報はありません", "No user selected": "ユーザーが選択されていません", "No users": "ユーザーなし", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "公式 OpenAI Embeddings", "Official OpenAI Images": "公式 OpenAI Images", "Official OpenAI Responses": "公式 OpenAI Responses", + "Official price confirmation is required while savings estimates are enabled.": "節約額の推定を有効にしている間は、公式料金の確認が必要です。", + "Official price estimate": "公式価格の見積もり", + "Official Price Estimate": "公式価格の見積もり", + "Official Price Updated": "公式価格の更新日時", + "Official price updated {{time}}": "公式価格更新: {{time}}", + "Official price validity (days)": "公式価格の有効期間(日)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "公式料金はモデルマーケットで確認済みの料金スナップショットです。推定結果はコスト比較専用です。", + "Official pricing": "公式価格", + "Official pricing estimate": "公式価格見積もり", "Official Repository": "公式リポジトリ", "Official Sync": "公式同期", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "1つのAPI", "One domain per line": "1行に1つのドメイン", "One domain per line (only used when domain restriction is enabled)": "1行に1つのドメイン (ドメイン制限が有効な場合のみ使用されます)", + "One endpoint, one key, and a clear view of every request.": "1つのエンドポイント、1つのキーで、すべてのリクエストを明確に確認。", "One IP or CIDR range per line": "1行に1つのIPまたはCIDR範囲", "One IP per line (empty for no restriction)": "1行に1つのIP (制限なしの場合は空欄)", "one keyword per line": "1行に1つのキーワード", @@ -3304,6 +3389,11 @@ "Path not set": "パス未設定", "Path Regex (one per line)": "パス正規表現(1行に1つ)", "Path:": "パス:", + "Pause backfill": "再計算を一時停止", + "pause_requested": "一時停止処理中", + "paused": "一時停止中", + "Paused": "一時停止中", + "Pausing": "一時停止処理中", "Pay": "Pay", "Pay with Balance": "残高で支払う", "Pay-as-you-go with real-time usage monitoring": "リアルタイム使用量監視付き従量課金制", @@ -3507,11 +3597,14 @@ "Price estimation description": "ハードウェアタイプ、デプロイ場所、レプリカ数などを設定すると、料金が自動的に計算されます。", "Price ID": "価格 ID", "Price mode (USD per 1M tokens)": "価格モード (100万トークンあたりのUSD)", + "Price overrides": "価格の上書き", "Price summary": "価格概要", "price_xxx": "price_xxx", "Price:": "価格:", "Price: High to Low": "価格:高い順", "Price: Low to High": "価格:低い順", + "Prices frozen at {{time}}": "価格固定日時:{{time}}", + "Prices older than this are excluded from savings estimates.": "この日数より古い価格は節約見積もりから除外されます。", "Prices shown per": "価格表示単位", "Prices synced successfully": "価格が正常に同期されました", "Prices vary by usage tier and request conditions": "価格は利用ティアとリクエスト条件で変動します", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "auto グループのトークンの優先順位。システムは上から順にグループを試します。", "Privacy Policy": "プライバシーポリシー", "Private Deployment URL": "プライベートデプロイメントURL", + "Process between 500 and 5000 usage logs per batch.": "1バッチあたり500~5000件の利用ログを処理します。", "Processing OAuth response...": "OAuth応答を処理中...", "Processing...": "処理中...", "Product": "商品", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "各リクエストごとにプールからランダムにキーを選択", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "現在のランキングデータはプレビュー用のシミュレーションです。バックエンド連携が完了次第、リアルタイム分析データに置き換わります。", "Rankings": "ランキング", + "RAPI has saved you about {{amount}} in total": "RAPIにより累計約{{amount}}節約できました", + "RAPI saved you about {{amount}}": "RAPI が約 {{amount}} 節約しました", "Rate Limit Windows": "レート制限ウィンドウ", "Rate Limited": "レート制限", "Rate Limiting": "レート制限", @@ -3658,6 +3754,7 @@ "Reason:": "理由:", "Reasoning": "理由", "Reasoning Effort": "推論強度", + "Recalculate legacy usage logs": "過去の使用ログを再計算", "Receive Upstream Model Update Notifications": "アップストリームモデル更新通知を受け取る", "Received": "受信済み", "Received amount": "受け取り額", @@ -3732,6 +3829,8 @@ "Reject Reason": "拒否理由", "Release details": "リリース詳細", "Released": "公開日", + "reliability controls": "安定性制御", + "Reload savings data": "節約データを再読み込み", "Relying Party Display Name": "依拠当事者表示名", "Relying Party ID": "依拠当事者ID", "Remaining": "残り", @@ -3790,6 +3889,7 @@ "Request Body Field": "リクエストボディフィールド", "Request Body Memory Cache": "リクエストボディのメモリキャッシュ", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "リクエストボディのパススルーが有効です。リクエストボディは変換なしで直接アップストリームに送信されます。", + "Request completed": "リクエスト完了", "Request conversion": "リクエスト変換", "Request Conversion": "リクエスト変換", "Request Count": "リクエスト数", @@ -3813,6 +3913,7 @@ "Requests": "リクエスト", "Requests (24h)": "リクエスト (24h)", "Requests / 24h": "リクエスト / 24h", + "Requests are routed across available services to improve call stability.": "利用可能なサービスへ自動ルーティングし、呼び出しの安定性を高めます。", "Requests per minute": "1分あたりのリクエスト数", "requests served": "処理されたリクエスト", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "リクエストはこのワーカーに転送されます。末尾のスラッシュは自動的に削除されます。", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "フォローアップ アクション前にジョブの成功を要求", "Require login to view models": "モデルを表示するにはログインを要求する", "Require login to view rankings": "ランキングを表示するにはログインを要求する", + "Require official price confirmation": "公式価格の確認を必須にする", "required": "必須", "Required": "必須", "Required events:": "必須イベント:", "Required provider, authentication, model, and group settings": "必須のプロバイダー、認証、モデル、グループ設定", "Required to expose MjProxy-style image generation to end users.": "エンドユーザーに MjProxy スタイルの画像生成を公開するために必要です。", + "Required while savings estimates are enabled.": "節約額の推定が有効な間は必須です。", "Rerank": "再ランク付け", "Reroll": "やり直し", "Research, analysis, scientific reasoning": "リサーチ・分析・科学的推論", @@ -3880,11 +3983,13 @@ "Restore global Auto": "グローバル Auto に戻す", "Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)", "Result": "結果", + "Resume backfill": "再計算を再開", "Retain last N days": "最新N日間を保持", "Retain last N files": "最新N個のファイルを保持", "Retention days": "保持日数", "Retry": "再試行", "Retry Chain": "リトライチェーン", + "Retry from saved progress": "保存済みの進捗から再試行", "Retry Settings": "再試行設定", "Retry Suggestion": "リトライ提案", "Retry Times": "再試行回数", @@ -3986,6 +4091,7 @@ "Save Preferences": "設定を保存", "Save preview": "保存プレビュー", "Save rate limits": "レート制限を保存", + "Save savings estimate settings": "節約見積もり設定を保存", "Save sensitive words": "敏感な言葉を保存", "Save Settings": "設定を保存", "Save sidebar modules": "サイドバーモジュールを保存", @@ -4000,6 +4106,11 @@ "Save Worker settings": "Worker設定を保存", "Saved successfully": "保存しました", "Saving...": "保存中...", + "Savings data update failed": "節約データを更新できませんでした", + "Savings estimate": "節約見積もり", + "Savings estimate is not enabled": "節約見積もりは有効化されていません", + "Savings lifetime backfill": "累計節約額の履歴再計算", + "Savings rate": "節約率", "Scan QR Code": "QRコードをスキャン", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "公式アカウントのQRコードを読み取り、「验证码」と返信して認証コードを受け取ってください。", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "このQRコードを認証アプリ(Google Authenticator、Microsoft Authenticatorなど)でスキャンしてください。", @@ -4200,13 +4311,21 @@ "Show": "表示", "Show All": "すべて表示", "Show all providers including unbound": "未バインドを含むすべてのプロバイダーを表示", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "ユーザーダッシュボードに累計節約額、カバー率、再計算の進捗を表示します。", + "Show in usage logs": "使用ログに表示", + "Show lifetime savings in wallet": "ウォレットに累計節約額を表示", + "Show lifetime savings on dashboard": "ダッシュボードに累計節約額を表示", + "Show on dashboard": "ダッシュボードに表示", "Show only bound providers": "バインド済みのプロバイダーのみ表示", "Show or hide flow columns": "フロー列の表示・非表示", + "Show password": "パスワードを表示", "Show preview": "プレビューを表示", "Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。", + "Show request-level savings estimates in usage logs.": "使用ログにリクエスト単位の節約見積もりを表示します。", "Show sensitive data": "機密データを表示", "Show setup guide": "セットアップガイドを表示", "Show source": "ソースを表示", + "Show the savings summary and trend on the user dashboard.": "ユーザーダッシュボードに節約のサマリーと推移を表示します。", "Show token usage statistics in the UI": "UIでトークン使用統計を表示", "Showcase core capabilities with demo credentials and limited access.": "デモ用の認証情報と制限付きアクセスでコア機能を紹介します。", "Showing": "表示", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "パスキーでサインインしました", "Signed out": "サインアウトしました", "Significant outages detected": "大規模な障害を検出", + "Signing in...": "ログイン中...", "Signing you in with {{provider}}": "{{provider}} でサインイン中", "SiliconFlow": "SiliconFlow", "Simple": "シンプル", "Simple mode only returns message; status code and error type use system defaults.": "シンプルモードはメッセージのみ返します。ステータスコードとエラータイプはシステムデフォルトを使用します。", "Simple mode: prune objects by type, e.g. redacted_thinking.": "シンプルモード:typeでオブジェクトを削除(例:redacted_thinking)。", + "Since {{date}} · {{coverage}} coverage": "{{date}}以降 · カバー率 {{coverage}}", "Single Key": "単一キー", "Site & Branding": "サイトとブランド", "Site Key": "サイトキー", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "失敗時にリトライしない", "Skip SMTP TLS certificate verification": "SMTP TLS証明書の検証をスキップ", "Skip to Main": "メインコンテンツへスキップ", + "Skipped: {{count}}": "スキップ:{{count}}", "Slug": "スラッグ", "Slug can only contain letters, numbers, hyphens, and underscores": "スラッグには英数字、ハイフン、アンダースコアのみ使用できます", "Slug is required": "スラッグは必須です", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite はすべてのデータを単一ファイルに保存します。コンテナで実行する場合は、ファイルが永続化されていることを確認してください。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF保護", + "Stable model calls": "安定したモデル呼び出し", "stale": "期限切れ", "Standard": "標準", "Standard price": "標準価格", "Start": "開始", "Start a conversation to see messages here": "会話を開始すると、ここにメッセージが表示されます", "Start a playground chat": "Playground でチャットを開始", + "Start calling supported models with RAPI": "RAPIで対応モデルを呼び出す", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "法人を設立せずに世界中で決済を受け付けられます。個人開発者、OPC 個人事業主、スタートアップ向けに設計されています。Waffo Pancake は Merchant of Record として、消費税、請求書、サブスクリプション管理、返金、チャージバックなど、グローバル決済のコンプライアンス負担を引き受けます。個人開発者はコンプライアンスではなくプロダクトに集中しながら素早くローンチできます。数分でオンボーディングし、1 つのプロンプトから完全な統合まで進められます。", "Start for free with generous limits. No credit card required.": "豊富な無料枠で始められます。クレジットカードは不要です。", + "Start historical backfill": "履歴再計算を開始", "Start Time": "開始時間", + "Start with the familiar OpenAI-compatible workflow.": "使い慣れたOpenAI互換の手順ですぐに開始できます。", "Started": "起動時刻", "STARTTLS": "STARTTLS", "Static page describing the platform.": "プラットフォームを説明する静的ページ。", @@ -4379,6 +4505,7 @@ "Super Large": "極大", "Support for high concurrency with automatic load balancing": "自動ロードバランシングによる高並行性のサポート", "Supported Applications": "サポートされているアプリケーション", + "supported billing models": "対応する課金モデル", "Supported Imagine Models": "対応Imagineモデル", "Supported modalities": "サポートされるモダリティ", "Supported parameters": "対応パラメータ", @@ -4411,6 +4538,8 @@ "System Behavior": "システムの動作", "System data statistics": "システムデータ統計", "System default": "システムデフォルト", + "System historical data counting is paused": "システムの過去データ集計は一時停止中です", + "System historical data is being counted": "システムの過去データを集計中です", "System Info": "システム情報", "System Information": "システム情報", "System initialized successfully! Redirecting…": "システムが正常に初期化されました!リダイレクト中…", @@ -4457,6 +4586,7 @@ "Task logs": "タスクログ", "Task Logs": "タスクログ", "Tasks currently pending or running.": "現在待機中または実行中のタスクです。", + "Tasks currently pending, running, or paused.": "待機中、実行中、または一時停止中のタスクです。", "Team Collaboration": "チームコラボレーション", "Technical Support": "テクニカルサポート", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "同じ vip ユーザーによる3回の呼び出し。1回の基本価格を 10 とします:", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "3つのグループ。上書きマトリクスには1つのセルだけ値が入っています(ハイライト表示)。", "Three steps to get started": "3ステップで始める", + "Three steps to your first model request": "最初のモデルリクエストまで3ステップ", "Throughput": "スループット", "Throughput by group": "グループ別スループット", "Throughput short": "TPS", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "有効なログインセッション数が上限に達しました。すでにログイン済みの端末で「ログインセッション」を開き、「他のセッションからログアウト」を使用して取り消してください。ログイン済み端末を利用できない場合は、パスワードをリセットしてすべてのセッションからログアウトしてください。", "Too many files. Some were not added.": "ファイルが多すぎます。一部は追加されませんでした。", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "最近作成されたログインセッションが多すぎます。ローリングウィンドウが経過してから、もう一度お試しください。", + "Too many records to summarize": "記録が多すぎるため集計できません", "Too many requests": "リクエストが多すぎます", "Tool / function declarations the model may call": "モデルが呼び出せるツール / 関数の宣言", "Tool identifier": "ツールID", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "残高への振替", "Translation": "翻訳", "Transparent Billing": "透明性のある請求", + "Treat local model marketplace prices as official reference prices.": "ローカルのモデル広場の価格を公式の参照価格として扱います。", "Trend": "トレンド", "Trending down": "下降中", "Trending up": "上昇中", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "ログインセッションを読み込めません", "Unable to load rankings": "ランキングを読み込めません", "Unable to load rankings data": "ランキングデータを読み込めません", + "Unable to load savings trend": "節約推移を読み込めません", "Unable to open chat": "チャットを開けません", "Unable to parse structured pricing": "構造化された価格を解析できません", "Unable to prepare chat link. Please ensure you have an enabled API key.": "チャットリンクを準備できません。有効な API キーが設定されていることを確認してください。", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "テキストとともに画像入力を理解", "Unexpected release payload": "予期しないリリースデータ", "Unified API Gateway for": "統合APIゲートウェイ -", + "Unified model API service": "統合モデルAPIサービス", "Unique identifier for this group.": "このグループの一意の識別子。", "Unit price (local currency / USD)": "単価 (現地通貨 / USD)", "Unit price (USD)": "単価 (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "ベンダーを更新しました", "Updated channel {{name}} (ID: {{id}})": "チャネル {{name}} を更新しました(ID: {{id}})", "Updated daily": "毎日更新", + "Updated savings official price setting": "節約用の公式価格設定を更新しました", "Updated successfully": "正常に更新されました", "Updated system setting {{key}}": "システム設定 {{key}} を更新しました", "Updated user {{username}} (ID: {{id}})": "ユーザー {{username}} を更新しました(ID: {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "URL は必須です", "URL to your logo image (optional)": "ロゴ画像のURL (オプション)", "Usage": "使用量", + "Usage Analysis": "使用状況分析", "Usage at a glance": "使用状況の概要", "Usage guide": "使用ガイド", "Usage logs": "使用ログ", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "外部ツールを利用して機能を拡張", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "このチャンネルで利用可能なリセット回数を1回使用します。確認後にのみリセット要求を送信します。", "Use one available reset credit to refresh the current Codex usage windows.": "利用可能なリセット回数を1回使用して、現在の Codex 使用量ウィンドウを更新します。", + "Use one compatible endpoint to access supported models without changing SDKs.": "既存SDKを変えずに、1つの互換エンドポイントから対応モデルへアクセスできます。", "Use our unified OpenAI-compatible endpoint in your applications": "アプリケーションでOpenAI互換の統一エンドポイントを使用", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "チャネルキーを表示する前に、Passkey または 2FA で本人確認を行ってください。", "Use Passkey to sign in without entering your password.": "パスワードを入力せずにサインインするには、パスキーを使用してください。", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "vip グループのユーザーが premium として課金されるときの倍率は", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "ユーザーにはユーザー選択可のグループだけが表示されます。選択不可グループも管理者は割り当てできます。", "uses": "使用回数", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "モデル広場のローカル公式価格をデフォルトで使用します。official_prices は例外の上書きにのみ必要です。", "Using the complete global Auto order ({{count}} groups)": "グローバル Auto の全順序を使用中({{count}} グループ)", "Validity": "有効期間", "Validity Period": "有効期間", @@ -5077,6 +5216,7 @@ "View mode": "表示モード", "View model statistics and charts": "モデルの統計とグラフを表示", "View Pricing": "価格を見る", + "View savings trend": "節約推移を表示", "View the complete details for this": "この", "View the complete details for this log entry": "このログエントリの完全な詳細を表示", "View the complete error message and details": "エラーメッセージと詳細を表示", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "worker インスタンスは master 専用のバックグラウンドタスクを実行しません。", "Worker Proxy": "Workerプロキシ", "Worker URL": "ワーカーURL", + "Workspace": "ワークスペース", "Workspaces": "ワークスペース", "Write value to the target field": "ターゲットフィールドに値を書き込む", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "未保存の変更があります。離れてもよろしいですか?", "You Pay": "お支払い額", "You save": "節約額", + "You saved about {{amount}}": "約 {{amount}} 節約しました", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "デプロイ、運用、課金行為に起因する法的責任を理解し、独立して負います。", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "このコンプライアンス注意事項はリスク通知にすぎず、法的助言、コンプライアンス審査の結論、または本システム利用の合法性の保証ではないことを理解しています。実際の事業状況に応じて、専門の法律またはコンプライアンス担当者に相談してください。", "You will be redirected to Telegram to complete the binding process.": "バインドプロセスを完了するためにTelegramにリダイレクトされます。", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 853dc874d463..95c5da996b3c 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} IP", "{{count}} log entries removed.": "Удалено {{count}} записей журнала.", "{{count}} minutes ago": "{{count}} минут назад", + "{{count}} model price overrides": "Переопределений цен моделей: {{count}}", "{{count}} models": "моделей: {{count}}", "{{count}} months ago": "{{count}} месяцев назад", "{{count}} override": "{{count}} переопределений", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} групп Uptime Kuma будут удалены из списка.", "{{count}} vendors": "поставщиков: {{count}}", "{{count}} weeks ago": "{{count}} недель назад", + "{{coverage}} coverage": "Покрытие {{coverage}}", "{{field}} updated to {{value}}": "{{field}} обновлено на {{value}}", "{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} обновлено на {{value}} для тега: {{tag}}", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "{{modality}} поддерживается", "{{n}} model(s) selected": "Выбрано моделей: {{n}}", "{{processed}} of {{total}} log entries processed.": "Обработано {{processed}} из {{total}} записей журнала.", + "{{processed}} of {{total}} usage logs processed": "Обработано журналов: {{processed}} из {{total}}", "{{success}} succeeded, {{failed}} failed": "{{success}} успешно, {{failed}} с ошибкой", "{{target}} test failed": "Тест {{target}} не выполнен", "{{target}} test succeeded": "Тест {{target}} успешно выполнен", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "Единый экран для ключей, баланса, маршрутов и состояния сервиса.", "About": "О проекте", "About {{days}} days left": "Примерно {{days}} дней", + "About historical savings estimates": "Об оценке экономии за прошлые периоды", + "About official pricing estimates": "Об оценке по официальным тарифам", "Accept Unpriced Models": "Принимать модели без цены", "Accepts a JSON array of model identifiers that support the Imagine API.": "Принимает JSON-массив идентификаторов моделей, поддерживающих Imagine API.", "Accepts comma-separated status codes and inclusive ranges.": "Принимает коды статуса, разделенные запятыми, и включающие диапазоны.", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Получите доступ к огромному выбору моделей через стандартный, единый протокол API. Развивайте приложения ИИ, управляйте цифровыми активами и соединяйте будущее.", "Access Denied Message": "Сообщение об отказе в доступе", "Access Forbidden": "Доступ запрещен", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "Подключайтесь к разным модельным сервисам через один совместимый API. Используйте единый ключ и контролируйте расход, баланс и запросы от разработки до продакшена.", "Access Policy (JSON)": "Политика доступа (JSON)", "Access previous conversations and start new ones.": "Доступ к предыдущим разговорам и начало новых.", "Access Token": "Токен доступа", "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey", + "Account": "Учётная запись", + "Account & Security": "Учётная запись и безопасность", "Account Binding Management": "Управление привязкой аккаунта", "Account Bindings": "Привязки аккаунта", "Account created! Please sign in": "Аккаунт создан! Пожалуйста, войдите в систему", @@ -152,6 +160,7 @@ "Active Tasks": "Активные задачи", "active users": "активных пользователей", "Actual Amount": "Фактическая сумма", + "Actual Cost": "Фактическая стоимость", "Actual Model": "Фактическая модель", "Actual Model:": "Фактическая модель:", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Адаптировать запросы с суффиксом `-thinking` к собственному режиму размышления Anthropic, сохраняя предсказуемость биллинга.", @@ -212,6 +221,7 @@ "Add split": "Добавить ветку", "Add subscription": "Добавить подписку", "Add tags...": "Добавить теги...", + "Add the frozen cumulative savings amount to the wallet summary.": "Добавить зафиксированную накопленную экономию в сводку кошелька.", "Add tier": "Добавить уровень", "Add time condition": "Добавить условие по времени", "Add time rule group": "Добавить группу правил по времени", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "После включения план будет отображаться пользователям. Продолжить?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "После аннулирования подписка будет немедленно деактивирована. Исторические записи не затронуты. Продолжить?", "Agent ID *": "Идентификатор агента *", + "Aggregate new usage into a frozen lifetime savings total.": "Учитывать новое использование в зафиксированной общей сумме экономии за всё время.", "Aggregate tokens delivered across the platform": "Совокупное количество токенов, доставленных платформой", "Aggregate traffic across every category": "Совокупный трафик по всем категориям", "Aggregated across enabled groups": "Агрегировано по включённым группам", @@ -342,6 +353,7 @@ "Allowed Ports": "Разрешенные порты", "Already have an account?": "Уже есть аккаунт?", "Always matches (default tier).": "Всегда совпадает (уровень по умолчанию).", + "Ambiguous ClickHouse rows skipped: {{count}}": "Пропущено неоднозначных строк ClickHouse: {{count}}", "Amount": "Сумма", "Amount cannot be changed when editing.": "Количество нельзя изменить при редактировании.", "Amount discount": "Скидка на сумму", @@ -531,6 +543,7 @@ "Available Models": "Доступные модели", "Available reset credits": "Доступные сбросы лимита", "Available Rewards": "Доступные награды", + "available service channels": "доступных каналов сервиса", "Average latency": "Средняя задержка", "Average latency, TTFT, and success rate by group": "Средняя задержка, TTFT и доля успешных запросов по группам", "Average latency, TTFT, TPS, and success rate": "Средняя задержка, TTFT, TPS и доля успешных запросов", @@ -551,6 +564,7 @@ "Back to login": "Вернуться к входу", "Back to Models": "Вернуться к моделям", "Backed up": "Резервная копия создана", + "Backfill running": "Пересчет выполняется", "Background job tracker for queued work.": "Отслеживатель фоновых заданий для задач в очереди.", "Backup Code": "Резервный код", "Backup code must be in format XXXX-XXXX": "Резервный код должен быть в формате XXXX-XXXX", @@ -700,6 +714,7 @@ "Cache write price": "Цена записи кэша", "Cached": "Кэш", "Cached input": "Кэшированный ввод", + "Calculate estimated savings using official model prices.": "Рассчитывать оценочную экономию по официальным ценам моделей.", "Calculated price: ${{price}} per 1M tokens": "Расчётная цена: ${{price}} за 1М токенов", "Calculated ratio: {{ratio}}": "Расчётный коэффициент: {{ratio}}", "Calculating...": "Вычисление...", @@ -801,6 +816,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "Китайский", + "Choose a supported model and send your first request.": "Выберите поддерживаемую модель и отправьте первый запрос.", "Choose a username": "Выберите имя пользователя", "Choose an amount and payment method": "Выберите сумму и способ оплаты", "Choose and order the groups this API key will try.": "Выберите и упорядочьте группы, которые будет использовать этот API-ключ.", @@ -847,6 +863,7 @@ "Clear search": "Очистить поиск", "Clear selection": "Снять выделение", "Clear selection (Escape)": "Снять выделение (Escape)", + "Clear usage and balance": "Очистить использование и баланс", "Cleared": "Очищено", "Cleared {{bindingType}} binding for user {{username}}": "Привязка {{bindingType}} пользователя {{username}} удалена", "Cleared all models": "Все модели очищены", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "Настроить модель, кэширование и групповые коэффициенты, используемые для выставления счетов", "Configure monitoring status page groups for the dashboard": "Настроить группы страниц состояния мониторинга для панели управления", "Configure NODE_NAME": "Настроить NODE_NAME", + "Configure official pricing snapshots for user savings estimates.": "Настройте официальные ценовые снимки для оценки экономии пользователей.", "Configure per-model ratio for image inputs or outputs.": "Настроить коэффициент для каждой модели для ввода или вывода изображений.", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Настройте стоимость единицы на инструмент ($/1K вызовов). Для моделей с оплатой за запрос доп. плата за инструменты не взимается.", "Configure pricing ratios for a specific model.": "Настроить коэффициенты ценообразования для конкретной модели.", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "Подтвердить аннулирование", "Confirm log cleanup": "Подтвердить очистку логов", "Confirm log file cleanup?": "Подтвердить очистку файлов журналов?", + "Confirm marketplace pricing as official": "Подтвердить цены каталога как официальные", "Confirm New Password": "Подтвердить новый пароль", "Confirm password": "Подтвердить пароль", "Confirm Payment": "Подтвердить оплату", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "Преобразовать reasoning_content в тег в content", "Convert string to lowercase": "Преобразовать строку в нижний регистр", "Convert string to uppercase": "Преобразовать строку в верхний регистр", + "Converted at 1 USD = {{rate}} CNY": "Пересчёт по курсу 1 USD = {{rate}} CNY", "Converter": "Конвертер", "Converter does not match incoming path": "Конвертер не соответствует входящему пути", "Converter is not registered": "Конвертер не зарегистрирован", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "Стоимость = 10 × 0,8 = 8", "Cost = 10 × 1.0 = 10": "Стоимость = 10 × 1,0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Стоимость = цена модели × этот единственный коэффициент. Другие настройки групп в формуле не участвуют.", + "Cost comparison": "Сравнение затрат", "Cost in USD per request, regardless of tokens used.": "Стоимость в долларах США за запрос, независимо от использованных токенов.", "Cost Tracking": "Отслеживание затрат", "Count must be between {{min}} and {{max}}": "Количество должно быть от {{min}} до {{max}}", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "Учтено на данный момент · покрытие {{coverage}} · пересчет {{progress}}", + "Coverage": "Покрытие", + "Covered request actual cost": "Фактическая стоимость охваченных запросов", + "Covered requests": "Охваченные запросы", "Coze": "Coze", "CPU": "ЦП", "CPU Threshold (%)": "Порог CPU (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "Создавайте правила переопределения параметров запроса с помощью визуального редактора или необработанного JSON.", "Create request parameter override rules without editing raw JSON.": "Создавайте правила переопределения параметров запроса без редактирования raw JSON.", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "Создавайте многократно используемые пакеты моделей, тегов, конечных точек и групп пользователей для ускорения настройки в других частях консоли.", + "Create separate keys for your projects and keep credentials under your control.": "Создавайте отдельные ключи для проектов и сохраняйте контроль над учетными данными.", "Create succeeded": "Успешно создано", "Create Vendor": "Создать поставщика", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "Создайте свою первую группу для повторного использования выбранных моделей, тегов или конечных точек в любом месте панели управления.", @@ -1187,6 +1213,8 @@ "Currency": "Валюта", "Currency & Display": "Валюта и отображение", "Current": "Текущий", + "Current account cost comparison": "Сравнение затрат текущего аккаунта", + "Current account only": "Только текущий аккаунт", "Current Balance": "Текущий баланс", "Current Billing": "Текущие счета", "Current Cache Size": "Текущий размер кэша", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "Включить {{parameter}}", "Enable 2FA": "Включить 2FA", "Enable All": "Включить все", + "Enable and save lifetime savings before starting a backfill.": "Включите и сохраните настройку накопленной экономии перед запуском пересчёта.", "Enable check-in feature": "Включить функцию прибытия", "Enable Data Dashboard": "Включить панель данных", "Enable demo mode with limited functionality": "Включить демонстрационный режим с ограниченной функциональностью", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "Включите, если это корпоративный аккаунт OpenRouter со специальным форматом ответа", "Enable io.net deployments": "Включить развертывания io.net", "Enable io.net model deployment service in console": "Включить сервис развертывания моделей io.net в консоли", + "Enable lifetime savings": "Включить учёт экономии за всё время", "Enable LinuxDO OAuth": "Включить LinuxDO OAuth", "Enable model performance metrics": "Включить метрики производительности моделей", "Enable OIDC": "Включить OIDC", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "Включить мониторинг производительности", "Enable rate limiting": "Включить ограничение скорости", "Enable Request Passthrough": "Включить сквозную передачу запросов", + "Enable savings estimates": "Включить оценку экономии", "Enable selected channels": "Включить выбранные каналы", "Enable selected models": "Включить выбранные модели", "Enable SSL/TLS": "Включить SSL/TLS", @@ -1727,11 +1758,18 @@ "Error Message (required)": "Сообщение об ошибке (обязательно)", "Error parsing response data": "Ошибка при разборе данных ответа", "Error Type (optional)": "Тип ошибки (необязательно)", + "Estimate historical logs without a saved official price snapshot.": "Оценивать исторические журналы без сохранённого снимка официальных цен.", "Estimated cost": "Примерная стоимость", + "Estimated from official pricing": "Оценено по официальным тарифам", + "Estimated from official public pricing": "Оценка по официальным публичным тарифам", "Estimated quota cost": "Ориентир стоимости квоты", + "Estimated savings": "Расчётная экономия", + "Estimated Savings": "Расчетная экономия", + "Estimated: {{count}}": "Оценено: {{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Каждое имя группы из таблицы тарифов используется в двух местах: у пользователя (группа пользователя, назначается администратором) и у токена (группа токена, выбирается при создании). Один набор имён — две разные роли.", "Every other device will lose access immediately. This device will remain signed in.": "Все остальные устройства немедленно потеряют доступ. Это устройство останется в системе.", "Everything configured for this group, in one place.": "Все настройки этой группы в одном месте.", + "Everything you need to start calling models": "Все необходимое для начала работы с моделями", "Exact": "Точное", "Exact Match": "Точное совпадение", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Только точное совпадение с учетом регистра. Префиксы, регулярные выражения и подстановки * не поддерживаются.", @@ -1744,6 +1782,7 @@ "Excellent": "Отлично", "Exchange rate is required": "Требуется курс обмена", "Exchange rate must be greater than 0": "Курс обмена должен быть больше 0", + "Exclude prices that have not been confirmed as official.": "Исключать цены, которые не были подтверждены как официальные.", "Execute code in a sandbox during the response": "Выполнять код в песочнице во время ответа", "Executor": "Исполнитель", "Exhausted": "Исчерпано", @@ -1871,6 +1910,7 @@ "Failed to load users": "Не удалось загрузить пользователей", "Failed to parse group items": "Не удалось разобрать элементы группы", "Failed to parse JSON file: {{name}}": "Не удалось проанализировать файл JSON: {{name}}", + "Failed to pause historical backfill": "Не удалось приостановить исторический пересчет", "Failed to query balance": "Не удалось запросить баланс", "Failed to refresh cache stats": "Не удалось обновить статистику кэша", "Failed to refresh credential": "Не удалось обновить учетные данные", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "Не удалось сбросить коэффициенты модели", "Failed to reset Passkey": "Не удалось сбросить Passkey", "Failed to reset usage": "Не удалось сбросить использование", + "Failed to resume historical backfill": "Не удалось возобновить исторический пересчет", + "Failed to retry historical backfill": "Не удалось повторить исторический пересчет", "Failed to save": "Не удалось сохранить", "Failed to save announcements": "Не удалось сохранить объявления", "Failed to save API info": "Не удалось сохранить информацию API", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "Не удалось начать вход через {{provider}}", "Failed to start Discord login": "Не удалось начать вход через Discord", "Failed to start GitHub login": "Не удалось начать вход через GitHub", + "Failed to start historical backfill": "Не удалось запустить исторический пересчет", "Failed to start LinuxDO login": "Не удалось начать вход через LinuxDO", "Failed to start OIDC login": "Не удалось начать вход через OIDC", "Failed to start Passkey login": "Не удалось начать вход с Passkey", @@ -2067,6 +2110,7 @@ "Frames per second": "Кадров в секунду", "Free": "Свободно", "Free: {{free}} / Total: {{total}}": "Свободно: {{free}} / Всего: {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "Зафиксировать текущие официальные цены и курс, затем рассчитать накопленную экономию по существующим журналам.", "Frequency Penalty": "Штраф за частоту", "Friendly name to identify this channel": "Дружественное имя для идентификации этого канала", "From Address": "Отправитель", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "Скрыта от {{group}}", "Hide": "Скрыть", "Hide API key": "Скрыть API ключ", + "Hide password": "Скрыть пароль", "Hide sensitive data": "Скрыть конфиденциальные данные", "Hide setup guide": "Скрыть руководство по настройке", "High Performance": "Высокая производительность", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "Я добровольно принимаю риски для стабильности системы, включая серьёзные тайм-ауты клиента и возможный сбой сервиса, и несу ответственность за возникшую очередь запросов или недоступность сервиса.", "High-risk status code retry risk disclaimer": "### ⚠️ Операция высокого риска: предупреждение и отказ от ответственности при повторах для кодов 504/524\n\nПо умолчанию проект не повторяет запросы при кодах `400` (неверный запрос), `504` (тайм-аут шлюза) и `524` (истекло время ожидания). Коды 504 и 524 обычно означают, что **запрос успешно дошёл до вышестоящего ИИ-сервиса и обработка на его стороне уже началась, но соединение закрылось из-за слишком долгой обработки вышестоящим сервисом**. Обычно это указывает на узкое место именно вышестоящего сервиса.\n\nВключение перенаправления или повторов для таких кодов тайм-аута — **операция чрезвычайно высокого риска**. Перед включением внимательно прочитайте и поймите следующие последствия:\n\n#### 1. Основные риски (прочитайте внимательно)\n\n1. 💸 Двойное или многократное списание: большинство вышестоящих ИИ-провайдеров **всё равно взимают плату** за запросы, обработка которых началась, но была прервана сетевым тайм-аутом (504/524). Повтор отправляет новый запрос вышестоящему сервису и может привести к **двойному или многократному списанию**.\n2. ⏳ Серьёзный тайм-аут клиента: если запрос уже завершился по тайм-ауту, повторы могут многократно увеличить общую задержку и вызвать неприемлемое ожидание у конечного клиента.\n3. 💥 Очередь запросов и сбой сервиса: принудительные повторы дольше занимают потоки и соединения. При высокой нагрузке это может создать серьёзную **очередь запросов**, исчерпать ресурсы, вызвать каскадный отказ и остановить прокси-сервис.\n\n#### 2. Принятие рисков\n\nЕсли вы всё же включаете эту функцию, вы подтверждаете следующее:", "Higher priority channels are selected first": "Каналы с более высоким приоритетом выбираются первыми", + "Historical backfill": "Исторический пересчет", + "Historical backfill batch size": "Размер пакета исторического пересчета", + "Historical estimates": "Исторические оценки", + "Historical rebuilds": "Пересчёты истории", + "Historical requests recalculated at current official prices: {{count}}": "Количество исторических запросов, пересчитанных по текущим официальным ценам: {{count}}", + "Historical savings backfill failed": "Исторический пересчет экономии завершился ошибкой", + "Historical savings backfill failed; results are incomplete.": "Пересчет исторической экономии завершился ошибкой; результаты неполные.", + "Historical savings backfill is already active": "Исторический пересчет экономии уже выполняется", + "Historical savings backfill pause requested": "Запрошена приостановка исторического пересчета экономии", + "Historical savings backfill resumed": "Исторический пересчет экономии возобновлен", + "Historical savings backfill retry started": "Исторический пересчет продолжен с сохраненного прогресса", + "Historical savings backfill started": "Исторический пересчет экономии запущен", "Historical Usage": "История использования", + "Historical usage has not been backfilled": "Историческое использование еще не пересчитано", + "Historical usage is recalculated using current official prices": "Прошлое использование пересчитано по текущим официальным ценам", "History of MjProxy-style image tasks.": "История задач генерации изображений в стиле MjProxy.", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "Критерий попадания: если в usage есть кэшированные токены, это считается попаданием.", "Hit Rate": "Частота попаданий", @@ -2299,6 +2358,7 @@ "Important": "Важно", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "В JSON внешний ключ — группа пользователя, внутренний — тарифная группа. Пример ниже означает: пользователи vip платят 0,8 по standard и 0,3 по premium.", "In Progress": "Выполняется", + "In the last 24 hours, RAPI saved you about {{amount}}": "За последние 24 часа RAPI сэкономил вам около {{amount}}", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "В визуальном редакторе это отображается как «Дополнительно видимая» и «Скрыта». В JSON префикс +: (или его отсутствие) добавляет группу, а -: удаляет её.", "In:": "Вх:", "incident": "инцидент", @@ -2413,6 +2473,7 @@ "Just now": "Только что", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "Хранить стабильную накопленную экономию без сканирования журналов при открытии страницы.", "Keep affinity when channel is disabled": "Сохранять привязку при отключении канала", "Keep enabled if you need to proxy requests for different upstream accounts.": "Оставьте включённым, если нужно проксировать запросы для разных upstream-аккаунтов.", "Keep enough balance before production traffic": "Поддерживайте достаточный баланс перед рабочим трафиком", @@ -2437,6 +2498,8 @@ "Language preference saved": "Языковая настройка сохранена", "Language Preferences": "Языковые настройки", "Language preferences sync across your signed-in devices and affect API error messages.": "Языковые настройки синхронизируются на всех ваших устройствах после входа и влияют на язык сообщений об ошибках API.", + "Last 24 hours": "Последние 24 часа", + "Last 24h savings estimate": "Оценка экономии за 24 часа", "Last 24h usage": "Расход за 24ч", "Last 30 days uptime": "Доступность за 30 дней", "Last active {{time}} · Expires {{expires}}": "Последняя активность: {{time}} · Истекает: {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "Меньше или равно", "Less Than or Equal": "Меньше или равно", "License": "Лицензия", + "Lifetime savings": "Накопленная экономия", + "Lifetime savings counted so far": "Накопленная экономия на данный момент", + "Lifetime savings counted so far: {{amount}}": "Накопленная экономия на данный момент: {{amount}}", "Light": "Светлая", "Lightning Fast": "Молниеносно быстро", "Limit period": "Период ограничения", "Limit Reached": "Достигнут лимит", + "Limit the date range of each savings summary query.": "Ограничить диапазон дат каждого запроса сводки экономии.", + "Limit the number of usage logs scanned per summary.": "Ограничить число журналов использования, проверяемых для одной сводки.", "Limit which models can be used with this key": "Ограничить модели, которые могут быть использованы с этим ключом", "Limited": "Ограничено", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Ограничивает только снимки Auto для отдельных токенов. Глобальное наследование Auto не ограничено.", @@ -2565,6 +2633,7 @@ "Manage Bindings": "Управление привязками", "Manage catalog visibility and pricing.": "Управление видимостью каталога и ценообразованием.", "Manage custom OAuth providers for user authentication": "Управление пользовательскими поставщиками OAuth для аутентификации пользователей", + "Manage in JSON": "Управлять в JSON", "Manage Keys": "Управление ключами", "Manage local models for:": "Управление локальными моделями для:", "Manage multi-key status and configuration for this channel": "Управление статусом и конфигурацией нескольких ключей для этого канала", @@ -2596,6 +2665,7 @@ "Match Value": "Значение сопоставления", "Match Value (optional)": "Значение сопоставления (необязательно)", "Matched": "Совпадение", + "Matched Model": "Сопоставленная модель", "Matched models": "Модели для сопоставления", "Matched Tier": "Подходящий уровень", "Matches models not claimed by earlier splits.": "Совпадает с моделями, не занятыми предыдущими ветками.", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Максимальное количество токенов, которое может создать каждый пользователь. По умолчанию 1000. Слишком большое значение может повлиять на производительность.", "Maximum number of tokens in the response": "Максимальное число токенов в ответе", "Maximum quota amount awarded for check-in": "Максимальная сумма квоты, присуждаемая за регистрацию", + "Maximum scanned log rows": "Максимум проверяемых строк журнала", + "Maximum summary range (days)": "Максимальный период сводки (дни)", "Maximum tokens including hidden reasoning tokens": "Максимум токенов с учётом скрытых reasoning-токенов", "Maximum tokens per response": "Максимум токенов на ответ", "Maximum tokens per user": "Максимальное количество токенов на пользователя", @@ -2905,6 +2977,7 @@ "No description available.": "Описание отсутствует.", "No discount tiers configured. Click \"Add discount tier\" to get started.": "Не настроены уровни скидок. Нажмите \"Добавить уровень скидки\", чтобы начать.", "No duplicate keys found": "Дубликаты ключей не найдены", + "No eligible savings records yet": "Пока нет подходящих записей об экономии", "No enabled tokens available": "Нет доступных активных токенов", "No encryption": "Без шифрования", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "Конечные точки не настроены. Переключитесь в режим JSON или добавьте строки для определения конечных точек.", @@ -2926,6 +2999,7 @@ "No Inviter": "Нет пригласившего", "No keys found": "Ключи не найдены", "No latency data available": "Данные о задержке недоступны", + "No lifetime savings records yet": "Данных о накопленной экономии пока нет", "No log entries matched the selected time.": "Нет записей журнала, соответствующих выбранному времени.", "No logs": "Нет логов", "No Logs Found": "Логи не найдены", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Пока нет групп Uptime Kuma. Нажмите \"Добавить группу\", чтобы создать одну.", "No uptime monitoring configured": "Мониторинг доступности не настроен", "No usage logs available. Logs will appear here once API calls are made.": "Нет логов использования. Они появятся после вызовов API.", + "No usage records in the selected range": "За выбранный период нет данных об использовании", "No user information available": "Нет данных пользователя", "No user selected": "Пользователь не выбран", "No users": "Нет пользователей", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "Официальные OpenAI Embeddings", "Official OpenAI Images": "Официальные OpenAI Images", "Official OpenAI Responses": "Официальный OpenAI Responses", + "Official price confirmation is required while savings estimates are enabled.": "При включенной оценке экономии требуется подтверждение официальных тарифов.", + "Official price estimate": "Оценка по официальной цене", + "Official Price Estimate": "Оценка по официальной цене", + "Official Price Updated": "Официальная цена обновлена", + "Official price updated {{time}}": "Официальная цена обновлена {{time}}", + "Official price validity (days)": "Срок действия официальной цены (дни)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "Официальные тарифы взяты из подтвержденных снимков цен каталога моделей; оценки предназначены только для сравнения затрат.", + "Official pricing": "Официальные цены", + "Official pricing estimate": "Оценка по официальным тарифам", "Official Repository": "Официальный репозиторий", "Official Sync": "Официальная синхронизация", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "Один API", "One domain per line": "Один домен на строку", "One domain per line (only used when domain restriction is enabled)": "Один домен на строку (используется только при включении ограничения домена)", + "One endpoint, one key, and a clear view of every request.": "Один endpoint, один ключ и полная видимость каждого запроса.", "One IP or CIDR range per line": "Один IP или диапазон CIDR на строку", "One IP per line (empty for no restriction)": "Один IP на строку (пусто для отсутствия ограничений)", "one keyword per line": "одно ключевое слово на строку", @@ -3304,6 +3389,11 @@ "Path not set": "Путь не задан", "Path Regex (one per line)": "Регулярное выражение пути (по одному на строку)", "Path:": "Путь:", + "Pause backfill": "Приостановить пересчет", + "pause_requested": "приостановка", + "paused": "приостановлено", + "Paused": "Приостановлено", + "Pausing": "Приостановка", "Pay": "Pay", "Pay with Balance": "Оплатить балансом", "Pay-as-you-go with real-time usage monitoring": "Оплата по мере использования с мониторингом в реальном времени", @@ -3507,11 +3597,14 @@ "Price estimation description": "После настройки типа оборудования, места размещения, количества реплик и т.д. стоимость будет рассчитана автоматически.", "Price ID": "ID цены", "Price mode (USD per 1M tokens)": "Режим ценообразования (USD за 1 млн токенов)", + "Price overrides": "Переопределения цен", "Price summary": "Сводка цен", "price_xxx": "price_xxx", "Price:": "Цена:", "Price: High to Low": "Цена: от высокой к низкой", "Price: Low to High": "Цена: от низкой к высокой", + "Prices frozen at {{time}}": "Цены зафиксированы {{time}}", + "Prices older than this are excluded from savings estimates.": "Более старые цены исключаются из расчёта экономии.", "Prices shown per": "Цены указаны за", "Prices synced successfully": "Цены успешно синхронизированы", "Prices vary by usage tier and request conditions": "Цена зависит от уровня использования и условий запроса", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "Порядок приоритета для токенов группы auto. Система перебирает группы сверху вниз.", "Privacy Policy": "Политика конфиденциальности", "Private Deployment URL": "URL частного развертывания", + "Process between 500 and 5000 usage logs per batch.": "Обрабатывать от 500 до 5000 журналов за пакет.", "Processing OAuth response...": "Обработка ответа OAuth...", "Processing...": "Обработка...", "Product": "Продукт", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "Случайно выбирать ключ из пула для каждого запроса", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "Сейчас данные рейтинга смоделированы для превью; после внедрения бэкенда они будут заменены реальной аналитикой.", "Rankings": "Рейтинги", + "RAPI has saved you about {{amount}} in total": "RAPI сэкономил вам всего около {{amount}}", + "RAPI saved you about {{amount}}": "RAPI сэкономил вам около {{amount}}", "Rate Limit Windows": "Окна ограничения скорости", "Rate Limited": "Ограничение частоты", "Rate Limiting": "Ограничение скорости", @@ -3658,6 +3754,7 @@ "Reason:": "Причина:", "Reasoning": "Обоснование", "Reasoning Effort": "Интенсивность рассуждения", + "Recalculate legacy usage logs": "Пересчитать устаревшие журналы использования", "Receive Upstream Model Update Notifications": "Получать уведомления об обновлениях вышестоящих моделей", "Received": "Получено", "Received amount": "Полученная сумма", @@ -3732,6 +3829,8 @@ "Reject Reason": "Причина отклонения", "Release details": "Детали релиза", "Released": "Выпущено", + "reliability controls": "механизмов надежности", + "Reload savings data": "Обновить данные об экономии", "Relying Party Display Name": "Отображаемое имя проверяющей стороны", "Relying Party ID": "Идентификатор проверяющей стороны", "Remaining": "Остаток", @@ -3790,6 +3889,7 @@ "Request Body Field": "Поле тела запроса", "Request Body Memory Cache": "Кэш памяти тела запроса", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "Проброс тела запроса включён. Тело запроса будет отправлено напрямую без конвертации.", + "Request completed": "Запрос выполнен", "Request conversion": "Преобразование запроса", "Request Conversion": "Конвертация запроса", "Request Count": "Количество запросов", @@ -3813,6 +3913,7 @@ "Requests": "Запросы", "Requests (24h)": "Запросы (24 ч)", "Requests / 24h": "Запросы / 24 ч", + "Requests are routed across available services to improve call stability.": "Запросы распределяются между доступными сервисами для повышения стабильности.", "Requests per minute": "Запросов в минуту", "requests served": "обслуженных запросов", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Запросы будут перенаправлены этому воркеру. Конечные слеши удаляются автоматически.", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "Требовать успеха задания перед последующими действиями", "Require login to view models": "Требовать вход для просмотра моделей", "Require login to view rankings": "Требовать вход для просмотра рейтингов", + "Require official price confirmation": "Требовать подтверждения официальной цены", "required": "обязателен", "Required": "Обязательно", "Required events:": "Обязательные события:", "Required provider, authentication, model, and group settings": "Обязательные настройки провайдера, аутентификации, моделей и групп", "Required to expose MjProxy-style image generation to end users.": "Необходимо для предоставления генерации изображений в стиле MjProxy конечным пользователям.", + "Required while savings estimates are enabled.": "Обязательно при включенной оценке экономии.", "Rerank": "Переранжировать", "Reroll": "Повторить", "Research, analysis, scientific reasoning": "Исследования, анализ, научные рассуждения", @@ -3880,11 +3983,13 @@ "Restore global Auto": "Восстановить глобальный Auto", "Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)", "Result": "Результат", + "Resume backfill": "Возобновить пересчет", "Retain last N days": "Хранить последние N дней", "Retain last N files": "Хранить последние N файлов", "Retention days": "Дней хранения", "Retry": "Повторить попытку", "Retry Chain": "Цепочка повторов", + "Retry from saved progress": "Повторить с сохраненного прогресса", "Retry Settings": "Настройки повторов", "Retry Suggestion": "Рекомендация по повтору", "Retry Times": "Количество повторных попыток", @@ -3986,6 +4091,7 @@ "Save Preferences": "Сохранить настройки", "Save preview": "Предпросмотр сохранения", "Save rate limits": "Сохранить лимиты скорости", + "Save savings estimate settings": "Сохранить настройки оценки экономии", "Save sensitive words": "Сохранить чувствительные слова", "Save Settings": "Сохранить настройки", "Save sidebar modules": "Сохранить модули боковой панели", @@ -4000,6 +4106,11 @@ "Save Worker settings": "Сохранить настройки Worker", "Saved successfully": "Сохранено успешно", "Saving...": "Сохранение...", + "Savings data update failed": "Не удалось обновить данные об экономии", + "Savings estimate": "Оценка экономии", + "Savings estimate is not enabled": "Оценка экономии не включена", + "Savings lifetime backfill": "Пересчет накопленной экономии", + "Savings rate": "Доля экономии", "Scan QR Code": "Сканировать QR-код", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "Отсканируйте QR-код, откройте официальный аккаунт и ответьте «验证码», чтобы получить код подтверждения.", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Отсканируйте этот QR-код с помощью вашего приложения-аутентификатора (Google Authenticator, Microsoft Authenticator и т.д.)", @@ -4200,13 +4311,21 @@ "Show": "Показать", "Show All": "Показать все", "Show all providers including unbound": "Показать всех провайдеров (включая непривязанные)", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "Показывать накопленную экономию, покрытие и ход пересчета на панели пользователя.", + "Show in usage logs": "Показывать в журналах использования", + "Show lifetime savings in wallet": "Показывать накопленную экономию в кошельке", + "Show lifetime savings on dashboard": "Показывать накопленную экономию на панели", + "Show on dashboard": "Показывать на панели", "Show only bound providers": "Показать только привязанных провайдеров", "Show or hide flow columns": "Показать или скрыть столбцы потока", + "Show password": "Показать пароль", "Show preview": "Показать предпросмотр", "Show prices in currency instead of quota.": "Показывать цены в валюте вместо квоты.", + "Show request-level savings estimates in usage logs.": "Показывать оценку экономии для каждого запроса в журналах использования.", "Show sensitive data": "Показать конфиденциальные данные", "Show setup guide": "Показать руководство по настройке", "Show source": "Показать исходный текст", + "Show the savings summary and trend on the user dashboard.": "Показывать сводку и динамику экономии на панели пользователя.", "Show token usage statistics in the UI": "Показывать статистику использования токенов в пользовательском интерфейсе", "Showcase core capabilities with demo credentials and limited access.": "Демонстрация основных возможностей с демо-учётными данными и ограниченным доступом.", "Showing": "Отображать", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "Вошли с помощью Passkey", "Signed out": "Выход выполнен", "Significant outages detected": "Обнаружены серьёзные сбои", + "Signing in...": "Выполняется вход...", "Signing you in with {{provider}}": "Входим через {{provider}}", "SiliconFlow": "SiliconFlow", "Simple": "Простой", "Simple mode only returns message; status code and error type use system defaults.": "Простой режим возвращает только сообщение; код статуса и тип ошибки используют системные значения по умолчанию.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Простой режим: очистка объектов по типу, например redacted_thinking.", + "Since {{date}} · {{coverage}} coverage": "С {{date}} · покрытие {{coverage}}", "Single Key": "Одиночный ключ", "Site & Branding": "Сайт и брендинг", "Site Key": "Ключ сайта", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "Не повторять при ошибке", "Skip SMTP TLS certificate verification": "Пропустить проверку TLS-сертификата SMTP", "Skip to Main": "Перейти к основному содержимому", + "Skipped: {{count}}": "Пропущено: {{count}}", "Slug": "Идентификатор", "Slug can only contain letters, numbers, hyphens, and underscores": "Slug может содержать только буквы, цифры, дефисы и подчёркивания", "Slug is required": "Slug обязателен", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite хранит все данные в одном файле. Убедитесь, что файл сохраняется при работе в контейнерах.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "Защита от SSRF", + "Stable model calls": "Стабильные вызовы моделей", "stale": "устарел", "Standard": "Стандартный", "Standard price": "Стандартная цена", "Start": "Начало", "Start a conversation to see messages here": "Начните разговор, чтобы увидеть сообщения здесь", "Start a playground chat": "Начните чат в Playground", + "Start calling supported models with RAPI": "Начните вызывать поддерживаемые модели через RAPI", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Начните принимать платежи по всему миру без регистрации компании. Подходит для независимых разработчиков, индивидуальных предпринимателей OPC и стартапов. Waffo Pancake выступает как Merchant of Record и берет на себя комплаенс глобального приема платежей: потребительские налоги, выставление счетов, управление подписками, возвраты и чарджбеки. Одиночные разработчики могут быстро запуститься и сосредоточиться на продукте, а не на комплаенсе. Подключение за минуты — от одного запроса до полной интеграции.", "Start for free with generous limits. No credit card required.": "Начните бесплатно с щедрыми лимитами. Кредитная карта не требуется.", + "Start historical backfill": "Запустить исторический пересчет", "Start Time": "Время начала", + "Start with the familiar OpenAI-compatible workflow.": "Начните со знакомого OpenAI-совместимого процесса.", "Started": "Запущен", "STARTTLS": "STARTTLS", "Static page describing the platform.": "Статическая страница, описывающая платформу.", @@ -4379,6 +4505,7 @@ "Super Large": "Очень крупная", "Support for high concurrency with automatic load balancing": "Поддержка высокой конкурентности с автоматической балансировкой нагрузки", "Supported Applications": "Поддерживаемые приложения", + "supported billing models": "поддерживаемых моделей тарификации", "Supported Imagine Models": "Поддерживаемые модели Imagine", "Supported modalities": "Поддерживаемые модальности", "Supported parameters": "Поддерживаемые параметры", @@ -4411,6 +4538,8 @@ "System Behavior": "Поведение системы", "System data statistics": "Статистика системных данных", "System default": "По умолчанию", + "System historical data counting is paused": "Системный учет исторических данных приостановлен", + "System historical data is being counted": "Идёт подсчёт исторических данных", "System Info": "Информация о системе", "System Information": "Системная информация", "System initialized successfully! Redirecting…": "Система успешно инициализирована! Перенаправление…", @@ -4457,6 +4586,7 @@ "Task logs": "Журналы задач", "Task Logs": "Журнал задач", "Tasks currently pending or running.": "Задачи, которые ожидают выполнения или выполняются сейчас.", + "Tasks currently pending, running, or paused.": "Задачи в ожидании, выполнении или на паузе.", "Team Collaboration": "Совместная работа в команде", "Technical Support": "Техническая поддержка", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "Три вызова одного пользователя vip. Пусть базовая цена одного вызова равна 10.", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "Три группы; в матрице переопределений заполнена ровно одна ячейка (выделена).", "Three steps to get started": "Три шага для начала работы", + "Three steps to your first model request": "Три шага до первого запроса к модели", "Throughput": "Пропускная способность", "Throughput by group": "Пропускная способность по группам", "Throughput short": "TPS", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Достигнут лимит активных сеансов входа. На устройстве, где вы уже вошли, откройте «Сеансы входа» и выберите «Завершить другие сеансы», чтобы отозвать их. Если доступа к такому устройству нет, сбросьте пароль, чтобы завершить все сеансы.", "Too many files. Some were not added.": "Слишком много файлов. Некоторые не были добавлены.", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "За последнее время создано слишком много сеансов входа. Дождитесь окончания скользящего временного окна и повторите попытку.", + "Too many records to summarize": "Слишком много записей для сводки", "Too many requests": "Слишком много запросов", "Tool / function declarations the model may call": "Объявления инструментов и функций, которые модель может вызывать", "Tool identifier": "Идентификатор инструмента", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "Перевести на баланс", "Translation": "Перевод", "Transparent Billing": "Прозрачная тарификация", + "Treat local model marketplace prices as official reference prices.": "Считать локальные цены каталога моделей официальными справочными ценами.", "Trend": "Тренд", "Trending down": "Падают", "Trending up": "Растут", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "Не удалось загрузить сеансы входа", "Unable to load rankings": "Не удалось загрузить рейтинги", "Unable to load rankings data": "Не удалось загрузить данные рейтингов", + "Unable to load savings trend": "Не удалось загрузить динамику экономии", "Unable to open chat": "Не удалось открыть чат", "Unable to parse structured pricing": "Не удалось разобрать структурированные цены", "Unable to prepare chat link. Please ensure you have an enabled API key.": "Не удается подготовить ссылку для чата. Убедитесь, что у вас есть активированный API-ключ.", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "Понимать изображения наряду с текстом", "Unexpected release payload": "Неожиданный формат данных релиза", "Unified API Gateway for": "Единый API-шлюз для", + "Unified model API service": "Единый сервис API моделей", "Unique identifier for this group.": "Уникальный идентификатор для этой группы.", "Unit price (local currency / USD)": "Цена за единицу (местная валюта / USD)", "Unit price (USD)": "Цена за единицу (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "Обновлён поставщик", "Updated channel {{name}} (ID: {{id}})": "Обновлён канал {{name}} (ID: {{id}})", "Updated daily": "Обновляется ежедневно", + "Updated savings official price setting": "Настройка официальных цен для экономии обновлена", "Updated successfully": "Обновлено успешно", "Updated system setting {{key}}": "Обновлён системный параметр {{key}}", "Updated user {{username}} (ID: {{id}})": "Обновлён пользователь {{username}} (ID: {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "URL обязателен", "URL to your logo image (optional)": "URL изображения вашего логотипа (необязательно)", "Usage": "Использование", + "Usage Analysis": "Анализ использования", "Usage at a glance": "Краткий обзор использования", "Usage guide": "Руководство", "Usage logs": "Журналы использования", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "Использовать внешние инструменты для расширения возможностей", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Для этого канала будет использован один доступный сброс. Запрос отправляется только после подтверждения.", "Use one available reset credit to refresh the current Codex usage windows.": "Использует один доступный сброс, чтобы обновить текущие окна использования Codex.", + "Use one compatible endpoint to access supported models without changing SDKs.": "Получайте доступ к поддерживаемым моделям через один совместимый endpoint без замены SDK.", "Use our unified OpenAI-compatible endpoint in your applications": "Используйте наш единый OpenAI-совместимый эндпоинт в ваших приложениях", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Подтвердите личность с помощью Passkey или 2FA перед просмотром ключа канала.", "Use Passkey to sign in without entering your password.": "Используйте ключ доступа для входа без ввода пароля.", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "Пользователи vip при тарификации по premium платят коэффициент", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Пользователи видят только группы, отмеченные как доступные для выбора. Недоступные для выбора группы всё равно могут назначаться администраторами.", "uses": "использует", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "По умолчанию используются локальные официальные цены из каталога моделей; official_prices нужен только для переопределений.", "Using the complete global Auto order ({{count}} groups)": "Используется полный глобальный порядок Auto (групп: {{count}})", "Validity": "Срок действия", "Validity Period": "Срок действия", @@ -5077,6 +5216,7 @@ "View mode": "Режим отображения", "View model statistics and charts": "Просмотр статистики и графиков моделей", "View Pricing": "Посмотреть цены", + "View savings trend": "Показать динамику экономии", "View the complete details for this": "Просмотр полных деталей этой", "View the complete details for this log entry": "Просмотр полной информации об этой записи журнала", "View the complete error message and details": "Просмотр полного сообщения об ошибке и деталей", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "Экземпляры worker не выполняют фоновые задачи только для master.", "Worker Proxy": "Прокси воркера", "Worker URL": "URL воркера", + "Workspace": "Рабочая область", "Workspaces": "Рабочие пространства", "Write value to the target field": "Записать значение в целевое поле", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "У вас есть несохранённые изменения. Вы уверены, что хотите уйти?", "You Pay": "Вы платите", "You save": "Вы экономите", + "You saved about {{amount}}": "Вы сэкономили около {{amount}}", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "Вы понимаете и самостоятельно несете юридическую ответственность, возникающую из развертывания, эксплуатации и взимания платы.", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Вы понимаете, что это напоминание о соответствии является только уведомлением о рисках и не является юридической консультацией, заключением проверки соответствия или гарантией законности использования этой системы; вам следует обратиться к профессиональным юридическим или комплаенс-консультантам с учетом вашей реальной бизнес-ситуации.", "You will be redirected to Telegram to complete the binding process.": "Вы будете перенаправлены в Telegram для завершения процесса привязки.", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index cdc1a12626e1..a7d4721f7ddc 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} IP", "{{count}} log entries removed.": "Đã xóa {{count}} mục nhật ký.", "{{count}} minutes ago": "{{count}} phút trước", + "{{count}} model price overrides": "{{count}} giá mô hình ghi đè", "{{count}} models": "{{count}} mô hình", "{{count}} months ago": "{{count}} tháng trước", "{{count}} override": "{{count}} ghi đè", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "{{count}} nhóm Uptime Kuma sẽ bị xóa khỏi danh sách.", "{{count}} vendors": "{{count}} nhà cung cấp", "{{count}} weeks ago": "{{count}} tuần trước", + "{{coverage}} coverage": "Mức bao phủ {{coverage}}", "{{field}} updated to {{value}}": "{{field}} đã cập nhật thành {{value}}", "{{field}} updated to {{value}} for tag: {{tag}}": "{{field}} đã cập nhật thành {{value}} cho nhãn: {{tag}}", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "Hỗ trợ {{modality}}", "{{n}} model(s) selected": "Đã chọn {{n}} model", "{{processed}} of {{total}} log entries processed.": "Đã xử lý {{processed}}/{{total}} mục nhật ký.", + "{{processed}} of {{total}} usage logs processed": "Đã xử lý {{processed}} / {{total}} nhật ký sử dụng", "{{success}} succeeded, {{failed}} failed": "{{success}} thành công, {{failed}} thất bại", "{{target}} test failed": "Kiểm tra {{target}} thất bại", "{{target}} test succeeded": "Kiểm tra {{target}} thành công", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "Trang tổng quan tập trung cho khóa, số dư, định tuyến và trạng thái dịch vụ.", "About": "Giới thiệu", "About {{days}} days left": "Còn khoảng {{days}} ngày", + "About historical savings estimates": "Về ước tính tiết kiệm trong quá khứ", + "About official pricing estimates": "Về ước tính theo giá chính thức", "Accept Unpriced Models": "Chấp nhận các Mô hình chưa định giá", "Accepts a JSON array of model identifiers that support the Imagine API.": "Chấp nhận một mảng JSON gồm các mã định danh mô hình hỗ trợ API Imagine.", "Accepts comma-separated status codes and inclusive ranges.": "Chấp nhận mã trạng thái phân cách bằng dấu phẩy và phạm vi bao gồm.", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "Truy cập số lượng lớn các mô hình thông qua giao thức API chuẩn hóa và thống nhất. Thúc đẩy các ứng dụng AI, quản lý tài sản kỹ thuật số và kết nối tương lai.", "Access Denied Message": "Thông báo từ chối truy cập", "Access Forbidden": "Truy cập bị cấm", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "Truy cập nhiều dịch vụ mô hình qua một API tương thích. Dùng một khóa duy nhất và theo dõi rõ mức sử dụng, số dư cùng yêu cầu từ phát triển đến vận hành.", "Access Policy (JSON)": "Chính sách truy cập (JSON)", "Access previous conversations and start new ones.": "Truy cập các cuộc trò chuyện trước đó và bắt đầu các cuộc trò chuyện mới.", "Access Token": "Token truy cập", "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey", + "Account": "Tài khoản", + "Account & Security": "Tài khoản và bảo mật", "Account Binding Management": "Quản lý liên kết tài khoản", "Account Bindings": "Liên kết tài khoản", "Account created! Please sign in": "Tài khoản đã được tạo! Vui lòng đăng nhập", @@ -152,6 +160,7 @@ "Active Tasks": "Tác vụ đang hoạt động", "active users": "Người dùng tích cực", "Actual Amount": "Số tiền thực tế", + "Actual Cost": "Chi phí thực tế", "Actual Model": "Mô hình thực tế", "Actual Model:": "Mô hình thực tế:", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "Điều chỉnh các yêu cầu có hậu tố `-thinking` sang hành vi suy luận gốc của Anthropic trong khi vẫn giữ tính phí dễ dự đoán.", @@ -212,6 +221,7 @@ "Add split": "Thêm nhánh", "Add subscription": "Thêm đăng ký", "Add tags...": "Thêm thẻ...", + "Add the frozen cumulative savings amount to the wallet summary.": "Thêm số tiền tiết kiệm tích lũy đã cố định vào tổng quan ví.", "Add tier": "Thêm bậc", "Add time condition": "Thêm điều kiện thời gian", "Add time rule group": "Thêm nhóm quy tắc theo thời gian", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "Sau khi kích hoạt, gói sẽ được hiển thị cho người dùng. Tiếp tục?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "Sau khi vô hiệu hóa, đăng ký này sẽ bị hủy kích hoạt ngay lập tức. Hồ sơ lịch sử không bị ảnh hưởng. Tiếp tục?", "Agent ID *": "Mã đại lý *", + "Aggregate new usage into a frozen lifetime savings total.": "Cộng mức sử dụng mới vào tổng tiết kiệm tích lũy đã cố định.", "Aggregate tokens delivered across the platform": "Tổng số token được phục vụ trên toàn hệ thống", "Aggregate traffic across every category": "Tổng hợp lưu lượng tất cả danh mục", "Aggregated across enabled groups": "Tổng hợp các nhóm đang bật", @@ -342,6 +353,7 @@ "Allowed Ports": "Cổng được phép", "Already have an account?": "Đã có tài khoản?", "Always matches (default tier).": "Luôn khớp (bậc mặc định).", + "Ambiguous ClickHouse rows skipped: {{count}}": "Đã bỏ qua các hàng ClickHouse không thể phân biệt: {{count}}", "Amount": "Số lượng", "Amount cannot be changed when editing.": "Số tiền không thể thay đổi khi chỉnh sửa.", "Amount discount": "Số tiền giảm giá", @@ -531,6 +543,7 @@ "Available Models": "Mô hình khả dụng", "Available reset credits": "Lượt đặt lại khả dụng", "Available Rewards": "Phần thưởng hiện có", + "available service channels": "kênh dịch vụ khả dụng", "Average latency": "Độ trễ trung bình", "Average latency, TTFT, and success rate by group": "Độ trễ trung bình, TTFT và tỷ lệ thành công theo nhóm", "Average latency, TTFT, TPS, and success rate": "Độ trễ trung bình, TTFT, TPS và tỷ lệ thành công", @@ -551,6 +564,7 @@ "Back to login": "Quay lại đăng nhập", "Back to Models": "Quay lại Mô hình", "Backed up": "Đã sao lưu", + "Backfill running": "Đang tính lại", "Background job tracker for queued work.": "Theo dõi công việc nền cho công việc chờ xử lý.", "Backup Code": "Mã dự phòng", "Backup code must be in format XXXX-XXXX": "Mã dự phòng phải có định dạng XXXX-XXXX", @@ -700,6 +714,7 @@ "Cache write price": "Giá ghi cache", "Cached": "Đã cache", "Cached input": "Đầu vào đã cache", + "Calculate estimated savings using official model prices.": "Tính khoản tiết kiệm ước tính theo giá chính thức của mô hình.", "Calculated price: ${{price}} per 1M tokens": "Giá tính toán: ${{price}} mỗi 1M token", "Calculated ratio: {{ratio}}": "Tỷ lệ tính toán: {{ratio}}", "Calculating...": "Đang tính...", @@ -801,6 +816,7 @@ "checkout.session.completed": "thanh toán.phiên.hoàn thành", "checkout.session.expired": "Phiên thanh toán đã hết hạn.", "Chinese": "Tiếng Trung", + "Choose a supported model and send your first request.": "Chọn một mô hình được hỗ trợ và gửi yêu cầu đầu tiên.", "Choose a username": "Chọn tên người dùng", "Choose an amount and payment method": "Chọn số tiền và phương thức thanh toán", "Choose and order the groups this API key will try.": "Chọn và sắp xếp các nhóm mà khóa API này sẽ thử.", @@ -847,6 +863,7 @@ "Clear search": "Xóa tìm kiếm", "Clear selection": "Bỏ chọn", "Clear selection (Escape)": "Bỏ chọn (Escape)", + "Clear usage and balance": "Mức sử dụng và số dư minh bạch", "Cleared": "Đã xóa", "Cleared {{bindingType}} binding for user {{username}}": "Đã xóa liên kết {{bindingType}} của người dùng {{username}}", "Cleared all models": "Đã xóa tất cả các mô hình", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "Cấu hình mô hình, bộ nhớ đệm và tỷ lệ nhóm được sử dụng để tính phí.", "Configure monitoring status page groups for the dashboard": "Cấu hình các nhóm trang trạng thái giám sát cho bảng điều khiển", "Configure NODE_NAME": "Cấu hình NODE_NAME", + "Configure official pricing snapshots for user savings estimates.": "Cấu hình bản chụp giá chính thức dùng cho ước tính tiết kiệm của người dùng.", "Configure per-model ratio for image inputs or outputs.": "Cấu hình tỷ lệ theo mô hình cho đầu vào hoặc đầu ra hình ảnh.", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "Cấu hình giá theo từng công cụ ($/1K lần gọi). Mô hình tính phí theo request không phát sinh thêm phí công cụ.", "Configure pricing ratios for a specific model.": "Cấu hình tỷ lệ định giá cho một mô hình cụ thể.", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "Xác nhận vô hiệu hóa", "Confirm log cleanup": "Xác nhận dọn dẹp nhật ký", "Confirm log file cleanup?": "Xác nhận dọn dẹp tệp nhật ký?", + "Confirm marketplace pricing as official": "Xác nhận giá trong kho mô hình là giá chính thức", "Confirm New Password": "Xác nhận mật khẩu mới", "Confirm password": "Xác nhận mật khẩu", "Confirm Payment": "Xác nhận Thanh toán", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "Chuyển đổi reasoning_content thành thẻ trong nội dung", "Convert string to lowercase": "Chuyển chuỗi sang chữ thường", "Convert string to uppercase": "Chuyển chuỗi sang chữ hoa", + "Converted at 1 USD = {{rate}} CNY": "Quy đổi theo 1 USD = {{rate}} CNY", "Converter": "Bộ chuyển đổi", "Converter does not match incoming path": "Bộ chuyển đổi không khớp path đầu vào", "Converter is not registered": "Bộ chuyển đổi chưa được đăng ký", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "Chi phí = 10 × 0.8 = 8", "Cost = 10 × 1.0 = 10": "Chi phí = 10 × 1.0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "Chi phí = giá mô hình × đúng một hệ số đó. Không có mục nào khác trong cài đặt nhóm tham gia công thức.", + "Cost comparison": "So sánh chi phí", "Cost in USD per request, regardless of tokens used.": "Chi phí bằng USD cho mỗi yêu cầu, bất kể số lượng token được sử dụng.", "Cost Tracking": "Theo dõi chi phí", "Count must be between {{min}} and {{max}}": "Số lượng phải nằm trong khoảng từ {{min}} đến {{max}}.", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "Đã thống kê đến hiện tại · độ phủ {{coverage}} · tính lại {{progress}}", + "Coverage": "Mức bao phủ", + "Covered request actual cost": "Chi phí thực tế của yêu cầu được tính", + "Covered requests": "Yêu cầu được tính", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "Ngưỡng CPU (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "Tạo quy tắc ghi đè tham số yêu cầu bằng trình soạn trực quan hoặc JSON thô.", "Create request parameter override rules without editing raw JSON.": "Tạo quy tắc ghi đè tham số yêu cầu mà không cần sửa JSON thô.", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "Tạo các gói có thể tái sử dụng gồm các mô hình, thẻ, điểm cuối và nhóm người dùng để tăng tốc cấu hình ở những nơi khác trong bảng điều khiển.", + "Create separate keys for your projects and keep credentials under your control.": "Tạo khóa riêng cho từng dự án và luôn kiểm soát thông tin xác thực.", "Create succeeded": "Tạo thành công", "Create Vendor": "Tạo Nhà cung cấp", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "Tạo nhóm đầu tiên của bạn để dùng lại các lựa chọn mô hình, thẻ hoặc điểm cuối ở bất cứ đâu trên bảng điều khiển.", @@ -1187,6 +1213,8 @@ "Currency": "Tiền tệ", "Currency & Display": "Tiền tệ & hiển thị", "Current": "Hiện tại", + "Current account cost comparison": "So sánh chi phí tài khoản hiện tại", + "Current account only": "Chỉ tài khoản hiện tại", "Current Balance": "Số Dư Hiện Tại", "Current Billing": "Thanh toán hiện tại", "Current Cache Size": "Kích thước bộ nhớ đệm hiện tại", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "Bật {{parameter}}", "Enable 2FA": "Bật 2FA", "Enable All": "Bật tất cả", + "Enable and save lifetime savings before starting a backfill.": "Hãy bật và lưu tiết kiệm tích lũy trước khi bắt đầu tính lại.", "Enable check-in feature": "Bật tính năng điểm danh", "Enable Data Dashboard": "Kích hoạt Trang tổng quan Dữ liệu", "Enable demo mode with limited functionality": "Bật chế độ demo với chức năng hạn chế", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "Bật nếu đây là tài khoản doanh nghiệp OpenRouter với định dạng phản hồi đặc biệt", "Enable io.net deployments": "Bật triển khai io.net", "Enable io.net model deployment service in console": "Bật dịch vụ triển khai mô hình io.net trong bảng điều khiển", + "Enable lifetime savings": "Bật tiết kiệm tích lũy", "Enable LinuxDO OAuth": "Bật LinuxDO OAuth", "Enable model performance metrics": "Bật chỉ số hiệu năng mô hình", "Enable OIDC": "Bật OIDC", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "Bật giám sát hiệu suất", "Enable rate limiting": "Bật giới hạn tốc độ", "Enable Request Passthrough": "Bật Truyền qua Yêu cầu", + "Enable savings estimates": "Bật ước tính tiết kiệm", "Enable selected channels": "Kích hoạt các kênh đã chọn", "Enable selected models": "Kích hoạt các mô hình đã chọn", "Enable SSL/TLS": "Bật SSL/TLS", @@ -1727,11 +1758,18 @@ "Error Message (required)": "Thông báo lỗi (bắt buộc)", "Error parsing response data": "Lỗi khi phân tích dữ liệu phản hồi", "Error Type (optional)": "Loại lỗi (tùy chọn)", + "Estimate historical logs without a saved official price snapshot.": "Ước tính nhật ký cũ chưa lưu bản chụp giá chính thức.", "Estimated cost": "Chi phí ước tính", + "Estimated from official pricing": "Ước tính theo giá chính thức", + "Estimated from official public pricing": "Ước tính theo giá công khai chính thức", "Estimated quota cost": "Ước tính chi phí hạn mức", + "Estimated savings": "Khoản tiết kiệm ước tính", + "Estimated Savings": "Khoản tiết kiệm ước tính", + "Estimated: {{count}}": "Đã ước tính: {{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "Mỗi tên nhóm trong bảng định giá có thể dùng ở hai nơi: trên người dùng (nhóm người dùng, do quản trị viên gán) và trên token (nhóm token, chọn khi tạo token). Cùng một bộ tên, hai vai trò khác nhau.", "Every other device will lose access immediately. This device will remain signed in.": "Mọi thiết bị khác sẽ mất quyền truy cập ngay lập tức. Thiết bị này vẫn duy trì đăng nhập.", "Everything configured for this group, in one place.": "Toàn bộ cấu hình của nhóm này, tại một nơi.", + "Everything you need to start calling models": "Mọi thứ cần thiết để bắt đầu gọi mô hình", "Exact": "Chính xác", "Exact Match": "Khớp chính xác", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Chỉ khớp chính xác và có phân biệt hoa thường. Không hỗ trợ tiền tố, regex hoặc ký tự đại diện *.", @@ -1744,6 +1782,7 @@ "Excellent": "Tuyệt vời", "Exchange rate is required": "Cần có tỷ giá", "Exchange rate must be greater than 0": "Tỷ giá phải lớn hơn 0", + "Exclude prices that have not been confirmed as official.": "Loại trừ giá chưa được xác nhận là chính thức.", "Execute code in a sandbox during the response": "Thực thi mã trong sandbox trong quá trình phản hồi", "Executor": "Trình thực thi", "Exhausted": "Đã cạn kiệt", @@ -1871,6 +1910,7 @@ "Failed to load users": "Không thể tải người dùng", "Failed to parse group items": "Thất bại khi phân tích các mục nhóm", "Failed to parse JSON file: {{name}}": "Không thể phân tích cú pháp tệp JSON: {{name}}", + "Failed to pause historical backfill": "Không thể tạm dừng tính lại dữ liệu lịch sử", "Failed to query balance": "Không thể truy vấn số dư", "Failed to refresh cache stats": "Không thể làm mới thống kê bộ nhớ đệm", "Failed to refresh credential": "Không thể làm mới thông tin xác thực", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "Không thể đặt lại tỷ lệ mô hình", "Failed to reset Passkey": "Không thể đặt lại Passkey", "Failed to reset usage": "Không thể đặt lại mức dùng", + "Failed to resume historical backfill": "Không thể tiếp tục tính lại dữ liệu lịch sử", + "Failed to retry historical backfill": "Không thể thử lại tính toán dữ liệu lịch sử", "Failed to save": "Lưu thất bại", "Failed to save announcements": "Không thể lưu thông báo", "Failed to save API info": "Không thể lưu thông tin API", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "Không thể bắt đầu đăng nhập {{provider}}", "Failed to start Discord login": "Không thể bắt đầu đăng nhập Discord", "Failed to start GitHub login": "Không thể bắt đầu đăng nhập GitHub", + "Failed to start historical backfill": "Không thể bắt đầu tính lại dữ liệu lịch sử", "Failed to start LinuxDO login": "Không thể bắt đầu đăng nhập LinuxDO", "Failed to start OIDC login": "Không thể bắt đầu đăng nhập OIDC", "Failed to start Passkey login": "Không thể bắt đầu đăng nhập Passkey", @@ -2067,6 +2110,7 @@ "Frames per second": "Khung hình / giây", "Free": "Trống", "Free: {{free}} / Total: {{total}}": "Còn trống: {{free}} / Tổng: {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "Cố định giá chính thức và tỷ giá hiện tại, sau đó tính tiết kiệm tích lũy từ nhật ký sử dụng hiện có.", "Frequency Penalty": "Phạt tần suất", "Friendly name to identify this channel": "Tên thân thiện để nhận dạng kênh này", "From Address": "Địa chỉ Người gửi", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "Ẩn khỏi {{group}}", "Hide": "Ẩn", "Hide API key": "Ẩn khóa API", + "Hide password": "Ẩn mật khẩu", "Hide sensitive data": "Ẩn dữ liệu nhạy cảm", "Hide setup guide": "Ẩn hướng dẫn thiết lập", "High Performance": "Hiệu suất cao", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "Tôi tự nguyện chấp nhận rủi ro về độ ổn định hệ thống, gồm hết thời gian chờ nghiêm trọng ở phía máy khách và khả năng dịch vụ gặp sự cố, đồng thời chịu trách nhiệm về tình trạng tồn đọng yêu cầu hoặc gián đoạn dịch vụ phát sinh.", "High-risk status code retry risk disclaimer": "### ⚠️ Thao tác rủi ro cao: cảnh báo và tuyên bố miễn trừ trách nhiệm khi thử lại mã 504/524\n\nTheo mặc định, dự án không thử lại với mã `400` (yêu cầu không hợp lệ), `504` (gateway hết thời gian chờ) hoặc `524` (đã hết thời gian chờ). Mã 504 và 524 thường có nghĩa là **yêu cầu đã đến dịch vụ AI thượng nguồn thành công và phía thượng nguồn đã bắt đầu xử lý, nhưng kết nối bị đóng vì quá trình xử lý ở thượng nguồn mất quá nhiều thời gian**. Điều này thường cho thấy nút thắt nằm ở dịch vụ thượng nguồn.\n\nBật chuyển hướng hoặc thử lại cho các mã hết thời gian chờ này là một **thao tác có rủi ro cực kỳ cao**. Trước khi bật, bạn phải đọc kỹ và hiểu các hậu quả sau:\n\n#### 1. Rủi ro chính (hãy đọc kỹ)\n\n1. 💸 Tính phí hai lần hoặc nhiều lần: phần lớn nhà cung cấp AI thượng nguồn **vẫn tính phí** cho yêu cầu đã bắt đầu xử lý nhưng bị ngắt do hết thời gian chờ mạng (504/524). Mỗi lần thử lại gửi một yêu cầu hoàn toàn mới đến thượng nguồn và có thể gây **tính phí hai lần hoặc nhiều lần**.\n2. ⏳ Hết thời gian chờ nghiêm trọng ở phía máy khách: khi một yêu cầu đã hết thời gian chờ, việc thử lại có thể làm tổng độ trễ tăng nhiều lần và gây thời gian chờ nghiêm trọng hoặc không thể chấp nhận cho máy khách cuối.\n3. 💥 Tồn đọng yêu cầu và sự cố dịch vụ: buộc thử lại giữ luồng và kết nối lâu hơn. Khi tải cao, điều này có thể gây **tồn đọng yêu cầu** nghiêm trọng, cạn kiệt tài nguyên, phát sinh lỗi dây chuyền và làm dịch vụ proxy ngừng hoạt động.\n\n#### 2. Xác nhận rủi ro\n\nNếu vẫn chọn bật tính năng này, bạn xác nhận tất cả nội dung sau:", "Higher priority channels are selected first": "Các kênh ưu tiên cao hơn được chọn trước tiên", + "Historical backfill": "Tính lại dữ liệu lịch sử", + "Historical backfill batch size": "Kích thước lô tính lại lịch sử", + "Historical estimates": "Ước tính dữ liệu cũ", + "Historical rebuilds": "Lần tính lại dữ liệu cũ", + "Historical requests recalculated at current official prices: {{count}}": "Số yêu cầu cũ đã tính lại theo giá chính thức hiện tại: {{count}}", + "Historical savings backfill failed": "Tính lại tiết kiệm lịch sử thất bại", + "Historical savings backfill failed; results are incomplete.": "Tính lại khoản tiết kiệm trước đây thất bại; kết quả chưa đầy đủ.", + "Historical savings backfill is already active": "Tính lại tiết kiệm lịch sử đang hoạt động", + "Historical savings backfill pause requested": "Đã yêu cầu tạm dừng tính lại tiết kiệm lịch sử", + "Historical savings backfill resumed": "Đã tiếp tục tính lại tiết kiệm lịch sử", + "Historical savings backfill retry started": "Đã tiếp tục tính lại tiết kiệm lịch sử từ tiến độ đã lưu", + "Historical savings backfill started": "Đã bắt đầu tính lại tiết kiệm lịch sử", "Historical Usage": "Lịch sử sử dụng", + "Historical usage has not been backfilled": "Dữ liệu sử dụng trước đây chưa được tính lại", + "Historical usage is recalculated using current official prices": "Mức sử dụng trước đây được tính lại theo giá chính thức hiện tại", "History of MjProxy-style image tasks.": "Lịch sử các tác vụ hình ảnh kiểu MjProxy.", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "Tiêu chí trúng: Nếu cached tokens tồn tại trong usage, được tính là trúng.", "Hit Rate": "Tỷ lệ trúng", @@ -2299,6 +2358,7 @@ "Important": "Quan trọng", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "Trong JSON, khóa ngoài là nhóm người dùng, khóa trong là nhóm tính phí. Ví dụ dưới đây nghĩa là: người dùng vip trả 0.8 khi tính phí theo standard và 0.3 khi theo premium.", "In Progress": "Đang xử lý", + "In the last 24 hours, RAPI saved you about {{amount}}": "Trong 24 giờ qua, RAPI đã giúp bạn tiết kiệm khoảng {{amount}}", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "Trong trình chỉnh sửa trực quan, các quy tắc này hiển thị là «Hiển thị thêm» và «Ẩn». Trong JSON, +: (hoặc không có tiền tố) thêm nhóm và -: xóa nhóm.", "In:": "Vào:", "incident": "sự cố", @@ -2413,6 +2473,7 @@ "Just now": "Vừa nãy", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "Giữ tổng tiết kiệm tích lũy ổn định mà không quét nhật ký khi người dùng mở trang.", "Keep affinity when channel is disabled": "Giữ ưu tiên khi kênh bị tắt", "Keep enabled if you need to proxy requests for different upstream accounts.": "Giữ bật nếu bạn cần proxy yêu cầu cho các tài khoản upstream khác nhau.", "Keep enough balance before production traffic": "Giữ đủ số dư trước khi chạy lưu lượng production", @@ -2437,6 +2498,8 @@ "Language preference saved": "Đã lưu tùy chọn ngôn ngữ", "Language Preferences": "Tùy chọn ngôn ngữ", "Language preferences sync across your signed-in devices and affect API error messages.": "Tùy chọn ngôn ngữ sẽ đồng bộ trên các thiết bị đã đăng nhập và ảnh hưởng đến ngôn ngữ thông báo lỗi API.", + "Last 24 hours": "24 giờ qua", + "Last 24h savings estimate": "Ước tính tiết kiệm 24 giờ qua", "Last 24h usage": "Sử dụng 24h qua", "Last 30 days uptime": "Uptime 30 ngày qua", "Last active {{time}} · Expires {{expires}}": "Hoạt động gần nhất {{time}} · Hết hạn {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "Nhỏ hơn hoặc bằng", "Less Than or Equal": "Nhỏ hơn hoặc bằng", "License": "Giấy phép", + "Lifetime savings": "Tiết kiệm tích lũy", + "Lifetime savings counted so far": "Tiết kiệm tích lũy đã thống kê", + "Lifetime savings counted so far: {{amount}}": "Tiết kiệm tích lũy đã thống kê: {{amount}}", "Light": "Ánh sáng", "Lightning Fast": "Nhanh như chớp", "Limit period": "Thời hiệu", "Limit Reached": "Đã đạt giới hạn", + "Limit the date range of each savings summary query.": "Giới hạn khoảng ngày cho mỗi truy vấn tổng hợp tiết kiệm.", + "Limit the number of usage logs scanned per summary.": "Giới hạn số nhật ký sử dụng được quét cho mỗi bản tổng hợp.", "Limit which models can be used with this key": "Giới hạn các mô hình có thể được sử dụng với khóa này", "Limited": "Giới hạn", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "Chỉ giới hạn cấu hình Auto riêng của token. Việc kế thừa Auto toàn cục không bị giới hạn.", @@ -2565,6 +2633,7 @@ "Manage Bindings": "Quản lý liên kết", "Manage catalog visibility and pricing.": "Quản lý hiển thị danh mục và giá cả.", "Manage custom OAuth providers for user authentication": "Quản lý nhà cung cấp OAuth tùy chỉnh để xác thực người dùng", + "Manage in JSON": "Quản lý bằng JSON", "Manage Keys": "Quản lý Khóa", "Manage local models for:": "Quản lý mô hình cục bộ cho:", "Manage multi-key status and configuration for this channel": "Quản lý trạng thái và cấu hình đa khóa cho kênh này", @@ -2596,6 +2665,7 @@ "Match Value": "Giá trị khớp", "Match Value (optional)": "Giá trị khớp (tùy chọn)", "Matched": "Đã khớp", + "Matched Model": "Mô hình khớp", "Matched models": "Mô hình khớp", "Matched Tier": "Bậc khớp", "Matches models not claimed by earlier splits.": "Khớp các mô hình chưa được nhánh trước nhận.", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Số lượng token tối đa mỗi người dùng có thể tạo. Mặc định là 1000. Đặt quá lớn có thể ảnh hưởng đến hiệu suất.", "Maximum number of tokens in the response": "Số token tối đa trong phản hồi", "Maximum quota amount awarded for check-in": "Số lượng hạn ngạch tối đa được trao cho điểm danh", + "Maximum scanned log rows": "Số dòng nhật ký quét tối đa", + "Maximum summary range (days)": "Khoảng tổng hợp tối đa (ngày)", "Maximum tokens including hidden reasoning tokens": "Số token tối đa bao gồm token suy luận ẩn", "Maximum tokens per response": "Số token tối đa mỗi phản hồi", "Maximum tokens per user": "Số token tối đa trên mỗi người dùng", @@ -2905,6 +2977,7 @@ "No description available.": "Chưa có mô tả.", "No discount tiers configured. Click \"Add discount tier\" to get started.": "Chưa cấu hình cấp chiết khấu nào. Nhấp vào \"Thêm cấp chiết khấu\" để bắt đầu.", "No duplicate keys found": "Không tìm thấy khóa trùng lặp", + "No eligible savings records yet": "Chưa có bản ghi tiết kiệm đủ điều kiện", "No enabled tokens available": "Không có token nào được kích hoạt", "No encryption": "Không mã hóa", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "Chưa cấu hình endpoint nào. Chuyển sang chế độ JSON hoặc thêm hàng để định nghĩa endpoint.", @@ -2926,6 +2999,7 @@ "No Inviter": "Không có người mời", "No keys found": "Không tìm thấy khóa", "No latency data available": "Không có dữ liệu độ trễ", + "No lifetime savings records yet": "Chưa có dữ liệu tiết kiệm tích lũy", "No log entries matched the selected time.": "Không có mục nhật ký nào khớp với thời gian đã chọn.", "No logs": "Không có nhật ký", "No Logs Found": "Không tìm thấy nhật ký", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "Chưa có nhóm Uptime Kuma nào. Nhấp vào \"Thêm nhóm\" để tạo một nhóm.", "No uptime monitoring configured": "Chưa cấu hình giám sát thời gian hoạt động", "No usage logs available. Logs will appear here once API calls are made.": "Chưa có nhật ký sử dụng. Nhật ký sẽ hiển thị sau khi có gọi API.", + "No usage records in the selected range": "Không có dữ liệu sử dụng trong khoảng đã chọn", "No user information available": "Không có thông tin người dùng", "No user selected": "Chưa chọn người dùng", "No users": "Không có người dùng", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "OpenAI Embeddings chính thức", "Official OpenAI Images": "OpenAI Images chính thức", "Official OpenAI Responses": "OpenAI Responses chính thức", + "Official price confirmation is required while savings estimates are enabled.": "Phải xác nhận giá chính thức khi bật ước tính tiết kiệm.", + "Official price estimate": "Ước tính theo giá chính thức", + "Official Price Estimate": "Ước tính theo giá chính thức", + "Official Price Updated": "Giá chính thức đã được cập nhật", + "Official price updated {{time}}": "Giá chính thức được cập nhật lúc {{time}}", + "Official price validity (days)": "Thời hạn giá chính thức (ngày)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "Giá chính thức là bản chụp giá đã xác nhận từ chợ mô hình; kết quả ước tính chỉ dùng để so sánh chi phí.", + "Official pricing": "Giá chính thức", + "Official pricing estimate": "Ước tính giá chính thức", "Official Repository": "Kho lưu trữ chính thức", "Official Sync": "Official sync", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "Một API", "One domain per line": "Một tên miền mỗi dòng", "One domain per line (only used when domain restriction is enabled)": "Mỗi dòng một tên miền (chỉ được sử dụng khi hạn chế tên miền được bật)", + "One endpoint, one key, and a clear view of every request.": "Một endpoint, một khóa và toàn bộ yêu cầu đều rõ ràng.", "One IP or CIDR range per line": "Một IP hoặc dải CIDR mỗi dòng", "One IP per line (empty for no restriction)": "Mỗi IP một dòng (để trống nếu không giới hạn)", "one keyword per line": "Mỗi dòng một từ khóa", @@ -3304,6 +3389,11 @@ "Path not set": "Chưa đặt đường dẫn", "Path Regex (one per line)": "Regex đường dẫn (mỗi dòng một mục)", "Path:": "Đường dẫn:", + "Pause backfill": "Tạm dừng tính lại", + "pause_requested": "đang tạm dừng", + "paused": "đã tạm dừng", + "Paused": "Đã tạm dừng", + "Pausing": "Đang tạm dừng", "Pay": "Pay", "Pay with Balance": "Thanh toán bằng số dư", "Pay-as-you-go with real-time usage monitoring": "Thanh toán theo mức sử dụng với theo dõi mức sử dụng theo thời gian thực", @@ -3507,11 +3597,14 @@ "Price estimation description": "Sau khi hoàn thành loại phần cứng, vị trí triển khai, số lượng bản sao, v.v., giá sẽ được tính toán tự động.", "Price ID": "Mã giá", "Price mode (USD per 1M tokens)": "Chế độ giá (USD mỗi 1 triệu token)", + "Price overrides": "Giá ghi đè", "Price summary": "Tóm tắt giá", "price_xxx": "price_xxx", "Price:": "Giá:", "Price: High to Low": "Giá: Từ cao đến thấp", "Price: Low to High": "Giá: Thấp đến Cao", + "Prices frozen at {{time}}": "Giá được cố định lúc {{time}}", + "Prices older than this are excluded from savings estimates.": "Giá cũ hơn thời hạn này sẽ bị loại khỏi ước tính tiết kiệm.", "Prices shown per": "Giá hiển thị theo", "Prices synced successfully": "Đồng bộ giá thành công", "Prices vary by usage tier and request conditions": "Giá thay đổi theo bậc dùng và điều kiện yêu cầu", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "Thứ tự ưu tiên cho token trong nhóm auto. Hệ thống thử các nhóm từ trên xuống dưới.", "Privacy Policy": "Chính sách quyền riêng tư", "Private Deployment URL": "URL Triển khai Riêng", + "Process between 500 and 5000 usage logs per batch.": "Xử lý từ 500 đến 5000 nhật ký sử dụng mỗi lô.", "Processing OAuth response...": "Đang xử lý phản hồi OAuth...", "Processing...": "Đang xử lý...", "Product": "Sản phẩm", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "Chọn ngẫu nhiên một khóa từ kho cho mỗi yêu cầu", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "Dữ liệu xếp hạng hiện đang được mô phỏng để xem trước và sẽ được thay bằng dữ liệu thực sau khi tích hợp backend.", "Rankings": "Bảng xếp hạng", + "RAPI has saved you about {{amount}} in total": "RAPI đã giúp bạn tiết kiệm tổng cộng khoảng {{amount}}", + "RAPI saved you about {{amount}}": "RAPI đã giúp bạn tiết kiệm khoảng {{amount}}", "Rate Limit Windows": "Cửa sổ giới hạn tốc độ", "Rate Limited": "Giới hạn tốc độ", "Rate Limiting": "Rate limit", @@ -3658,6 +3754,7 @@ "Reason:": "Lý do:", "Reasoning": "Lý luận", "Reasoning Effort": "Cường độ suy luận", + "Recalculate legacy usage logs": "Tính lại nhật ký sử dụng cũ", "Receive Upstream Model Update Notifications": "Nhận thông báo cập nhật mô hình nguồn", "Received": "Đã nhận", "Received amount": "Số tiền đã nhận", @@ -3732,6 +3829,8 @@ "Reject Reason": "Lý do từ chối", "Release details": "Chi tiết phiên bản", "Released": "Phát hành", + "reliability controls": "cơ chế đảm bảo ổn định", + "Reload savings data": "Tải lại dữ liệu tiết kiệm", "Relying Party Display Name": "Tên Hiển Thị của Bên Tin Cậy", "Relying Party ID": "Định danh Bên phụ thuộc", "Remaining": "Còn lại", @@ -3790,6 +3889,7 @@ "Request Body Field": "Trường thân yêu cầu", "Request Body Memory Cache": "Bộ nhớ đệm RAM nội dung yêu cầu", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "Chuyển tiếp body yêu cầu đã được bật. Body yêu cầu sẽ được gửi trực tiếp mà không chuyển đổi.", + "Request completed": "Yêu cầu đã hoàn tất", "Request conversion": "Chuyển đổi yêu cầu", "Request Conversion": "Chuyển đổi yêu cầu", "Request Count": "Number of requests", @@ -3813,6 +3913,7 @@ "Requests": "Yêu cầu", "Requests (24h)": "Yêu cầu (24h)", "Requests / 24h": "Yêu cầu / 24h", + "Requests are routed across available services to improve call stability.": "Yêu cầu được định tuyến qua các dịch vụ khả dụng để tăng độ ổn định.", "Requests per minute": "Yêu cầu mỗi phút", "requests served": "yêu cầu đã phục vụ", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "Các yêu cầu sẽ được chuyển tiếp đến worker này. Dấu gạch chéo ở cuối được tự động loại bỏ.", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "Yêu cầu công việc thành công trước các hành động tiếp theo", "Require login to view models": "Yêu cầu đăng nhập để xem các mô hình", "Require login to view rankings": "Yêu cầu đăng nhập để xem bảng xếp hạng", + "Require official price confirmation": "Yêu cầu xác nhận giá chính thức", "required": "bắt buộc", "Required": "Bắt buộc", "Required events:": "Sự kiện bắt buộc:", "Required provider, authentication, model, and group settings": "Thiết lập bắt buộc về nhà cung cấp, xác thực, mô hình và nhóm", "Required to expose MjProxy-style image generation to end users.": "Cần thiết để cung cấp tính năng tạo hình ảnh kiểu MjProxy cho người dùng cuối.", + "Required while savings estimates are enabled.": "Bắt buộc khi bật ước tính tiết kiệm.", "Rerank": "Re-rank", "Reroll": "Quay lại", "Research, analysis, scientific reasoning": "Nghiên cứu, phân tích, suy luận khoa học", @@ -3880,11 +3983,13 @@ "Restore global Auto": "Khôi phục Auto toàn cục", "Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)", "Result": "Kết quả", + "Resume backfill": "Tiếp tục tính lại", "Retain last N days": "Giữ lại N ngày gần nhất", "Retain last N files": "Giữ lại N tệp gần nhất", "Retention days": "Số ngày lưu giữ", "Retry": "Thử lại", "Retry Chain": "Chuỗi thử lại", + "Retry from saved progress": "Thử lại từ tiến độ đã lưu", "Retry Settings": "Cài đặt thử lại", "Retry Suggestion": "Gợi ý thử lại", "Retry Times": "Số lần thử lại", @@ -3986,6 +4091,7 @@ "Save Preferences": "Lưu tùy chọn", "Save preview": "Xem trước lưu", "Save rate limits": "Lưu giới hạn tốc độ", + "Save savings estimate settings": "Lưu cấu hình ước tính tiết kiệm", "Save sensitive words": "Lưu từ nhạy cảm", "Save Settings": "Lưu Cài đặt", "Save sidebar modules": "Lưu các mô-đun thanh bên", @@ -4000,6 +4106,11 @@ "Save Worker settings": "Lưu cài đặt Worker", "Saved successfully": "Lưu thành công", "Saving...": "Đang lưu...", + "Savings data update failed": "Không thể cập nhật dữ liệu tiết kiệm", + "Savings estimate": "Ước tính tiết kiệm", + "Savings estimate is not enabled": "Ước tính tiết kiệm chưa được bật", + "Savings lifetime backfill": "Tính lại tiết kiệm tích lũy", + "Savings rate": "Tỷ lệ tiết kiệm", "Scan QR Code": "Quét mã QR", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "Quét mã QR để theo dõi tài khoản chính thức, trả lời « 验证码 » để nhận mã xác minh.", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "Quét mã QR này bằng ứng dụng xác thực của bạn (Google Authenticator, Microsoft Authenticator, v.v.)", @@ -4200,13 +4311,21 @@ "Show": "Hiển thị", "Show All": "Hiển thị tất cả", "Show all providers including unbound": "Hiển thị tất cả nhà cung cấp (bao gồm chưa liên kết)", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "Hiển thị tiết kiệm tích lũy, độ phủ và tiến độ tính lại trên bảng điều khiển.", + "Show in usage logs": "Hiển thị trong nhật ký sử dụng", + "Show lifetime savings in wallet": "Hiển thị tiết kiệm tích lũy trong ví", + "Show lifetime savings on dashboard": "Hiển thị tiết kiệm tích lũy trên bảng điều khiển", + "Show on dashboard": "Hiển thị trên bảng điều khiển", "Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết", "Show or hide flow columns": "Hiện hoặc ẩn các cột luồng", + "Show password": "Hiện mật khẩu", "Show preview": "Hiển thị bản xem trước", "Show prices in currency instead of quota.": "Hiển thị giá bằng tiền tệ thay vì hạn ngạch.", + "Show request-level savings estimates in usage logs.": "Hiển thị ước tính tiết kiệm theo từng yêu cầu trong nhật ký sử dụng.", "Show sensitive data": "Hiển thị dữ liệu nhạy cảm", "Show setup guide": "Hiển thị hướng dẫn thiết lập", "Show source": "Hiển thị nguồn", + "Show the savings summary and trend on the user dashboard.": "Hiển thị tổng hợp và xu hướng tiết kiệm trên bảng điều khiển người dùng.", "Show token usage statistics in the UI": "Hiển thị thống kê sử dụng token trong giao diện người dùng", "Showcase core capabilities with demo credentials and limited access.": "Trình diễn các tính năng cốt lõi với thông tin đăng nhập demo và quyền truy cập hạn chế.", "Showing": "Đang hiển thị", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "Đã đăng nhập bằng Passkey", "Signed out": "Đã đăng xuất", "Significant outages detected": "Phát hiện gián đoạn nghiêm trọng", + "Signing in...": "Đang đăng nhập...", "Signing you in with {{provider}}": "Đang đăng nhập bằng {{provider}}", "SiliconFlow": "SiliconFlow", "Simple": "Đơn giản", "Simple mode only returns message; status code and error type use system defaults.": "Chế độ đơn giản chỉ trả về message; mã trạng thái và loại lỗi sử dụng giá trị mặc định.", "Simple mode: prune objects by type, e.g. redacted_thinking.": "Chế độ đơn giản: dọn dẹp đối tượng theo type, ví dụ redacted_thinking.", + "Since {{date}} · {{coverage}} coverage": "Từ {{date}} · độ phủ {{coverage}}", "Single Key": "Khóa đơn", "Site & Branding": "Trang web & thương hiệu", "Site Key": "Khóa trang web", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "Không thử lại khi thất bại", "Skip SMTP TLS certificate verification": "Bỏ qua xác minh chứng chỉ TLS SMTP", "Skip to Main": "Bỏ qua đến nội dung chính", + "Skipped: {{count}}": "Đã bỏ qua: {{count}}", "Slug": "Slug", "Slug can only contain letters, numbers, hyphens, and underscores": "Slug chỉ có thể chứa chữ cái, số, dấu gạch ngang và dấu gạch dưới", "Slug is required": "Slug là bắt buộc", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite lưu trữ tất cả dữ liệu trong một tệp duy nhất. Đảm bảo tệp được lưu trữ lâu dài khi chạy trong container.", "SSL/TLS": "SSL/TLS", "SSRF Protection": "Bảo vệ SSRF", + "Stable model calls": "Lệnh gọi mô hình ổn định", "stale": "mất kết nối", "Standard": "Tiêu chuẩn", "Standard price": "Giá tiêu chuẩn", "Start": "Bắt đầu", "Start a conversation to see messages here": "Bắt đầu một cuộc trò chuyện để xem tin nhắn tại đây", "Start a playground chat": "Bắt đầu cuộc trò chuyện trong playground", + "Start calling supported models with RAPI": "Bắt đầu gọi các mô hình được hỗ trợ bằng RAPI", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "Bắt đầu thu thanh toán toàn cầu mà không cần đăng ký công ty. Dành cho lập trình viên độc lập, chủ sở hữu OPC và startup. Waffo Pancake đóng vai trò Merchant of Record, chịu trách nhiệm tuân thủ cho việc thu thanh toán toàn cầu — thuế tiêu dùng, hóa đơn, quản lý đăng ký, hoàn tiền và tranh chấp thanh toán. Lập trình viên cá nhân có thể ra mắt nhanh và tập trung vào sản phẩm thay vì tuân thủ. Onboard trong vài phút — từ một prompt đến tích hợp hoàn chỉnh.", "Start for free with generous limits. No credit card required.": "Bắt đầu miễn phí với giới hạn hào phóng. Không cần thẻ tín dụng.", + "Start historical backfill": "Bắt đầu tính lại lịch sử", "Start Time": "Thời gian bắt đầu", + "Start with the familiar OpenAI-compatible workflow.": "Bắt đầu với quy trình tương thích OpenAI quen thuộc.", "Started": "Đã khởi động", "STARTTLS": "STARTTLS", "Static page describing the platform.": "Trang tĩnh mô tả nền tảng.", @@ -4379,6 +4505,7 @@ "Super Large": "Rất lớn", "Support for high concurrency with automatic load balancing": "Hỗ trợ đồng thời cao với cân bằng tải tự động", "Supported Applications": "Ứng dụng được hỗ trợ", + "supported billing models": "mô hình tính phí được hỗ trợ", "Supported Imagine Models": "Mô hình Imagine được hỗ trợ", "Supported modalities": "Phương thức hỗ trợ", "Supported parameters": "Tham số hỗ trợ", @@ -4411,6 +4538,8 @@ "System Behavior": "Hành vi hệ thống", "System data statistics": "Thống kê dữ liệu hệ thống", "System default": "Mặc định hệ thống", + "System historical data counting is paused": "Hệ thống đã tạm dừng thống kê dữ liệu trước đây", + "System historical data is being counted": "Hệ thống đang thống kê dữ liệu trước đây", "System Info": "Thông tin hệ thống", "System Information": "Thông tin hệ thống", "System initialized successfully! Redirecting…": "Hệ thống đã được khởi tạo thành công! Đang chuyển hướng…", @@ -4457,6 +4586,7 @@ "Task logs": "Nhật ký tác vụ", "Task Logs": "Nhật ký tác vụ", "Tasks currently pending or running.": "Các tác vụ hiện đang chờ hoặc đang chạy.", + "Tasks currently pending, running, or paused.": "Các tác vụ đang chờ, đang chạy hoặc tạm dừng.", "Team Collaboration": "Teamwork", "Technical Support": "Hỗ trợ kỹ thuật", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "Ba cuộc gọi của cùng một người dùng vip. Giả sử giá cơ bản một cuộc gọi là 10.", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "Ba nhóm; ma trận ghi đè chỉ có đúng một ô được điền (được tô sáng).", "Three steps to get started": "Ba bước để bắt đầu", + "Three steps to your first model request": "Ba bước tới yêu cầu mô hình đầu tiên", "Throughput": "Thông lượng", "Throughput by group": "Thông lượng theo nhóm", "Throughput short": "TPS", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "Đã đạt giới hạn phiên đăng nhập đang hoạt động. Trên một thiết bị đã đăng nhập, hãy mở “Phiên đăng nhập” và dùng “Đăng xuất các phiên khác” để thu hồi chúng. Nếu bạn không thể truy cập thiết bị nào đã đăng nhập, hãy đặt lại mật khẩu để đăng xuất khỏi tất cả phiên.", "Too many files. Some were not added.": "Quá nhiều tệp. Một số không được thêm.", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "Gần đây đã tạo quá nhiều phiên đăng nhập. Vui lòng chờ cửa sổ thời gian trượt kết thúc rồi thử lại.", + "Too many records to summarize": "Quá nhiều bản ghi để tổng hợp", "Too many requests": "Quá nhiều yêu cầu", "Tool / function declarations the model may call": "Khai báo công cụ / hàm mà model có thể gọi", "Tool identifier": "Định danh công cụ", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "Chuyển vào số dư", "Translation": "Dịch thuật", "Transparent Billing": "Thanh toán minh bạch", + "Treat local model marketplace prices as official reference prices.": "Xem giá cục bộ trong kho mô hình là giá tham chiếu chính thức.", "Trend": "Xu hướng", "Trending down": "Đang giảm", "Trending up": "Đang tăng", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "Không thể tải các phiên đăng nhập", "Unable to load rankings": "Không thể tải bảng xếp hạng", "Unable to load rankings data": "Không thể tải dữ liệu bảng xếp hạng", + "Unable to load savings trend": "Không thể tải xu hướng tiết kiệm", "Unable to open chat": "Không thể mở trò chuyện", "Unable to parse structured pricing": "Không thể phân tích giá có cấu trúc", "Unable to prepare chat link. Please ensure you have an enabled API key.": "Không thể chuẩn bị liên kết chat. Vui lòng đảm bảo bạn có khóa API được kích hoạt.", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "Hiểu hình ảnh cùng với văn bản", "Unexpected release payload": "Dữ liệu phiên bản không mong đợi", "Unified API Gateway for": "Cổng API thống nhất cho", + "Unified model API service": "Dịch vụ API mô hình hợp nhất", "Unique identifier for this group.": "Mã định danh duy nhất cho nhóm này.", "Unit price (local currency / USD)": "Đơn giá (tiền tệ địa phương / USD)", "Unit price (USD)": "Đơn giá (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "Đã cập nhật một nhà cung cấp", "Updated channel {{name}} (ID: {{id}})": "Đã cập nhật kênh {{name}} (ID: {{id}})", "Updated daily": "Cập nhật hàng ngày", + "Updated savings official price setting": "Đã cập nhật cấu hình giá chính thức cho ước tính tiết kiệm", "Updated successfully": "Cập nhật thành công", "Updated system setting {{key}}": "Đã cập nhật cài đặt hệ thống {{key}}", "Updated user {{username}} (ID: {{id}})": "Đã cập nhật người dùng {{username}} (ID: {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "URL là bắt buộc", "URL to your logo image (optional)": "URL hình ảnh logo của bạn (tùy chọn)", "Usage": "Sử dụng", + "Usage Analysis": "Phân tích mức sử dụng", "Usage at a glance": "Tổng quan mức dùng", "Usage guide": "Hướng dẫn sử dụng", "Usage logs": "Nhật ký sử dụng", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "Sử dụng công cụ ngoài để mở rộng khả năng", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Sử dụng một lượt đặt lại khả dụng cho kênh này. Yêu cầu chỉ được gửi sau khi xác nhận.", "Use one available reset credit to refresh the current Codex usage windows.": "Sử dụng một lượt đặt lại khả dụng để làm mới các cửa sổ mức dùng Codex hiện tại.", + "Use one compatible endpoint to access supported models without changing SDKs.": "Truy cập các mô hình được hỗ trợ qua một endpoint tương thích mà không cần đổi SDK.", "Use our unified OpenAI-compatible endpoint in your applications": "Sử dụng endpoint thống nhất tương thích OpenAI trong ứng dụng của bạn", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "Hãy dùng Passkey hoặc 2FA để xác nhận danh tính trước khi xem khóa kênh này.", "Use Passkey to sign in without entering your password.": "Sử dụng Khóa truy cập để đăng nhập mà không cần nhập mật khẩu của bạn.", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "Người dùng của vip, khi tính phí theo premium, trả hệ số", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "Người dùng chỉ thấy các nhóm được đánh dấu là có thể chọn. Nhóm không thể chọn vẫn có thể do quản trị viên gán.", "uses": "sử dụng", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "Mặc định dùng giá chính thức cục bộ từ kho mô hình; official_prices chỉ cần để ghi đè ngoại lệ.", "Using the complete global Auto order ({{count}} groups)": "Đang dùng thứ tự Auto toàn cục đầy đủ ({{count}} nhóm)", "Validity": "Hiệu lực", "Validity Period": "Thời hạn hiệu lực", @@ -5077,6 +5216,7 @@ "View mode": "Chế độ xem", "View model statistics and charts": "Xem thống kê và biểu đồ mô hình", "View Pricing": "View price", + "View savings trend": "Xem xu hướng tiết kiệm", "View the complete details for this": "Xem chi tiết đầy đủ của", "View the complete details for this log entry": "Xem chi tiết đầy đủ cho mục nhật ký này", "View the complete error message and details": "Xem toàn bộ thông báo lỗi và chi tiết", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "Phiên bản worker không chạy các tác vụ nền chỉ dành cho master.", "Worker Proxy": "Proxy Nhân viên", "Worker URL": "URL của Worker", + "Workspace": "Không gian làm việc", "Workspaces": "Không gian làm việc", "Write value to the target field": "Ghi giá trị vào trường đích", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "Bạn có thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?", "You Pay": "Bạn thanh toán", "You save": "Bạn tiết kiệm", + "You saved about {{amount}}": "Bạn đã tiết kiệm khoảng {{amount}}", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "Bạn hiểu và tự chịu trách nhiệm pháp lý phát sinh từ việc triển khai, vận hành và thu phí.", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "Bạn hiểu rằng nhắc nhở tuân thủ này chỉ là thông báo rủi ro, không cấu thành tư vấn pháp lý, kết luận rà soát tuân thủ hoặc bảo đảm tính hợp pháp của việc sử dụng hệ thống; bạn nên tham khảo cố vấn pháp lý hoặc tuân thủ chuyên nghiệp dựa trên tình huống kinh doanh thực tế.", "You will be redirected to Telegram to complete the binding process.": "Bạn sẽ được chuyển hướng đến Telegram để hoàn tất quá trình liên kết.", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index f8b5fafd8d2c..30de8b0d37d8 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} 個 IP", "{{count}} log entries removed.": "已刪除 {{count}} 條日誌。", "{{count}} minutes ago": "{{count}} 分鐘前", + "{{count}} model price overrides": "{{count}} 個模型價格覆寫", "{{count}} models": "{{count}} 個模型", "{{count}} months ago": "{{count}} 個月前", "{{count}} override": "{{count}} 個覆蓋", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "將從列表中移除 {{count}} 個 Uptime Kuma 分組。", "{{count}} vendors": "{{count}} 間供應商", "{{count}} weeks ago": "{{count}} 週前", + "{{coverage}} coverage": "覆蓋率 {{coverage}}", "{{field}} updated to {{value}}": "{{field}} 已更新為 {{value}}", "{{field}} updated to {{value}} for tag: {{tag}}": "標籤「{{tag}}」的 {{field}} 已更新為 {{value}}", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "支援 {{modality}}", "{{n}} model(s) selected": "已選 {{n}} 個模型", "{{processed}} of {{total}} log entries processed.": "已處理 {{processed}} / {{total}} 條日誌。", + "{{processed}} of {{total}} usage logs processed": "已處理 {{processed}} / {{total}} 筆使用日誌", "{{success}} succeeded, {{failed}} failed": "{{success}} 個成功,{{failed}} 個失敗", "{{target}} test failed": "{{target}} 測試失敗", "{{target}} test succeeded": "{{target}} 測試成功", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "集中展示金鑰、餘額、路由和服務健康狀態。", "About": "關於", "About {{days}} days left": "約剩 {{days}} 日", + "About historical savings estimates": "關於歷史節省估算", + "About official pricing estimates": "關於官方定價估算", "Accept Unpriced Models": "接受未定價模型", "Accepts a JSON array of model identifiers that support the Imagine API.": "接受支援 Imagine API 的模型標識符的 JSON 陣列。", "Accepts comma-separated status codes and inclusive ranges.": "接受逗號分隔的狀態碼和包含性範圍。", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "透過統一、標準的介面協定接入海量模型。承載 AI 套用,高效管理數位資產,連接未來。", "Access Denied Message": "存取被拒訊息", "Access Forbidden": "禁止存取", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "透過一個相容介面接入多種模型服務,使用統一金鑰,並在開發到正式環境的全程清楚掌握用量、餘額與請求。", "Access Policy (JSON)": "存取政策 (JSON)", "Access previous conversations and start new ones.": "存取之前的對話並開始新的對話。", "Access Token": "存取令牌", "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey", + "Account": "帳戶", + "Account & Security": "帳戶與安全", "Account Binding Management": "用戶連結管理", "Account Bindings": "用戶連結", "Account created! Please sign in": "用戶已建立!請登入", @@ -152,6 +160,7 @@ "Active Tasks": "進行中任務", "active users": "活躍用戶", "Actual Amount": "實付金額", + "Actual Cost": "實際花費", "Actual Model": "實際模型", "Actual Model:": "實際模型:", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "將帶 `-thinking` 後綴的請求適配為 Anthropic 原生思考請求,並保持收費可預測。", @@ -212,6 +221,7 @@ "Add split": "新增分流", "Add subscription": "新增訂閱", "Add tags...": "新增標籤...", + "Add the frozen cumulative savings amount to the wallet summary.": "在錢包摘要中顯示凍結後的累計節省金額。", "Add tier": "新增檔位", "Add time condition": "新增時間條件", "Add time rule group": "新增時間規則組", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "啟用後套餐將在用戶端展示。是否繼續?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "作廢後該訂閱將立即失效,歷史記錄不受影響。是否繼續?", "Agent ID *": "代理 ID *", + "Aggregate new usage into a frozen lifetime savings total.": "將新用量彙總為口徑凍結的歷史累計節省。", "Aggregate tokens delivered across the platform": "平台累計輸出的 Token 量", "Aggregate traffic across every category": "聚合所有分類的整體流量", "Aggregated across enabled groups": "已聚合各啟用分組", @@ -342,6 +353,7 @@ "Allowed Ports": "允許的端口", "Already have an account?": "已有用戶?", "Always matches (default tier).": "始終匹配(預設檔位)。", + "Ambiguous ClickHouse rows skipped: {{count}}": "已略過無法區分的 ClickHouse 資料列:{{count}}", "Amount": "金額", "Amount cannot be changed when editing.": "編輯時無法更改數量。", "Amount discount": "金額折扣", @@ -531,6 +543,7 @@ "Available Models": "可用模型", "Available reset credits": "可用重置次數", "Available Rewards": "可用獎勵", + "available service channels": "可用服務渠道", "Average latency": "平均延遲", "Average latency, TTFT, and success rate by group": "各分組的平均延遲、首 Token 延遲和成功率", "Average latency, TTFT, TPS, and success rate": "平均延遲、TTFT、TPS 和成功率", @@ -551,6 +564,7 @@ "Back to login": "返回登入", "Back to Models": "返回模型", "Backed up": "已備份", + "Backfill running": "回算進行中", "Background job tracker for queued work.": "佇列工作的背景作業追蹤器。", "Backup Code": "備用代碼", "Backup code must be in format XXXX-XXXX": "備用代碼必須為 XXXX-XXXX 格式", @@ -700,6 +714,7 @@ "Cache write price": "緩存寫入價格", "Cached": "緩存", "Cached input": "緩存輸入", + "Calculate estimated savings using official model prices.": "根據模型官方定價計算預估節省金額。", "Calculated price: ${{price}} per 1M tokens": "計算價格:${{price}} / 1M tokens", "Calculated ratio: {{ratio}}": "計算倍率:{{ratio}}", "Calculating...": "計算中...", @@ -801,6 +816,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "中文", + "Choose a supported model and send your first request.": "選擇支援的模型並送出第一個請求。", "Choose a username": "選擇一個用戶名", "Choose an amount and payment method": "選擇金額和支付方式", "Choose and order the groups this API key will try.": "選擇此 API 金鑰要依序嘗試的分組並排序。", @@ -847,6 +863,7 @@ "Clear search": "清除搜尋", "Clear selection": "清除選擇", "Clear selection (Escape)": "清除選擇 (Escape)", + "Clear usage and balance": "用量與餘額清楚可見", "Cleared": "已清空", "Cleared {{bindingType}} binding for user {{username}}": "清除用戶 {{username}} 的 {{bindingType}} 連結", "Cleared all models": "已清除所有模型", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "設定用於收費的模型、緩存和分組比例", "Configure monitoring status page groups for the dashboard": "設定用於儀表板的監控狀態頁面分組", "Configure NODE_NAME": "設定 NODE_NAME", + "Configure official pricing snapshots for user savings estimates.": "設定用於使用者節省估算的官方定價快照。", "Configure per-model ratio for image inputs or outputs.": "設定圖像輸入或輸出的每模型比例。", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "為每個工具設定單價($/1K 次呼叫)。按請求收費的模型不額外收取工具費用。", "Configure pricing ratios for a specific model.": "設定特定模型的定價比例。", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "確認作廢", "Confirm log cleanup": "確認日誌清理", "Confirm log file cleanup?": "確認清理日誌檔案?", + "Confirm marketplace pricing as official": "確認模型廣場定價為官方價格", "Confirm New Password": "確認新密碼", "Confirm password": "確認密碼", "Confirm Payment": "確認付款", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "將 reasoning_content 轉換為 content 中的 標籤", "Convert string to lowercase": "把字串轉成小寫", "Convert string to uppercase": "把字串轉成大寫", + "Converted at 1 USD = {{rate}} CNY": "按 1 美元 = {{rate}} 元人民幣換算", "Converter": "轉換器", "Converter does not match incoming path": "轉換器與入口路徑不匹配", "Converter is not registered": "轉換器未註冊", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "費用 = 10 × 0.8 = 8", "Cost = 10 × 1.0 = 10": "費用 = 10 × 1.0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "費用 = 模型價格 × 這一個倍率。分組設定裡的其他項都不參與該公式。", + "Cost comparison": "成本比較", "Cost in USD per request, regardless of tokens used.": "每請求的美元費用,不考慮使用的令牌數。", "Cost Tracking": "成本追蹤", "Count must be between {{min}} and {{max}}": "計數必須介於{{min}}和{{max}}之間", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "目前已統計 · 覆蓋率 {{coverage}} · 歷史回算 {{progress}}", + "Coverage": "覆蓋率", + "Covered request actual cost": "已覆蓋請求的實際成本", + "Covered requests": "已覆蓋請求", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 閾值 (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "使用可視化編輯器或原始 JSON 建立請求參數覆蓋規則。", "Create request parameter override rules without editing raw JSON.": "無需編輯原始 JSON 即可建立請求參數覆蓋規則。", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "建立模型、標籤、端點和用戶分組的可重用捆綁包,以加快控制台中其他地方的設定速度。", + "Create separate keys for your projects and keep credentials under your control.": "為不同專案建立獨立金鑰,憑證始終由你掌控。", "Create succeeded": "建立成功", "Create Vendor": "建立供應商", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "建立您的第一個分組,以便在儀表板的任何位置重用模型、標籤或端點選擇。", @@ -1187,6 +1213,8 @@ "Currency": "貨幣", "Currency & Display": "貨幣與展示", "Current": "目前", + "Current account cost comparison": "目前帳戶成本比較", + "Current account only": "僅目前帳戶", "Current Balance": "目前餘額", "Current Billing": "目前收費", "Current Cache Size": "目前緩存大小", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "啟用 {{parameter}}", "Enable 2FA": "啟用 2FA", "Enable All": "啟用全部", + "Enable and save lifetime savings before starting a backfill.": "請先啟用並儲存歷史累計節省,再啟動回算。", "Enable check-in feature": "啟用簽到功能", "Enable Data Dashboard": "啟用數據儀表板", "Enable demo mode with limited functionality": "啟用功能受限的演示模式", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "如果這是具有特殊回應格式的 OpenRouter 企業用戶,則啟用", "Enable io.net deployments": "啟用 io.net 部署", "Enable io.net model deployment service in console": "在控制台啟用 io.net 模型部署服務", + "Enable lifetime savings": "啟用歷史累計節省", "Enable LinuxDO OAuth": "啟用 LinuxDO OAuth", "Enable model performance metrics": "啟用模型效能指標", "Enable OIDC": "啟用 OIDC", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "啟用效能監控", "Enable rate limiting": "啟用速率限制", "Enable Request Passthrough": "啟用請求透傳", + "Enable savings estimates": "啟用節省估算", "Enable selected channels": "啟用選定的渠道", "Enable selected models": "啟用選定的模型", "Enable SSL/TLS": "啟用 SSL/TLS", @@ -1727,11 +1758,18 @@ "Error Message (required)": "錯誤訊息(必填)", "Error parsing response data": "解析回應數據失敗", "Error Type (optional)": "錯誤類型(可選)", + "Estimate historical logs without a saved official price snapshot.": "估算未儲存官方價格快照的歷史日誌。", "Estimated cost": "預計成本", + "Estimated from official pricing": "基於官方定價估算", + "Estimated from official public pricing": "基於官方公開定價估算", "Estimated quota cost": "估算配額費用", + "Estimated savings": "預估節省", + "Estimated Savings": "預估節省", + "Estimated: {{count}}": "已估算:{{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定價表中的每個分組名可用在兩個地方:用戶身上(用戶分組,由管理員分配)和令牌身上(令牌分組,建立令牌時選擇)。同一批名字,兩種不同職責。", "Every other device will lose access immediately. This device will remain signed in.": "其他所有裝置將立即失去存取權限,目前裝置將保持登入。", "Everything configured for this group, in one place.": "該分組的全部設定,一處看全。", + "Everything you need to start calling models": "呼叫模型所需的一切", "Exact": "精確", "Exact Match": "完全匹配", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "只做完整精確匹配,區分大小寫;不支援前綴、正則或 * 萬用字元。", @@ -1744,6 +1782,7 @@ "Excellent": "優秀", "Exchange rate is required": "匯率為必填項", "Exchange rate must be greater than 0": "匯率必須大於 0", + "Exclude prices that have not been confirmed as official.": "排除尚未確認為官方價格的定價。", "Execute code in a sandbox during the response": "在回應過程中沙箱執行程式碼", "Executor": "執行實例", "Exhausted": "已耗盡", @@ -1871,6 +1910,7 @@ "Failed to load users": "載入用戶失敗", "Failed to parse group items": "解析組項目失敗", "Failed to parse JSON file: {{name}}": "解析 JSON 檔案失敗:{{name}}", + "Failed to pause historical backfill": "暫停歷史回算失敗", "Failed to query balance": "查詢餘額失敗", "Failed to refresh cache stats": "重新整理緩存統計失敗", "Failed to refresh credential": "重新整理憑證失敗", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "重置模型比率失敗", "Failed to reset Passkey": "重置 Passkey 失敗", "Failed to reset usage": "重置用量失敗", + "Failed to resume historical backfill": "恢復歷史回算失敗", + "Failed to retry historical backfill": "重試歷史回算失敗", "Failed to save": "儲存失敗", "Failed to save announcements": "儲存公告失敗", "Failed to save API info": "儲存 API 資訊失敗", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "啟動 {{provider}} 登入失敗", "Failed to start Discord login": "啟動 Discord 登入失敗", "Failed to start GitHub login": "啟動 GitHub 登入失敗", + "Failed to start historical backfill": "啟動歷史回算失敗", "Failed to start LinuxDO login": "啟動 LinuxDO 登入失敗", "Failed to start OIDC login": "啟動 OIDC 登入失敗", "Failed to start Passkey login": "無法啟動 Passkey 登入", @@ -2067,6 +2110,7 @@ "Frames per second": "幀率", "Free": "可用", "Free: {{free}} / Total: {{total}}": "可用空間: {{free}} / 總空間: {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "凍結目前官方價格和匯率,然後根據既有使用日誌計算歷史累計節省。", "Frequency Penalty": "頻率懲罰", "Friendly name to identify this channel": "用於識別此渠道的友好名稱", "From Address": "發件地址", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "對 {{group}} 屏蔽", "Hide": "隱藏", "Hide API key": "隱藏 API 金鑰", + "Hide password": "隱藏密碼", "Hide sensitive data": "隱藏敏感數據", "Hide setup guide": "隱藏設定引導", "High Performance": "高效能", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "我自願承擔系統穩定性風險:本人知悉該操作可能導致用戶端嚴重逾時及服務崩潰。若因本人開啟此功能導致請求積壓或服務不可用,後果由本人自行承擔。", "High-risk status code retry risk disclaimer": "### ⚠️ 高風險操作:504/524 狀態碼重試風險告知與免責聲明\n\n本專案預設對 `400`(請求錯誤)、`504`(閘道逾時)與 `524`(CDN 逾時)狀態碼不進行重試。504 與 524 錯誤通常代表**請求已成功送達上游 AI 服務,且上游正在處理,但因上游處理時間過長導致連線中斷**。這通常表示逾時源於上游服務瓶頸。\n\n開啟此類逾時狀態碼的重新導向/重試屬於**極高風險操作**。在開啟該功能前,您必須仔細閱讀並知悉以下嚴重後果:\n\n#### 一、核心風險告知(請仔細閱讀)\n\n1. 💸 雙重/多重計費風險:多數 AI 上游廠商對於已開始處理但因網路原因中斷(504/524)的請求**仍然會扣費**。此時若觸發重試,將會向上游發起全新請求,導致您被**雙重甚至多重計費**。\n2. ⏳ 用戶端嚴重逾時:單次請求已觸發逾時,疊加重試機制會使總請求耗時成倍增加,導致最終用戶端(或呼叫方)出現嚴重甚至無法接受的逾時現象。\n3. 💥 請求積壓與系統崩潰風險:強制重試逾時請求會長時間占用系統執行緒與連線數。在高併發場景下,這將導致嚴重的**請求積壓**,進而耗盡系統資源,引發雪崩效應,造成整個代理服務崩潰。\n\n#### 二、風險確認聲明\n\n若您堅持開啟該功能,即代表您作出以下確認:", "Higher priority channels are selected first": "優先級更高的渠道優先被選中", + "Historical backfill": "歷史回算", + "Historical backfill batch size": "歷史回算批次大小", + "Historical estimates": "歷史估算", + "Historical rebuilds": "歷史回算", + "Historical requests recalculated at current official prices: {{count}}": "按目前官方定價回算的歷史請求:{{count}}", + "Historical savings backfill failed": "歷史節省回算失敗", + "Historical savings backfill failed; results are incomplete.": "歷史節省回算失敗,結果尚不完整。", + "Historical savings backfill is already active": "歷史節省回算已在執行", + "Historical savings backfill pause requested": "已請求暫停歷史節省回算", + "Historical savings backfill resumed": "歷史節省回算已恢復", + "Historical savings backfill retry started": "歷史節省回算已從進度點重試", + "Historical savings backfill started": "歷史節省回算已啟動", "Historical Usage": "歷史使用情況", + "Historical usage has not been backfilled": "歷史消費尚未回算", + "Historical usage is recalculated using current official prices": "歷史用量按目前官方價格重新計算", "History of MjProxy-style image tasks.": "MjProxy 風格圖像任務歷史。", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "命中判定:usage 中存在 cached tokens 即視為命中。", "Hit Rate": "命中率", @@ -2299,6 +2358,7 @@ "Important": "重要", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外層鍵是用戶分組,內層鍵是收費分組。下面的示例表示:vip 用戶按 standard 收費時用 0.8,按 premium 收費時用 0.3。", "In Progress": "進行中", + "In the last 24 hours, RAPI saved you about {{amount}}": "近 24 小時 RAPI 已幫你節省約 {{amount}}", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可視化編輯器中顯示為「額外可見」和「屏蔽」。在 JSON 中,+:(或無前綴)表示新增分組,-: 表示移除分組。", "In:": "入:", "incident": "次故障", @@ -2413,6 +2473,7 @@ "Just now": "剛剛", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "保存穩定的累計節省總額,使用者開啟頁面時無需掃描使用日誌。", "Keep affinity when channel is disabled": "渠道停用後保留親和", "Keep enabled if you need to proxy requests for different upstream accounts.": "如果需要為不同上游用戶代理請求,請保持啟用。", "Keep enough balance before production traffic": "生產流量前保持充足餘額", @@ -2437,6 +2498,8 @@ "Language preference saved": "語言偏好已儲存", "Language Preferences": "語言偏好", "Language preferences sync across your signed-in devices and affect API error messages.": "語言偏好會同步到您登入的所有設備,並影響 API 錯誤訊息語言。", + "Last 24 hours": "近 24 小時", + "Last 24h savings estimate": "近 24 小時節省估算", "Last 24h usage": "近 24 小時消耗", "Last 30 days uptime": "近 30 天可用率", "Last active {{time}} · Expires {{expires}}": "最後活動於 {{time}} · 到期時間 {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "小於等於", "Less Than or Equal": "小於等於", "License": "許可證", + "Lifetime savings": "歷史累計節省", + "Lifetime savings counted so far": "目前已統計累計節省", + "Lifetime savings counted so far: {{amount}}": "目前已統計累計節省:{{amount}}", "Light": "淺色", "Lightning Fast": "極速", "Limit period": "限制周期", "Limit Reached": "已達上限", + "Limit the date range of each savings summary query.": "限制單次節省彙總查詢的日期範圍。", + "Limit the number of usage logs scanned per summary.": "限制單次彙總掃描的使用日誌數量。", "Limit which models can be used with this key": "限制此金鑰可使用的模型", "Limited": "受限", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "僅限制令牌專屬的 Auto 快照;繼承全域 Auto 時不受限制。", @@ -2565,6 +2633,7 @@ "Manage Bindings": "管理連結", "Manage catalog visibility and pricing.": "管理目錄可見性和定價。", "Manage custom OAuth providers for user authentication": "管理用於用戶認證的自訂 OAuth 供應商", + "Manage in JSON": "在 JSON 中管理", "Manage Keys": "管理金鑰", "Manage local models for:": "管理本地模型:", "Manage multi-key status and configuration for this channel": "管理此渠道的多金鑰狀態和設定", @@ -2596,6 +2665,7 @@ "Match Value": "匹配值", "Match Value (optional)": "匹配值(可選)", "Matched": "已命中", + "Matched Model": "匹配模型", "Matched models": "匹配模型", "Matched Tier": "命中階梯", "Matches models not claimed by earlier splits.": "匹配前面分流未佔用的模型。", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每個用戶可建立的最大令牌數量。預設 1000。設定過大可能會影響效能。", "Maximum number of tokens in the response": "回應中最大 token 數", "Maximum quota amount awarded for check-in": "簽到獎勵的最大額度", + "Maximum scanned log rows": "最大掃描日誌數", + "Maximum summary range (days)": "最大彙總範圍(天)", "Maximum tokens including hidden reasoning tokens": "最大 token 數(含隱藏的推理 token)", "Maximum tokens per response": "單次回應最大 token 數", "Maximum tokens per user": "每個用戶的最大令牌數", @@ -2905,6 +2977,7 @@ "No description available.": "暫無描述。", "No discount tiers configured. Click \"Add discount tier\" to get started.": "未設定折扣等級。點擊「新增折扣等級」即可開始使用。", "No duplicate keys found": "未發現重複金鑰", + "No eligible savings records yet": "暫無可估算的節省記錄", "No enabled tokens available": "目前沒有可用的啟用令牌", "No encryption": "無加密", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "未設定端點。切換到 JSON 模式或新增列來定義端點。", @@ -2926,6 +2999,7 @@ "No Inviter": "無邀請人", "No keys found": "未找到金鑰", "No latency data available": "暫無延遲數據", + "No lifetime savings records yet": "暫無歷史累計節省記錄", "No log entries matched the selected time.": "沒有日誌條目匹配所選時間。", "No logs": "暫無日誌", "No Logs Found": "未找到日誌", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "暫無 Uptime Kuma 分組。點擊「新增分組」來建立一個。", "No uptime monitoring configured": "未設定正常執行時間監控", "No usage logs available. Logs will appear here once API calls are made.": "暫無使用日誌。發起 API 呼叫後日誌將顯示在此處。", + "No usage records in the selected range": "所選時間範圍內暫無用量記錄", "No user information available": "暫無用戶資訊", "No user selected": "未選擇用戶", "No users": "無用戶", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "官方 OpenAI Embeddings", "Official OpenAI Images": "官方 OpenAI Images", "Official OpenAI Responses": "官方 OpenAI Responses", + "Official price confirmation is required while savings estimates are enabled.": "啟用節省估算時必須要求官方價格確認。", + "Official price estimate": "官方價格估算", + "Official Price Estimate": "官方價格估算", + "Official Price Updated": "官方價格更新時間", + "Official price updated {{time}}": "官方價格更新於 {{time}}", + "Official price validity (days)": "官方價格有效期(天)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "官方價格來自模型廣場中已確認的價格快照;估算結果僅用於成本比較。", + "Official pricing": "官方定價", + "Official pricing estimate": "官方定價估算", "Official Repository": "官方倉庫", "Official Sync": "官方同步", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "One API", "One domain per line": "每行一個域名", "One domain per line (only used when domain restriction is enabled)": "每行一個域名 (僅在啟用域名限制時使用)", + "One endpoint, one key, and a clear view of every request.": "一個端點、一枚金鑰,每次請求都清楚可見。", "One IP or CIDR range per line": "每行一個 IP 或 CIDR 範圍", "One IP per line (empty for no restriction)": "每行一個 IP (留空表示無限制)", "one keyword per line": "每行一個關鍵詞", @@ -3304,6 +3389,11 @@ "Path not set": "未設定路徑", "Path Regex (one per line)": "路徑正則(每行一個)", "Path:": "路徑:", + "Pause backfill": "暫停回算", + "pause_requested": "暫停中", + "paused": "已暫停", + "Paused": "已暫停", + "Pausing": "暫停中", "Pay": "支付", "Pay with Balance": "使用餘額支付", "Pay-as-you-go with real-time usage monitoring": "按量付費,實時監控使用情況", @@ -3507,11 +3597,14 @@ "Price estimation description": "完成硬件類型、部署位置、副本數量等設定後,價格將自動計算。", "Price ID": "價格 ID", "Price mode (USD per 1M tokens)": "價格模式(每 100 萬個 token 的美元價格)", + "Price overrides": "價格覆寫", "Price summary": "價格摘要", "price_xxx": "price_xxx", "Price:": "價格:", "Price: High to Low": "價格:從高到低", "Price: Low to High": "價格:從低到高", + "Prices frozen at {{time}}": "價格凍結於 {{time}}", + "Prices older than this are excluded from savings estimates.": "超過該天數的價格不參與節省估算。", "Prices shown per": "價格顯示單位", "Prices synced successfully": "價格同步成功", "Prices vary by usage tier and request conditions": "價格根據用量檔位和請求條件動態調整", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "auto 分組令牌的優先順序。系統會從上到下依次嘗試各分組。", "Privacy Policy": "私隱政策", "Private Deployment URL": "私有部署 URL", + "Process between 500 and 5000 usage logs per batch.": "每批處理 500 至 5000 筆使用日誌。", "Processing OAuth response...": "正在處理 OAuth 回應...", "Processing...": "處理中...", "Product": "產品", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "每次請求從池中隨機選擇一個金鑰", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "目前排行榜數據為預覽用模擬數據,後端整合完成後將替換為真實分析數據。", "Rankings": "排行榜", + "RAPI has saved you about {{amount}} in total": "RAPI 已累計幫你節省約 {{amount}}", + "RAPI saved you about {{amount}}": "RAPI 已幫你節省約 {{amount}}", "Rate Limit Windows": "速率限制窗口", "Rate Limited": "限流", "Rate Limiting": "速率限制", @@ -3658,6 +3754,7 @@ "Reason:": "原因:", "Reasoning": "推理", "Reasoning Effort": "推理強度", + "Recalculate legacy usage logs": "回算歷史使用日誌", "Receive Upstream Model Update Notifications": "接收上游模型更新通知", "Received": "獲得", "Received amount": "已收額度", @@ -3732,6 +3829,8 @@ "Reject Reason": "拒絕原因", "Release details": "版本詳情", "Released": "發佈於", + "reliability controls": "穩定性保障能力", + "Reload savings data": "重新載入節省資料", "Relying Party Display Name": "依賴方顯示名稱", "Relying Party ID": "依賴方 ID", "Remaining": "剩餘", @@ -3790,6 +3889,7 @@ "Request Body Field": "請求體欄位", "Request Body Memory Cache": "請求體記憶體緩存", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "請求體透傳已啟用。請求體將直接傳送到上游,不進行任何轉換。", + "Request completed": "請求已完成", "Request conversion": "請求轉換", "Request Conversion": "請求轉換", "Request Count": "請求計數", @@ -3813,6 +3913,7 @@ "Requests": "請求數", "Requests (24h)": "請求數(24 小時)", "Requests / 24h": "請求 / 24 小時", + "Requests are routed across available services to improve call stability.": "請求會在可用服務間自動路由,提升呼叫穩定性。", "Requests per minute": "每分鐘請求數", "requests served": "服務請求數", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "請求將被轉發到此 Worker。最後的斜線會自動移除。", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "在後續操作前要求任務成功", "Require login to view models": "要求登入才能查看模型", "Require login to view rankings": "要求登入才能查看排行榜", + "Require official price confirmation": "要求確認官方價格", "required": "必填", "Required": "必需", "Required events:": "必需事件:", "Required provider, authentication, model, and group settings": "必填的供應商、鑒權、模型和分組設定", "Required to expose MjProxy-style image generation to end users.": "需要向終端用戶開放 MjProxy 風格的圖像生成。", + "Required while savings estimates are enabled.": "啟用節省估算時必須開啟此項。", "Rerank": "重新排序", "Reroll": "重繪", "Research, analysis, scientific reasoning": "研究、分析與科學推理", @@ -3880,11 +3983,13 @@ "Restore global Auto": "恢復全域 Auto", "Restrict user model request frequency (may impact high concurrency performance)": "限制用戶模型請求頻率(可能會影響高並發效能)", "Result": "結果", + "Resume backfill": "恢復回算", "Retain last N days": "保留最近N天", "Retain last N files": "保留最近 N 個檔案", "Retention days": "保留天數", "Retry": "重試", "Retry Chain": "重試鏈路", + "Retry from saved progress": "從已儲存進度重試", "Retry Settings": "重試設定", "Retry Suggestion": "重試建議", "Retry Times": "重試次數", @@ -3986,6 +4091,7 @@ "Save Preferences": "儲存偏好設定", "Save preview": "儲存預覽", "Save rate limits": "儲存速率限制", + "Save savings estimate settings": "儲存節省估算設定", "Save sensitive words": "儲存敏感詞", "Save Settings": "儲存設定", "Save sidebar modules": "儲存側邊欄模組", @@ -4000,6 +4106,11 @@ "Save Worker settings": "儲存 Worker 設定", "Saved successfully": "儲存成功", "Saving...": "正在儲存...", + "Savings data update failed": "節省資料更新失敗", + "Savings estimate": "節省估算", + "Savings estimate is not enabled": "節省估算未啟用", + "Savings lifetime backfill": "歷史累計節省回算", + "Savings rate": "節省比例", "Scan QR Code": "掃描二維碼", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "掃描二維碼關注官方賬號,回覆「驗證碼」以接收您的驗證碼。", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "使用您的身份驗證器套用(Google Authenticator、Microsoft Authenticator 等)掃描此二維碼", @@ -4200,13 +4311,21 @@ "Show": "顯示", "Show All": "顯示全部", "Show all providers including unbound": "顯示所有供應商(包括未連結)", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "在使用者儀表板顯示累計節省、覆蓋率和回算進度。", + "Show in usage logs": "在使用日誌中顯示", + "Show lifetime savings in wallet": "在錢包顯示歷史累計節省", + "Show lifetime savings on dashboard": "在儀表板顯示歷史累計節省", + "Show on dashboard": "在儀表板顯示", "Show only bound providers": "僅顯示已連結的供應商", "Show or hide flow columns": "顯示或隱藏分流列", + "Show password": "顯示密碼", "Show preview": "顯示預覽", "Show prices in currency instead of quota.": "以貨幣而非配額顯示價格。", + "Show request-level savings estimates in usage logs.": "在使用日誌中顯示每次請求的節省估算。", "Show sensitive data": "顯示敏感數據", "Show setup guide": "顯示設定引導", "Show source": "顯示源碼", + "Show the savings summary and trend on the user dashboard.": "在用戶儀表板中顯示節省彙總和趨勢。", "Show token usage statistics in the UI": "在用戶介面中顯示令牌使用統計資訊", "Showcase core capabilities with demo credentials and limited access.": "使用演示憑證和有限存取權限展示核心功能。", "Showing": "顯示第", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "使用 Passkey 登入", "Signed out": "已登出", "Significant outages detected": "偵測到較為嚴重的故障", + "Signing in...": "登入中...", "Signing you in with {{provider}}": "正在使用 {{provider}} 登入", "SiliconFlow": "SiliconFlow", "Simple": "簡潔", "Simple mode only returns message; status code and error type use system defaults.": "簡潔模式僅回傳 message;狀態碼和錯誤類型將使用系統預設值。", "Simple mode: prune objects by type, e.g. redacted_thinking.": "簡潔模式:按 type 全量清理物件,例如 redacted_thinking。", + "Since {{date}} · {{coverage}} coverage": "統計始於 {{date}} · 覆蓋率 {{coverage}}", "Single Key": "單金鑰", "Site & Branding": "站點與品牌", "Site Key": "站點金鑰", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "失敗後不重試", "Skip SMTP TLS certificate verification": "跳過 SMTP TLS 證書驗證", "Skip to Main": "跳到主內容", + "Skipped: {{count}}": "已略過:{{count}}", "Slug": "標識符", "Slug can only contain letters, numbers, hyphens, and underscores": "Slug 只能包含字母、數字、連字符和底線", "Slug is required": "Slug 不能為空", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 將所有數據儲存在單個檔案中。在容器中執行時請確保該檔案已持久化。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF 保護", + "Stable model calls": "模型呼叫更穩定", "stale": "失聯", "Standard": "標準", "Standard price": "標準價格", "Start": "開始", "Start a conversation to see messages here": "開始對話以在此處查看訊息", "Start a playground chat": "開始一場遊樂場對話", + "Start calling supported models with RAPI": "使用 RAPI 呼叫支援的模型", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "無需註冊公司即可開始全球收款。面向獨立開發者、OPC 個體經營者和初創團隊構建。Waffo Pancake 作為你的登記商戶(Merchant of Record),承擔全球收款相關的合規負擔,包括消費稅、開票、訂閱管理、退款和拒付。個人開發者可以快速上線,專注產品而不是合規事務。幾分鐘即可完成入駐,從一個提示詞到完整整合。", "Start for free with generous limits. No credit card required.": "免費開始使用,額度充足,無需連結信用卡。", + "Start historical backfill": "啟動歷史回算", "Start Time": "起始時間", + "Start with the familiar OpenAI-compatible workflow.": "沿用熟悉的 OpenAI 相容方式快速開始。", "Started": "啟動時間", "STARTTLS": "STARTTLS", "Static page describing the platform.": "描述平台的靜態頁面。", @@ -4379,6 +4505,7 @@ "Super Large": "超大", "Support for high concurrency with automatic load balancing": "支援高並發和自動負載平衡", "Supported Applications": "常用套用支援", + "supported billing models": "支援的計費模型", "Supported Imagine Models": "支援的 Imagine 模型", "Supported modalities": "支援的模態", "Supported parameters": "支援的參數", @@ -4411,6 +4538,8 @@ "System Behavior": "系統行為", "System data statistics": "系統數據統計", "System default": "系統預設", + "System historical data counting is paused": "系統歷史資料統計已暫停", + "System historical data is being counted": "系統歷史資料統計中", "System Info": "系統資訊", "System Information": "系統資訊", "System initialized successfully! Redirecting…": "系統初始化成功!正在重新導向…", @@ -4457,6 +4586,7 @@ "Task logs": "任務日誌", "Task Logs": "任務日誌", "Tasks currently pending or running.": "目前等待中或執行中的任務。", + "Tasks currently pending, running, or paused.": "目前處於等待、執行或暫停狀態的任務。", "Team Collaboration": "團隊協作", "Technical Support": "技術支援", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "同一個 vip 用戶發起三次呼叫,假設模型單次呼叫基礎價格為 10:", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "三個分組;覆蓋矩陣只填了一個格子(高亮顯示)。", "Three steps to get started": "三步快速上手", + "Three steps to your first model request": "三步完成首次模型請求", "Throughput": "吞吐量", "Throughput by group": "各分組吞吐量", "Throughput short": "吞吐", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "有效登入工作階段數量已達上限。請在一台已登入的裝置上開啟「登入工作階段」,使用「登出其他工作階段」將其撤銷。如果無法存取任何已登入裝置,請重設密碼以登出所有工作階段。", "Too many files. Some were not added.": "檔案過多。部分未添加。", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "近期建立的登入工作階段過多。請等待滾動時間窗口結束後再試。", + "Too many records to summarize": "記錄過多,暫時無法彙總", "Too many requests": "請求過於頻繁", "Tool / function declarations the model may call": "模型可呼叫的工具 / 函數聲明", "Tool identifier": "工具標識", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "轉移到餘額", "Translation": "翻譯", "Transparent Billing": "透明收費", + "Treat local model marketplace prices as official reference prices.": "將本地模型廣場價格視為官方參考價格。", "Trend": "趨勢", "Trending down": "下降趨勢", "Trending up": "上升趨勢", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "無法載入登入工作階段", "Unable to load rankings": "無法載入排行榜", "Unable to load rankings data": "無法載入排行榜數據", + "Unable to load savings trend": "無法載入節省趨勢", "Unable to open chat": "無法打開聊天", "Unable to parse structured pricing": "無法解析為結構化價格", "Unable to prepare chat link. Please ensure you have an enabled API key.": "無法準備聊天連結。請確保您有一個已啟用的 API 金鑰。", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "在文字之外理解圖像輸入", "Unexpected release payload": "意外的版本數據格式", "Unified API Gateway for": "統一 API 閘道,服務於", + "Unified model API service": "統一模型介面服務", "Unique identifier for this group.": "此組的唯一標識符。", "Unit price (local currency / USD)": "單價(本地貨幣 / USD)", "Unit price (USD)": "單價 (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "更新了一個供應商", "Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}(ID: {{id}})", "Updated daily": "每日更新", + "Updated savings official price setting": "已更新節省估算官方價格設定", "Updated successfully": "更新成功", "Updated system setting {{key}}": "修改系統設定 {{key}}", "Updated user {{username}} (ID: {{id}})": "更新用戶 {{username}}(ID: {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "URL 為必填項", "URL to your logo image (optional)": "您的徽標圖片 URL(可選)", "Usage": "用量", + "Usage Analysis": "用量分析", "Usage at a glance": "用量概覽", "Usage guide": "使用教學", "Usage logs": "使用日誌", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "透過外部工具擴展能力", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "將為目前渠道使用 1 次可用重置次數。只有確認後才會發送重置請求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次數,重新整理目前 Codex 用量窗口。", + "Use one compatible endpoint to access supported models without changing SDKs.": "透過一個相容端點存取支援的模型,無需更換現有 SDK。", "Use our unified OpenAI-compatible endpoint in your applications": "在套用中使用我們兼容 OpenAI 的統一接口", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "請使用 Passkey 或雙重身份驗證確認身份後再查看此渠道金鑰。", "Use Passkey to sign in without entering your password.": "使用通行金鑰登入,無需輸入密碼。", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "vip 分組的用戶,按 premium 收費時,倍率用", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用戶只能看到標記為用戶可選的分組。不可選分組仍可由管理員分配。", "uses": "使用次數", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "預設使用模型廣場中的本地官方定價;official_prices 僅用於覆寫例外模型。", "Using the complete global Auto order ({{count}} groups)": "正在使用完整的全域 Auto 順序({{count}} 個分組)", "Validity": "有效期", "Validity Period": "有效期", @@ -5077,6 +5216,7 @@ "View mode": "檢視模式", "View model statistics and charts": "查看模型統計和圖表", "View Pricing": "查看定價", + "View savings trend": "查看節省趨勢", "View the complete details for this": "查看此條", "View the complete details for this log entry": "查看此日誌條目的完整詳情", "View the complete error message and details": "查看完整錯誤資訊與詳情", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "worker 實例不執行僅限 master 的背景任務。", "Worker Proxy": "Worker 代理", "Worker URL": "Worker URL", + "Workspace": "工作區", "Workspaces": "工作區", "Write value to the target field": "把值寫入目標欄位", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "您有未儲存的變更。確定要離開嗎?", "You Pay": "您支付", "You save": "您節省", + "You saved about {{amount}}": "已為你節省約 {{amount}}", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "你理解並獨立承擔因部署、運營和收費行為產生的法律責任。", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "你理解此合規提醒僅用於風險提示,不構成法律意見、合規審查結論或對你使用本系統合法性的保證;你應結合實際業務場景諮詢專業法律或合規顧問。", "You will be redirected to Telegram to complete the binding process.": "您將被重新導向到 Telegram 以完成連結過程。", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 842991e714f0..464452830237 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -50,6 +50,7 @@ "{{count}} IP(s)": "{{count}} 个 IP", "{{count}} log entries removed.": "已删除 {{count}} 条日志。", "{{count}} minutes ago": "{{count}} 分钟前", + "{{count}} model price overrides": "{{count}} 个模型价格覆盖", "{{count}} models": "{{count}} 个模型", "{{count}} months ago": "{{count}} 个月前", "{{count}} override": "{{count}} 个覆盖", @@ -58,6 +59,7 @@ "{{count}} Uptime Kuma groups will be removed from the list.": "将从列表中移除 {{count}} 个 Uptime Kuma 分组。", "{{count}} vendors": "{{count}} 家厂商", "{{count}} weeks ago": "{{count}} 周前", + "{{coverage}} coverage": "覆盖率 {{coverage}}", "{{field}} updated to {{value}}": "{{field}} 已更新为 {{value}}", "{{field}} updated to {{value}} for tag: {{tag}}": "标签「{{tag}}」的 {{field}} 已更新为 {{value}}", "{{method}} {{route}}": "{{method}} {{route}}", @@ -65,6 +67,7 @@ "{{modality}} supported": "支持 {{modality}}", "{{n}} model(s) selected": "已选 {{n}} 个模型", "{{processed}} of {{total}} log entries processed.": "已处理 {{processed}} / {{total}} 条日志。", + "{{processed}} of {{total}} usage logs processed": "已处理 {{processed}} / {{total}} 条使用日志", "{{success}} succeeded, {{failed}} failed": "{{success}} 个成功,{{failed}} 个失败", "{{target}} test failed": "{{target}} 测试失败", "{{target}} test succeeded": "{{target}} 测试成功", @@ -121,16 +124,21 @@ "A focused home for keys, balance, routing, and service health.": "集中展示密钥、余额、路由和服务健康状态。", "About": "关于", "About {{days}} days left": "约剩 {{days}} 天", + "About historical savings estimates": "关于历史节省估算", + "About official pricing estimates": "关于官方定价估算", "Accept Unpriced Models": "接受未定价模型", "Accepts a JSON array of model identifiers that support the Imagine API.": "接受支持 Imagine API 的模型标识符的 JSON 数组。", "Accepts comma-separated status codes and inclusive ranges.": "接受逗号分隔的状态码和包含性范围。", "Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.": "通过统一、标准的接口协议接入海量模型。承载 AI 应用,高效管理数字资产,连接未来。", "Access Denied Message": "访问被拒绝消息", "Access Forbidden": "禁止访问", + "Access multiple model services through one compatible API. Use a single key and keep usage, balance, and requests clear from development to production.": "通过一个兼容接口接入多种模型服务,使用统一密钥,并在开发到生产的全过程清晰掌握用量、余额与请求。", "Access Policy (JSON)": "访问策略 (JSON)", "Access previous conversations and start new ones.": "访问之前的对话并开始新的对话。", "Access Token": "访问令牌", "AccessKey / SecretAccessKey": "AccessKey / SecretAccessKey", + "Account": "账户", + "Account & Security": "账户与安全", "Account Binding Management": "账户绑定管理", "Account Bindings": "账户绑定", "Account created! Please sign in": "账户已创建!请登录", @@ -152,6 +160,7 @@ "Active Tasks": "进行中任务", "active users": "活跃用户", "Actual Amount": "实付金额", + "Actual Cost": "实际花费", "Actual Model": "实际模型", "Actual Model:": "实际模型:", "Adapt `-thinking` suffix requests to Anthropic native thinking behavior while keeping billing predictable.": "将带 `-thinking` 后缀的请求适配为 Anthropic 原生思考请求,并保持计费可预测。", @@ -212,6 +221,7 @@ "Add split": "添加分流", "Add subscription": "新增订阅", "Add tags...": "添加标签...", + "Add the frozen cumulative savings amount to the wallet summary.": "在钱包汇总中显示冻结后的累计节省金额。", "Add tier": "新增档位", "Add time condition": "新增时间条件", "Add time rule group": "新增时间规则组", @@ -260,6 +270,7 @@ "After enabling, the plan will be shown to users. Continue?": "启用后套餐将在用户端展示。是否继续?", "After invalidating, this subscription will be immediately deactivated. Historical records are not affected. Continue?": "作废后该订阅将立即失效,历史记录不受影响。是否继续?", "Agent ID *": "代理 ID *", + "Aggregate new usage into a frozen lifetime savings total.": "将新用量聚合为口径冻结的历史累计节省。", "Aggregate tokens delivered across the platform": "平台累计输出的 Token 量", "Aggregate traffic across every category": "聚合所有分类的整体流量", "Aggregated across enabled groups": "已聚合各启用分组", @@ -342,6 +353,7 @@ "Allowed Ports": "允许的端口", "Already have an account?": "已有账户?", "Always matches (default tier).": "始终匹配(默认档位)。", + "Ambiguous ClickHouse rows skipped: {{count}}": "已跳过无法区分的 ClickHouse 行:{{count}}", "Amount": "金额", "Amount cannot be changed when editing.": "编辑时无法更改数量。", "Amount discount": "金额折扣", @@ -531,6 +543,7 @@ "Available Models": "可用模型", "Available reset credits": "可用重置次数", "Available Rewards": "可用奖励", + "available service channels": "可用服务渠道", "Average latency": "平均延迟", "Average latency, TTFT, and success rate by group": "各分组的平均延迟、首 Token 延迟和成功率", "Average latency, TTFT, TPS, and success rate": "平均延迟、TTFT、TPS 和成功率", @@ -551,6 +564,7 @@ "Back to login": "返回登录", "Back to Models": "返回模型", "Backed up": "已备份", + "Backfill running": "回算进行中", "Background job tracker for queued work.": "队列工作的后台作业跟踪器。", "Backup Code": "备用代码", "Backup code must be in format XXXX-XXXX": "备份代码必须为 XXXX-XXXX 格式", @@ -700,6 +714,7 @@ "Cache write price": "缓存写入价格", "Cached": "缓存", "Cached input": "缓存输入", + "Calculate estimated savings using official model prices.": "根据模型官方定价计算预计节省金额。", "Calculated price: ${{price}} per 1M tokens": "计算价格:${{price}} / 1M tokens", "Calculated ratio: {{ratio}}": "计算倍率:{{ratio}}", "Calculating...": "计算中...", @@ -801,6 +816,7 @@ "checkout.session.completed": "checkout.session.completed", "checkout.session.expired": "checkout.session.expired", "Chinese": "中文", + "Choose a supported model and send your first request.": "选择支持的模型并发送第一个请求。", "Choose a username": "选择一个用户名", "Choose an amount and payment method": "选择金额和支付方式", "Choose and order the groups this API key will try.": "选择并排列此 API 密钥将依次尝试的分组。", @@ -847,6 +863,7 @@ "Clear search": "清除搜索", "Clear selection": "清除选择", "Clear selection (Escape)": "清除选择 (Escape)", + "Clear usage and balance": "用量与余额清晰可见", "Cleared": "已清空", "Cleared {{bindingType}} binding for user {{username}}": "清除用户 {{username}} 的 {{bindingType}} 绑定", "Cleared all models": "已清除所有模型", @@ -973,6 +990,7 @@ "Configure model, caching, and group ratios used for billing": "配置用于计费的模型、缓存和分组比例", "Configure monitoring status page groups for the dashboard": "配置用于仪表板的监控状态页面分组", "Configure NODE_NAME": "配置 NODE_NAME", + "Configure official pricing snapshots for user savings estimates.": "配置用于用户节省估算的官方定价快照。", "Configure per-model ratio for image inputs or outputs.": "配置图像输入或输出的每模型比例。", "Configure per-tool unit prices ($/1K calls). Per-request models do not incur additional tool fees.": "为每个工具配置单价($/1K 次调用)。按请求计费的模型不额外收取工具费用。", "Configure pricing ratios for a specific model.": "配置特定模型的定价比例。", @@ -1005,6 +1023,7 @@ "Confirm invalidate": "确认作废", "Confirm log cleanup": "确认日志清理", "Confirm log file cleanup?": "确认清理日志文件?", + "Confirm marketplace pricing as official": "确认模型广场定价为官方价格", "Confirm New Password": "确认新密码", "Confirm password": "确认密码", "Confirm Payment": "确认付款", @@ -1062,6 +1081,7 @@ "Convert reasoning_content to tag in content": "将 reasoning_content 转换为 content 中的 标签", "Convert string to lowercase": "把字符串转成小写", "Convert string to uppercase": "把字符串转成大写", + "Converted at 1 USD = {{rate}} CNY": "按 1 美元 = {{rate}} 元人民币换算", "Converter": "转换器", "Converter does not match incoming path": "转换器与入口路径不匹配", "Converter is not registered": "转换器未注册", @@ -1118,9 +1138,14 @@ "Cost = 10 × 0.8 = 8": "费用 = 10 × 0.8 = 8", "Cost = 10 × 1.0 = 10": "费用 = 10 × 1.0 = 10", "Cost = model price × that one ratio. Nothing else from the group settings enters the formula.": "费用 = 模型价格 × 这一个倍率。分组设置里的其他项都不参与该公式。", + "Cost comparison": "成本对比", "Cost in USD per request, regardless of tokens used.": "每请求的美元费用,不考虑使用的令牌数。", "Cost Tracking": "成本跟踪", "Count must be between {{min}} and {{max}}": "计数必须介于{{min}}和{{max}}之间", + "Counted so far · {{coverage}} coverage · {{progress}} backfilled": "当前已统计 · 覆盖率 {{coverage}} · 历史回算 {{progress}}", + "Coverage": "覆盖率", + "Covered request actual cost": "已覆盖请求的实际成本", + "Covered requests": "已覆盖请求", "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 阈值 (%)", @@ -1154,6 +1179,7 @@ "Create request parameter override rules with a visual editor or raw JSON.": "使用可视化编辑器或原始 JSON 创建请求参数覆盖规则。", "Create request parameter override rules without editing raw JSON.": "无需编辑原始 JSON 即可创建请求参数覆盖规则。", "Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.": "创建模型、标签、端点和用户分组的可重用捆绑包,以加快控制台中其他地方的配置速度。", + "Create separate keys for your projects and keep credentials under your control.": "为不同项目创建独立密钥,凭据始终由你掌控。", "Create succeeded": "创建成功", "Create Vendor": "创建供应商", "Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.": "创建您的第一个分组,以便在仪表板的任何位置重用模型、标签或端点选择。", @@ -1187,6 +1213,8 @@ "Currency": "货币", "Currency & Display": "货币与展示", "Current": "当前", + "Current account cost comparison": "当前账户成本对比", + "Current account only": "仅当前账户", "Current Balance": "当前余额", "Current Billing": "当前计费", "Current Cache Size": "当前缓存大小", @@ -1572,6 +1600,7 @@ "Enable {{parameter}}": "启用 {{parameter}}", "Enable 2FA": "启用 2FA", "Enable All": "启用全部", + "Enable and save lifetime savings before starting a backfill.": "请先启用并保存历史累计节省,再启动回算。", "Enable check-in feature": "启用签到功能", "Enable Data Dashboard": "启用数据仪表板", "Enable demo mode with limited functionality": "启用功能受限的演示模式", @@ -1585,6 +1614,7 @@ "Enable if this is an OpenRouter enterprise account with special response format": "如果这是具有特殊响应格式的 OpenRouter 企业账户,则启用", "Enable io.net deployments": "启用 io.net 部署", "Enable io.net model deployment service in console": "在控制台启用 io.net 模型部署服务", + "Enable lifetime savings": "启用历史累计节省", "Enable LinuxDO OAuth": "启用 LinuxDO OAuth", "Enable model performance metrics": "启用模型性能指标", "Enable OIDC": "启用 OIDC", @@ -1594,6 +1624,7 @@ "Enable Performance Monitoring": "启用性能监控", "Enable rate limiting": "启用速率限制", "Enable Request Passthrough": "启用请求透传", + "Enable savings estimates": "启用节省估算", "Enable selected channels": "启用选定的渠道", "Enable selected models": "启用选定的模型", "Enable SSL/TLS": "启用 SSL/TLS", @@ -1727,11 +1758,18 @@ "Error Message (required)": "错误消息(必填)", "Error parsing response data": "解析响应数据失败", "Error Type (optional)": "错误类型(可选)", + "Estimate historical logs without a saved official price snapshot.": "估算未保存官方价格快照的历史日志。", "Estimated cost": "预计成本", + "Estimated from official pricing": "基于官方定价估算", + "Estimated from official public pricing": "基于官方公开定价估算", "Estimated quota cost": "估算配额费用", + "Estimated savings": "预计节省", + "Estimated Savings": "预计节省", + "Estimated: {{count}}": "已估算:{{count}}", "Every group name in the pricing table can be used in two places: on a user (the user group, assigned by admins) and on a token (the token group, chosen when creating the token). Same name pool, two different jobs.": "定价表中的每个分组名可用在两个地方:用户身上(用户分组,由管理员分配)和令牌身上(令牌分组,创建令牌时选择)。同一批名字,两种不同职责。", "Every other device will lose access immediately. This device will remain signed in.": "其他所有设备将立即失去访问权限,当前设备将保持登录。", "Everything configured for this group, in one place.": "该分组的全部配置,一处看全。", + "Everything you need to start calling models": "调用模型所需的一切", "Exact": "精确", "Exact Match": "完全匹配", "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "只做完整精确匹配,区分大小写;不支持前缀、正则或 * 通配。", @@ -1744,6 +1782,7 @@ "Excellent": "优秀", "Exchange rate is required": "汇率为必填项", "Exchange rate must be greater than 0": "汇率必须大于 0", + "Exclude prices that have not been confirmed as official.": "排除尚未确认为官方价格的定价。", "Execute code in a sandbox during the response": "在响应过程中沙箱执行代码", "Executor": "执行实例", "Exhausted": "已耗尽", @@ -1871,6 +1910,7 @@ "Failed to load users": "加载用户失败", "Failed to parse group items": "解析组项目失败", "Failed to parse JSON file: {{name}}": "解析 JSON 文件失败:{{name}}", + "Failed to pause historical backfill": "暂停历史回算失败", "Failed to query balance": "查询余额失败", "Failed to refresh cache stats": "刷新缓存统计失败", "Failed to refresh credential": "刷新凭证失败", @@ -1882,6 +1922,8 @@ "Failed to reset model ratios": "重置模型比率失败", "Failed to reset Passkey": "重置 Passkey 失败", "Failed to reset usage": "重置用量失败", + "Failed to resume historical backfill": "恢复历史回算失败", + "Failed to retry historical backfill": "重试历史回算失败", "Failed to save": "保存失败", "Failed to save announcements": "保存公告失败", "Failed to save API info": "保存 API 信息失败", @@ -1900,6 +1942,7 @@ "Failed to start {{provider}} login": "启动 {{provider}} 登录失败", "Failed to start Discord login": "启动 Discord 登录失败", "Failed to start GitHub login": "启动 GitHub 登录失败", + "Failed to start historical backfill": "启动历史回算失败", "Failed to start LinuxDO login": "启动 LinuxDO 登录失败", "Failed to start OIDC login": "启动 OIDC 登录失败", "Failed to start Passkey login": "无法启动 Passkey 登录", @@ -2067,6 +2110,7 @@ "Frames per second": "帧率", "Free": "可用", "Free: {{free}} / Total: {{total}}": "可用空间: {{free}} / 总空间: {{total}}", + "Freeze current official prices and exchange rate, then calculate lifetime savings from existing usage logs.": "冻结当前官方价格和汇率,然后根据已有使用日志计算历史累计节省。", "Frequency Penalty": "频率惩罚", "Friendly name to identify this channel": "用于识别此渠道的友好名称", "From Address": "发件地址", @@ -2196,6 +2240,7 @@ "Hidden from {{group}}": "对 {{group}} 屏蔽", "Hide": "隐藏", "Hide API key": "隐藏 API 密钥", + "Hide password": "隐藏密码", "Hide sensitive data": "隐藏敏感数据", "Hide setup guide": "隐藏设置引导", "High Performance": "高性能", @@ -2209,7 +2254,21 @@ "High-risk status code retry risk check 4": "我自愿承担系统稳定性风险:本人知晓该操作可能导致客户端严重超时及服务崩溃。若因本人开启此功能导致请求积压或服务不可用,后果由本人自行承担。", "High-risk status code retry risk disclaimer": "### ⚠️ 高危操作:504/524 状态码重试风险告知与免责声明\n\n本项目默认对 `400`(请求错误)、`504`(网关超时)和 `524`(CDN 超时)状态码不进行重试。504 和 524 错误通常意味着**请求已成功送达上游 AI 服务,且上游正在处理,但因上游处理时间过长导致连接断开**。这通常说明超时源于上游服务瓶颈。\n\n开启对此类超时状态码的重定向/重试属于**极高风险操作**。在开启该功能前,您必须仔细阅读并知悉以下严重后果:\n\n#### 一、核心风险告知(请仔细阅读)\n\n1. 💸 双重/多重计费风险:绝大多数 AI 上游厂商对于已经开始处理但因网络原因中断(504/524)的请求**依然会进行扣费**。此时若触发重试,将会向上游发起全新请求,导致您被**双重甚至多重计费**。\n2. ⏳ 客户端严重超时:单次请求已经触发超时,叠加重试机制将会使总请求耗时成倍增加,导致您的最终客户端(或调用方)出现严重甚至完全无法接受的超时现象。\n3. 💥 请求积压与系统崩溃风险:强制重试超时请求会长时间占用系统线程和连接数。在高并发场景下,这会导致严重的**请求积压**,进而耗尽系统资源,引发雪崩效应,导致您的整个代理服务崩溃。\n\n#### 二、风险确认声明\n\n如果您坚持开启该功能,即代表您作出以下确认:", "Higher priority channels are selected first": "优先级更高的渠道优先被选中", + "Historical backfill": "历史回算", + "Historical backfill batch size": "历史回算批次大小", + "Historical estimates": "历史估算", + "Historical rebuilds": "历史回算", + "Historical requests recalculated at current official prices: {{count}}": "按当前官方价回算的历史请求:{{count}}", + "Historical savings backfill failed": "历史节省回算失败", + "Historical savings backfill failed; results are incomplete.": "历史节省回算失败,结果尚不完整。", + "Historical savings backfill is already active": "历史节省回算已在运行", + "Historical savings backfill pause requested": "已请求暂停历史节省回算", + "Historical savings backfill resumed": "历史节省回算已恢复", + "Historical savings backfill retry started": "历史节省回算已从断点重试", + "Historical savings backfill started": "历史节省回算已启动", "Historical Usage": "历史使用情况", + "Historical usage has not been backfilled": "历史消费尚未回算", + "Historical usage is recalculated using current official prices": "历史用量按当前官方价格重新计算", "History of MjProxy-style image tasks.": "MjProxy 风格图像任务历史。", "Hit criteria: If cached tokens exist in usage, it counts as a hit.": "命中判定:usage 中存在 cached tokens 即视为命中。", "Hit Rate": "命中率", @@ -2299,6 +2358,7 @@ "Important": "重要", "In JSON, the user group is the outer key and the billing group is the inner key. The example below means: vip users pay 0.8 when billed as standard, and 0.3 when billed as premium.": "在 JSON 中,外层键是用户分组,内层键是计费分组。下面的示例表示:vip 用户按 standard 计费时用 0.8,按 premium 计费时用 0.3。", "In Progress": "进行中", + "In the last 24 hours, RAPI saved you about {{amount}}": "近 24 小时 RAPI 已帮你节省约 {{amount}}", "In the visual editor these appear as Extra visible and Hidden. In JSON, +: (or no prefix) adds a group and -: removes one.": "在可视化编辑器中显示为「额外可见」和「屏蔽」。在 JSON 中,+:(或无前缀)表示添加分组,-: 表示移除分组。", "In:": "入:", "incident": "次故障", @@ -2413,6 +2473,7 @@ "Just now": "刚刚", "JustSong": "JustSong", "K": "K", + "Keep a stable cumulative savings total without scanning usage logs when users open a page.": "保存稳定的累计节省总额,用户打开页面时无需扫描使用日志。", "Keep affinity when channel is disabled": "渠道禁用后保留亲和", "Keep enabled if you need to proxy requests for different upstream accounts.": "如果需要为不同上游账户代理请求,请保持启用。", "Keep enough balance before production traffic": "生产流量前保持充足余额", @@ -2437,6 +2498,8 @@ "Language preference saved": "语言偏好已保存", "Language Preferences": "语言偏好", "Language preferences sync across your signed-in devices and affect API error messages.": "语言偏好会同步到您登录的所有设备,并影响 API 错误消息语言。", + "Last 24 hours": "近 24 小时", + "Last 24h savings estimate": "近 24 小时节省估算", "Last 24h usage": "近 24 小时消耗", "Last 30 days uptime": "近 30 天可用率", "Last active {{time}} · Expires {{expires}}": "最后活跃于 {{time}} · 到期时间 {{expires}}", @@ -2490,10 +2553,15 @@ "Less than or equal": "小于等于", "Less Than or Equal": "小于等于", "License": "许可证", + "Lifetime savings": "历史累计节省", + "Lifetime savings counted so far": "当前已统计累计节省", + "Lifetime savings counted so far: {{amount}}": "当前已统计累计节省:{{amount}}", "Light": "浅色", "Lightning Fast": "极速", "Limit period": "限制周期", "Limit Reached": "已达上限", + "Limit the date range of each savings summary query.": "限制单次节省汇总查询的日期范围。", + "Limit the number of usage logs scanned per summary.": "限制单次汇总扫描的使用日志数量。", "Limit which models can be used with this key": "限制此密钥可使用的模型", "Limited": "受限", "Limits only token-specific Auto snapshots. Global Auto inheritance remains unlimited.": "仅限制令牌专属的 Auto 快照;继承全局 Auto 时不受此限制。", @@ -2565,6 +2633,7 @@ "Manage Bindings": "管理绑定", "Manage catalog visibility and pricing.": "管理目录可见性和定价。", "Manage custom OAuth providers for user authentication": "管理用于用户认证的自定义 OAuth 提供商", + "Manage in JSON": "在 JSON 中管理", "Manage Keys": "管理密钥", "Manage local models for:": "管理本地模型:", "Manage multi-key status and configuration for this channel": "管理此渠道的多密钥状态和配置", @@ -2596,6 +2665,7 @@ "Match Value": "匹配值", "Match Value (optional)": "匹配值(可选)", "Matched": "已命中", + "Matched Model": "匹配模型", "Matched models": "匹配模型", "Matched Tier": "命中阶梯", "Matches models not claimed by earlier splits.": "匹配前面分流未占用的模型。", @@ -2621,6 +2691,8 @@ "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每个用户可创建的最大令牌数量。默认 1000。设置过大可能会影响性能。", "Maximum number of tokens in the response": "响应中最大 token 数", "Maximum quota amount awarded for check-in": "签到奖励的最大额度", + "Maximum scanned log rows": "最大扫描日志数", + "Maximum summary range (days)": "最大汇总范围(天)", "Maximum tokens including hidden reasoning tokens": "最大 token 数(含隐藏的推理 token)", "Maximum tokens per response": "单次响应最大 token 数", "Maximum tokens per user": "每个用户的最大令牌数", @@ -2905,6 +2977,7 @@ "No description available.": "暂无描述。", "No discount tiers configured. Click \"Add discount tier\" to get started.": "未配置折扣等级。点击“添加折扣等级”即可开始使用。", "No duplicate keys found": "未发现重复密钥", + "No eligible savings records yet": "暂无可估算的节省记录", "No enabled tokens available": "当前没有可用的启用令牌", "No encryption": "无加密", "No endpoints configured. Switch to JSON mode or add rows to define endpoints.": "未配置端点。切换到 JSON 模式或添加行来定义端点。", @@ -2926,6 +2999,7 @@ "No Inviter": "无邀请人", "No keys found": "未找到密钥", "No latency data available": "暂无延迟数据", + "No lifetime savings records yet": "暂无历史累计节省记录", "No log entries matched the selected time.": "没有日志条目匹配所选时间。", "No logs": "暂无日志", "No Logs Found": "未找到日志", @@ -3015,6 +3089,7 @@ "No Uptime Kuma groups yet. Click \"Add Group\" to create one.": "暂无 Uptime Kuma 分组。点击“添加分组”来创建一个。", "No uptime monitoring configured": "未配置正常运行时间监控", "No usage logs available. Logs will appear here once API calls are made.": "暂无使用日志。发起 API 调用后日志将显示在此处。", + "No usage records in the selected range": "所选时间范围内暂无用量记录", "No user information available": "暂无用户信息", "No user selected": "未选择用户", "No users": "无用户", @@ -3087,6 +3162,15 @@ "Official OpenAI Embeddings": "官方 OpenAI Embeddings", "Official OpenAI Images": "官方 OpenAI Images", "Official OpenAI Responses": "官方 OpenAI Responses", + "Official price confirmation is required while savings estimates are enabled.": "启用节省估算时必须要求官方价格确认。", + "Official price estimate": "官方价格估算", + "Official Price Estimate": "官方价格估算", + "Official Price Updated": "官方价格更新时间", + "Official price updated {{time}}": "官方价格更新于 {{time}}", + "Official price validity (days)": "官方价格有效期(天)", + "Official prices are confirmed snapshots from the model marketplace; estimates are for cost comparison only.": "官方价格来自模型广场中已确认的价格快照;估算结果仅用于成本对比。", + "Official pricing": "官方定价", + "Official pricing estimate": "官方定价估算", "Official Repository": "官方仓库", "Official Sync": "官方同步", "OhMyGPT": "OhMyGPT", @@ -3105,6 +3189,7 @@ "One API": "One API", "One domain per line": "每行一个域名", "One domain per line (only used when domain restriction is enabled)": "每行一个域名 (仅在启用域名限制时使用)", + "One endpoint, one key, and a clear view of every request.": "一个接口、一枚密钥,每次请求都清晰可见。", "One IP or CIDR range per line": "每行一个 IP 或 CIDR 范围", "One IP per line (empty for no restriction)": "每行一个 IP (留空表示无限制)", "one keyword per line": "每行一个关键词", @@ -3304,6 +3389,11 @@ "Path not set": "未设置路径", "Path Regex (one per line)": "路径正则(每行一个)", "Path:": "路径:", + "Pause backfill": "暂停回算", + "pause_requested": "暂停中", + "paused": "已暂停", + "Paused": "已暂停", + "Pausing": "暂停中", "Pay": "支付", "Pay with Balance": "使用余额支付", "Pay-as-you-go with real-time usage monitoring": "按量付费,实时监控使用情况", @@ -3507,11 +3597,14 @@ "Price estimation description": "完成硬件类型、部署位置、副本数量等设置后,价格将自动计算。", "Price ID": "价格 ID", "Price mode (USD per 1M tokens)": "价格模式(每 100 万个 token 的美元价格)", + "Price overrides": "价格覆盖", "Price summary": "价格摘要", "price_xxx": "price_xxx", "Price:": "价格:", "Price: High to Low": "价格:从高到低", "Price: Low to High": "价格:从低到高", + "Prices frozen at {{time}}": "价格冻结于 {{time}}", + "Prices older than this are excluded from savings estimates.": "超过该天数的价格不参与节省估算。", "Prices shown per": "价格显示单位", "Prices synced successfully": "价格同步成功", "Prices vary by usage tier and request conditions": "价格根据用量档位和请求条件动态调整", @@ -3533,6 +3626,7 @@ "Priority order for tokens in the auto group. The system tries groups from top to bottom.": "auto 分组令牌的优先顺序。系统会从上到下依次尝试各分组。", "Privacy Policy": "隐私政策", "Private Deployment URL": "私有部署 URL", + "Process between 500 and 5000 usage logs per batch.": "每批处理 500 至 5000 条使用日志。", "Processing OAuth response...": "正在处理 OAuth 响应...", "Processing...": "处理中...", "Product": "产品", @@ -3629,6 +3723,8 @@ "Randomly select a key from the pool for each request": "每次请求从池中随机选择一个密钥", "Ranking data is currently simulated for preview purposes and will be replaced with live analytics once the backend integration ships.": "当前排行榜数据为预览用模拟数据,后端集成完成后将替换为真实分析数据。", "Rankings": "排行榜", + "RAPI has saved you about {{amount}} in total": "RAPI 已累计帮你节省约 {{amount}}", + "RAPI saved you about {{amount}}": "RAPI 已帮你节省约 {{amount}}", "Rate Limit Windows": "速率限制窗口", "Rate Limited": "限流", "Rate Limiting": "速率限制", @@ -3658,6 +3754,7 @@ "Reason:": "原因:", "Reasoning": "推理", "Reasoning Effort": "推理强度", + "Recalculate legacy usage logs": "回算历史使用日志", "Receive Upstream Model Update Notifications": "接收上游模型更新通知", "Received": "获得", "Received amount": "已收额度", @@ -3732,6 +3829,8 @@ "Reject Reason": "拒绝原因", "Release details": "版本详情", "Released": "发布于", + "reliability controls": "稳定性保障能力", + "Reload savings data": "重新加载节省数据", "Relying Party Display Name": "依赖方显示名称", "Relying Party ID": "依赖方 ID", "Remaining": "剩余", @@ -3790,6 +3889,7 @@ "Request Body Field": "请求体字段", "Request Body Memory Cache": "请求体内存缓存", "Request body pass-through is enabled. The request body will be sent directly to the upstream without any conversion.": "请求体透传已启用。请求体将直接发送到上游,不进行任何转换。", + "Request completed": "请求已完成", "Request conversion": "请求转换", "Request Conversion": "请求转换", "Request Count": "请求计数", @@ -3813,6 +3913,7 @@ "Requests": "请求数", "Requests (24h)": "请求数(24 小时)", "Requests / 24h": "请求 / 24 小时", + "Requests are routed across available services to improve call stability.": "请求会在可用服务间自动路由,提升调用稳定性。", "Requests per minute": "每分钟请求数", "requests served": "服务请求数", "Requests will be forwarded to this worker. Trailing slashes are removed automatically.": "请求将被转发到此 Worker。末尾的斜杠会自动移除。", @@ -3821,11 +3922,13 @@ "Require job success before follow-up actions": "在后续操作前要求任务成功", "Require login to view models": "要求登录才能查看模型", "Require login to view rankings": "要求登录才能查看排行榜", + "Require official price confirmation": "要求确认官方价格", "required": "必填", "Required": "必需", "Required events:": "必需事件:", "Required provider, authentication, model, and group settings": "必填的供应商、鉴权、模型和分组设置", "Required to expose MjProxy-style image generation to end users.": "需要向终端用户开放 MjProxy 风格的图像生成。", + "Required while savings estimates are enabled.": "启用节省估算时必须开启此项。", "Rerank": "重新排序", "Reroll": "重绘", "Research, analysis, scientific reasoning": "研究、分析与科学推理", @@ -3880,11 +3983,13 @@ "Restore global Auto": "恢复全局 Auto", "Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)", "Result": "结果", + "Resume backfill": "恢复回算", "Retain last N days": "保留最近N天", "Retain last N files": "保留最近 N 个文件", "Retention days": "保留天数", "Retry": "重试", "Retry Chain": "重试链路", + "Retry from saved progress": "从已保存进度重试", "Retry Settings": "重试设置", "Retry Suggestion": "重试建议", "Retry Times": "重试次数", @@ -3986,6 +4091,7 @@ "Save Preferences": "保存偏好设置", "Save preview": "保存预览", "Save rate limits": "保存速率限制", + "Save savings estimate settings": "保存节省估算设置", "Save sensitive words": "保存敏感词", "Save Settings": "保存设置", "Save sidebar modules": "保存侧边栏模块", @@ -4000,6 +4106,11 @@ "Save Worker settings": "保存 Worker 设置", "Saved successfully": "保存成功", "Saving...": "正在保存...", + "Savings data update failed": "节省数据更新失败", + "Savings estimate": "节省估算", + "Savings estimate is not enabled": "节省估算未开启", + "Savings lifetime backfill": "历史累计节省回算", + "Savings rate": "节省比例", "Scan QR Code": "扫描二维码", "Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.": "扫描二维码关注官方账号,回复“验证码”以接收您的验证码。", "Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)": "使用您的身份验证器应用(Google Authenticator、Microsoft Authenticator 等)扫描此二维码", @@ -4200,13 +4311,21 @@ "Show": "显示", "Show All": "显示全部", "Show all providers including unbound": "显示所有提供商(包括未绑定)", + "Show cumulative savings, coverage, and backfill progress on the user dashboard.": "在用户仪表盘显示累计节省、覆盖率和回算进度。", + "Show in usage logs": "在使用日志中显示", + "Show lifetime savings in wallet": "在钱包显示历史累计节省", + "Show lifetime savings on dashboard": "在仪表盘显示历史累计节省", + "Show on dashboard": "在仪表盘显示", "Show only bound providers": "仅显示已绑定的提供商", "Show or hide flow columns": "显示或隐藏分流列", + "Show password": "显示密码", "Show preview": "显示预览", "Show prices in currency instead of quota.": "以货币而非配额显示价格。", + "Show request-level savings estimates in usage logs.": "在使用日志中显示每次请求的节省估算。", "Show sensitive data": "显示敏感数据", "Show setup guide": "显示设置引导", "Show source": "显示源码", + "Show the savings summary and trend on the user dashboard.": "在用户仪表盘中显示节省汇总和趋势。", "Show token usage statistics in the UI": "在用户界面中显示令牌使用统计信息", "Showcase core capabilities with demo credentials and limited access.": "使用演示凭据和有限访问权限展示核心功能。", "Showing": "显示第", @@ -4232,11 +4351,13 @@ "Signed in with Passkey": "使用 Passkey 登录", "Signed out": "已登出", "Significant outages detected": "检测到较为严重的故障", + "Signing in...": "登录中...", "Signing you in with {{provider}}": "正在使用 {{provider}} 登录", "SiliconFlow": "SiliconFlow", "Simple": "简洁", "Simple mode only returns message; status code and error type use system defaults.": "简洁模式仅返回 message;状态码和错误类型将使用系统默认值。", "Simple mode: prune objects by type, e.g. redacted_thinking.": "简洁模式:按 type 全量清理对象,例如 redacted_thinking。", + "Since {{date}} · {{coverage}} coverage": "统计始于 {{date}} · 覆盖率 {{coverage}}", "Single Key": "单密钥", "Site & Branding": "站点与品牌", "Site Key": "站点密钥", @@ -4247,6 +4368,7 @@ "Skip retry on failure": "失败后不重试", "Skip SMTP TLS certificate verification": "跳过 SMTP TLS 证书验证", "Skip to Main": "跳到主内容", + "Skipped: {{count}}": "已跳过:{{count}}", "Slug": "标识符", "Slug can only contain letters, numbers, hyphens, and underscores": "Slug 只能包含字母、数字、连字符和下划线", "Slug is required": "Slug 不能为空", @@ -4285,15 +4407,19 @@ "SQLite stores all data in a single file. Make sure that file is persisted when running in containers.": "SQLite 将所有数据存储在单个文件中。在容器中运行时请确保该文件已持久化。", "SSL/TLS": "SSL/TLS", "SSRF Protection": "SSRF 保护", + "Stable model calls": "模型调用更稳定", "stale": "失联", "Standard": "标准", "Standard price": "标准价格", "Start": "开始", "Start a conversation to see messages here": "开始对话以在此处查看消息", "Start a playground chat": "开始一场游乐场对话", + "Start calling supported models with RAPI": "使用 RAPI 调用支持的模型", "Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.": "无需注册公司即可开始全球收款。面向独立开发者、OPC 个体经营者和初创团队构建。Waffo Pancake 作为你的登记商户(Merchant of Record),承担全球收款相关的合规负担,包括消费税、开票、订阅管理、退款和拒付。个人开发者可以快速上线,专注产品而不是合规事务。几分钟即可完成入驻,从一个提示词到完整集成。", "Start for free with generous limits. No credit card required.": "免费开始使用,额度充足,无需绑定信用卡。", + "Start historical backfill": "启动历史回算", "Start Time": "起始时间", + "Start with the familiar OpenAI-compatible workflow.": "沿用熟悉的 OpenAI 兼容方式快速开始。", "Started": "启动时间", "STARTTLS": "STARTTLS", "Static page describing the platform.": "描述平台的静态页面。", @@ -4379,6 +4505,7 @@ "Super Large": "超大", "Support for high concurrency with automatic load balancing": "支持高并发和自动负载均衡", "Supported Applications": "常用应用支持", + "supported billing models": "支持的计费模型", "Supported Imagine Models": "支持的 Imagine 模型", "Supported modalities": "支持的模态", "Supported parameters": "支持的参数", @@ -4411,6 +4538,8 @@ "System Behavior": "系统行为", "System data statistics": "系统数据统计", "System default": "系统默认", + "System historical data counting is paused": "系统历史数据统计已暂停", + "System historical data is being counted": "系统历史数据统计中", "System Info": "系统信息", "System Information": "系统信息", "System initialized successfully! Redirecting…": "系统初始化成功!正在重定向…", @@ -4457,6 +4586,7 @@ "Task logs": "任务日志", "Task Logs": "任务日志", "Tasks currently pending or running.": "当前等待中或运行中的任务。", + "Tasks currently pending, running, or paused.": "当前处于等待、运行或暂停状态的任务。", "Team Collaboration": "团队协作", "Technical Support": "技术支持", "Telegram": "Telegram", @@ -4615,6 +4745,7 @@ "Three calls made by the same vip user. Assume the base price of one call is 10.": "同一个 vip 用户发起三次调用,假设模型单次调用基础价格为 10:", "Three groups; the override matrix has exactly one cell filled in (highlighted).": "三个分组;覆盖矩阵只填了一个格子(高亮显示)。", "Three steps to get started": "三步快速上手", + "Three steps to your first model request": "三步完成首次模型请求", "Throughput": "吞吐量", "Throughput by group": "各分组吞吐量", "Throughput short": "吞吐", @@ -4696,6 +4827,7 @@ "Too many active login sessions. On a device where you are already signed in, open Login sessions and use “Sign out other sessions” to revoke them. If you cannot access a signed-in device, reset your password to sign out all sessions.": "活跃登录会话数量已达上限。请在一台已登录的设备上打开“登录会话”,使用“退出其他登录会话”将其撤销。如果无法访问任何已登录设备,请重置密码以退出所有会话。", "Too many files. Some were not added.": "文件过多。部分未添加。", "Too many login sessions were created recently. Please wait for the rolling window to pass, then try again.": "近期创建的登录会话过多。请等待滚动时间窗口结束后再试。", + "Too many records to summarize": "记录过多,暂无法汇总", "Too many requests": "请求过于频繁", "Tool / function declarations the model may call": "模型可调用的工具 / 函数声明", "Tool identifier": "工具标识", @@ -4760,6 +4892,7 @@ "Transfer to Balance": "转移到余额", "Translation": "翻译", "Transparent Billing": "透明计费", + "Treat local model marketplace prices as official reference prices.": "将本地模型广场价格视为官方参考价格。", "Trend": "趋势", "Trending down": "下降趋势", "Trending up": "上升趋势", @@ -4803,6 +4936,7 @@ "Unable to load login sessions": "无法加载登录会话", "Unable to load rankings": "无法加载排行榜", "Unable to load rankings data": "无法加载排行榜数据", + "Unable to load savings trend": "无法加载节省趋势", "Unable to open chat": "无法打开聊天", "Unable to parse structured pricing": "无法解析为结构化价格", "Unable to prepare chat link. Please ensure you have an enabled API key.": "无法准备聊天链接。请确保您有一个已启用的 API 密钥。", @@ -4818,6 +4952,7 @@ "Understand image inputs alongside text": "在文本之外理解图像输入", "Unexpected release payload": "意外的版本数据格式", "Unified API Gateway for": "统一 API 网关,服务于", + "Unified model API service": "统一模型接口服务", "Unique identifier for this group.": "此组的唯一标识符。", "Unit price (local currency / USD)": "单价(本地货币 / USD)", "Unit price (USD)": "单价 (USD)", @@ -4870,6 +5005,7 @@ "Updated a vendor": "更新了一个供应商", "Updated channel {{name}} (ID: {{id}})": "更新渠道 {{name}}(ID: {{id}})", "Updated daily": "每日更新", + "Updated savings official price setting": "已更新节省估算官方价格设置", "Updated successfully": "更新成功", "Updated system setting {{key}}": "修改系统设置 {{key}}", "Updated user {{username}} (ID: {{id}})": "更新用户 {{username}}(ID: {{id}})", @@ -4922,6 +5058,7 @@ "URL is required": "URL 为必填项", "URL to your logo image (optional)": "您的徽标图片 URL(可选)", "Usage": "用量", + "Usage Analysis": "用量分析", "Usage at a glance": "用量概览", "Usage guide": "使用教程", "Usage logs": "使用日志", @@ -4944,6 +5081,7 @@ "Use external tools to extend capabilities": "通过外部工具扩展能力", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "将为当前渠道使用 1 次可用重置次数。只有确认后才会发送重置请求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次数,刷新当前 Codex 用量窗口。", + "Use one compatible endpoint to access supported models without changing SDKs.": "通过一个兼容接口访问支持的模型,无需更换现有 SDK。", "Use our unified OpenAI-compatible endpoint in your applications": "在应用中使用我们兼容 OpenAI 的统一接口", "Use Passkey or 2FA to confirm your identity before revealing this channel key.": "请使用 Passkey 或双重身份验证确认身份后再查看此渠道密钥。", "Use Passkey to sign in without entering your password.": "使用通行密钥登录,无需输入密码。", @@ -5014,6 +5152,7 @@ "Users of vip, when billed as premium, pay ratio": "vip 分组的用户,按 premium 计费时,倍率用", "Users only see groups marked as user selectable. Non-selectable groups can still be assigned by administrators.": "用户只能看到标记为用户可选的分组。不可选分组仍可由管理员分配。", "uses": "使用次数", + "Uses local official pricing from the model marketplace by default; official_prices is only needed for overrides.": "默认使用模型广场中的本地官方定价;official_prices 仅用于覆盖例外模型。", "Using the complete global Auto order ({{count}} groups)": "正在使用完整全局 Auto 顺序({{count}} 个分组)", "Validity": "有效期", "Validity Period": "有效期", @@ -5077,6 +5216,7 @@ "View mode": "视图模式", "View model statistics and charts": "查看模型统计和图表", "View Pricing": "查看定价", + "View savings trend": "查看节省趋势", "View the complete details for this": "查看此条", "View the complete details for this log entry": "查看此日志条目的完整详情", "View the complete error message and details": "查看完整错误信息与详情", @@ -5199,6 +5339,7 @@ "Worker instances do not run master-only background tasks.": "worker 实例不执行仅限 master 的后台任务。", "Worker Proxy": "Worker 代理", "Worker URL": "Worker URL", + "Workspace": "工作区", "Workspaces": "工作区", "Write value to the target field": "把值写入目标字段", "x": "x", @@ -5223,6 +5364,7 @@ "You have unsaved changes. Are you sure you want to leave?": "您有未保存的更改。确定要离开吗?", "You Pay": "您支付", "You save": "您节省", + "You saved about {{amount}}": "已为你节省约 {{amount}}", "You understand and independently bear legal responsibility arising from deployment, operation, and charging behavior.": "你理解并独立承担因部署、运营和收费行为产生的法律责任。", "You understand this compliance reminder is only for risk notice and does not constitute legal advice, a compliance review conclusion, or a guarantee of the legality of your use of this system; you should consult professional legal or compliance advisors based on your actual business scenario.": "你理解此合规提醒仅用于风险提示,不构成法律意见、合规审查结论或对你使用本系统合法性的保证;你应结合实际业务场景咨询专业法律或合规顾问。", "You will be redirected to Telegram to complete the binding process.": "您将被重定向到 Telegram 以完成绑定过程。",