Files
gpt-load/internal/requestlog/usage_query.go
T
tbphp 08172b9e88 feat(web): implement the modern management frontend (#668)
* feat(web): 搭建新旧前端独立架构与界面切换

* feat(web): 设计珊瑚主题新版工作区框架

* refactor(web): 精调新版框架并移除预览占位内容

* fix(web): 隔离新版页面路由并恢复经典版启动

* fix(web): 修复页头菜单定位并精简导航提示

* feat(web): 优化导航布局并接入侧栏版本检查

* refactor(web): 精调侧栏底部外链与版本展示

* refactor(web): 整理侧栏外链并增加赞助入口

* refactor(web): 调整路由检查菜单与版本栏对齐

* Codex worktree snapshot: archive-cleanup

Co-authored-by: Codex

* fix(web): 恢复新版浮层组件样式

* feat(web): 完成新版登录认证与会话管理

* feat(web): 增加登录记忆选项并精简登录页

* fix(web): 独立展示访问密钥身份标识

* fix(modern): 调整快速导航触发器高度

* refactor(modern): 统一页面标题与设计令牌规范

* feat(modern): 完善分组工作区与公共框架

* feat(modern): 完善分组列表与公共组件

* feat(modern): 完成 API 密钥分组创建与模型批量选择

* feat(modern): 完善订阅分组创建与并行账号接入

* feat(modern): 完善分组详情与凭据管理

* feat(modern): 完善凭据卡片与高级配置交互

* feat(modern): 完善分组筛选并校正凭据统计

* feat(modern): 完善分组操作并统一消息与路由状态

* fix(modern): 统一凭据卡片底栏文字对齐

* feat(modern): 完善访问密钥管理与公共表单组件

* fix(modern): 区分页面与局部操作的加载反馈

* feat(modern): 完善请求日志页面与筛选展示

* refactor(modern): 精简日志表格字段与排版并降低渲染开销

表格只有单一字号、两档色阶,且每个配对单元格重复渲染表头已有的标签。
建立五档字号与三档色阶,数字列改等宽右对齐,删除格内标签。

字段配对从 7 组扩到 17 组并覆盖全部字段,避免非默认列各占一列;
细分缓存写入与两个完整性状态移出表格,仅保留在详情面板;
思考模式、强度、预算合并为一列,按强度、预算、开关的优先级取一,
等级保留上游原值不作翻译。列宽按内容与表头重排,全展开总宽减少约 480px。

表头与内容原先分属两个滚动容器、靠 JS 同步 scrollLeft,横向滚动有延迟,
改为同一容器内 sticky;行右内边距归零,固定列不再随滚动露出缝隙。

渲染上,AppOverflowText 仅在文字溢出时才创建 AppTooltip,
溢出检测共用一个 ResizeObserver,AppTooltip 的作用域属性改为缓存计算。

* refactor(modern): 重排日志高级筛选并精简筛选标签

* feat(modern): 统一协议标签并优化日志详情

* feat(modern): 完善请求日志展示与脱敏复制

* perf(modern): 减少页面切换等待与重复渲染开销

* fix(modern): 精简分页提示并缩小日期弹层

* feat(modern): 完善用量统计页面

* feat(modern): 支持日志字段快捷筛选

* feat(modern): 完善健康状态页面

* fix(health): 返回全部异常账号

* refactor(modern): 精简健康页并统一监控交互

* fix(brand): 统一品牌图形并修复刷新位置跳动

* feat(modern): 实现模型管理页面

* feat(modern): 完善全局设置页面

* fix(modern): 统一原生协议展示与弹窗确认焦点

* fix(modern): 优化全局滚动条体验

* refactor(modern): 模型卡片改为来源价格表格式并支持分组跳转

- 来源行改为与表头共用同一套列定义(来源/分组/4 项价格/计价方式/操作),
  多来源时价格上下对齐可比
- 协议折成一行中性摘要,悬浮查看完整列表,不再占用卡片最强的颜色
- 分组徽章对管理员改为可点击跳转到分组详情,非管理员保持纯展示
- 计价方式按常见程度分级:自动匹配为默认灰字,待计价才用警示徽章
- 卡片列表最小宽度从 320px 提到 620px 以容纳价格表;表格内容超宽时整体横向滚动

* fix(modern): 修正模型卡片价格溢出、行高不齐与列错位

- 价格列宽改为按本卡最长价格计算的共享变量:原先写死 52px,9 位小数的
  单价会溢出压到隔壁列;用 max-content 则表头与来源行各算各的导致列错位
- 来源单元格由 flex-wrap 改为网格:长渠道名原先会把图标、名称、档位各自
  甩到新行,单元格高度从 24px 涨到 87px
- 分组只展开第一个并禁止换行,超出交给 +N 提示,行高不再被撑乱
- 行内容压进 40px 下限,有无上游模型的行等高
- 溢出时行宽跟随内容,底色与分隔线不再在容器右边缘断掉
- 分组标签常态加描边,亮色下 subtle 几乎等于卡片白底、看不出可点

* feat(modern): 模型卡改多列排布并重做卡头视觉

- 网格改为 CSS 多列:来源数不同导致卡高不一,网格按行内最高的卡留空洞,
  实测同一批数据容器高从 616px 降到 576px 且无空洞
- 卡头加冷色渐变,自右上向左下淡出,左侧留白给模型名
- 新增 --modern-model-card-tint,沿用账号卡那套 info 蓝调,避开主色的橙
- 模型名靠左独占一端,协议/来源/分组三项统计右对齐

* refactor(modern): 精简模型详情侧边栏

- 去掉「全部来源」中间页:多来源靠顶部下拉切换即可,进面板直接落到
  第一个来源,随之移除返回箭头与只服务于该页的 ModelSourcePreview
- 面板宽度 sheet(660px) 改回 default(540px),与日志详情、访问密钥面板一致
- 价格由每档重复四个标签的块改为表格:表头只写一次,标准档与各阶梯档、
  各模式上下对齐可比,极端价格不再撑破容器
- 模型资料标签列定宽右对齐,原先 auto 会让每行按各自内容算宽而行间错位
- 关联分组去掉 AppButton>RouterLink>AppBadge 三层嵌套,改用与模型卡共用的
  可点标签样式(新增 model-group-chip.css)
- 清理随视图作废的样式与导入

* fix(modern): 换掉卡头底色并重排侧边栏价格表

- 卡头 tint 换成冷灰蓝 #e4ebf5/#2c3340,渐变两端都保留色:原先淡出到
  纯白,左下角与内容底色无差,所以区分不出卡头
- 修正上一轮未生效的改动:tint 仍混着 8% accent-soft(橙),当时单行
  replace 没匹配上已被格式化成多行的定义,且漏了断言
- 价格表单位说明移入表头首列,消掉标签列 225px 空白(占表宽 44%)
- 去掉价格表外框与表头底色,540px 侧栏里原先过重
- 价格四列收紧靠右(各 64px),数字挨在一起才便于比较
- 计价方式徽章移到「计价规则」分段标题行

* fix(modern): 卡头改用右上角径向晕染

照搬用量统计顶部卡片的手法:radial-gradient(ellipse at top right) 配低比例
混色,基色取成功绿。原先用线性渐变方向一直不对——to bottom left 会按盒子
比例换算,卡头宽高比约 15:1 时算出来接近 86°,等于水平色带;即便改成固定
225deg,扁盒子里的线性渐变也只是一条生硬的斜带,做不出角落发散的效果。

* feat(modern): 模型卡卡头晕染定为蓝调

基色改用 --modern-chart-input,与用量统计里 Token 卡同一支蓝,两页同源。

* fix(modern): 阶梯价入口仅保留标准档,Fast/Ultrafast 不再翻译

- Fast、Ultrafast 是计价档位专名(对应 OpenAI service_tier),三语一律不译
- 阶梯价的新增入口收窄到标准档:后端 NormalizeModePriceSchedules 直接驳回
  Fast 带阶梯,此前编辑器对所有档位都给了按钮,加完保存必然失败;
  Ultrafast 后端虽未拦截,按约定前端统一不做
- 非标准档改为提示「仅标准档支持阶梯价」,已存在的阶梯仍可见可删,
  避免静默隐藏却在保存时原样回传

* fix(modern): Fast 与 Ultrafast 档位行为对齐

后端对两档一视同仁:validPricingModeKey 只排除 standard,没有模型级限制;
计费同走 quote.go 查 ModeSchedules,仅触发参数不同(service_tier 取
fast/priority 与 ultrafast)。此前前端只给 Ultrafast 添加入口、删除按钮也
只对 Ultrafast 开放,目录同步来的 Fast 档因此无法删除。

- 添加入口改为按 optionalModes 渲染,两档对称
- 删除按钮放开到所有非标准档
- 阶梯价仍只在标准档提供(Fast 后端驳回,Ultrafast 按约定统一不做)

* fix(modern): 修正价格表头下方的边框重叠

选择器 .row + .row:not(--head) 把紧跟表头的第一个数据行也匹配进去了,
它的上边框与表头的下边框落在同一像素,叠出一条明显更粗的线。改为要求
前一项也是数据行。

* fix(frontend): 限制经典版入口为管理员

* refactor(modern): 精调全局设置页排版

- 状态徽章只在有状态时出现:17 个里 14 个是「默认」,等于反复声明
  「这里没什么特别的」,现仅剩已配置/已修改/环境锁定 3 个
- 设置项控件列由 auto 改为定宽,右边缘从 4 种位置收敛到 1 种
- 表单限宽 760px:内容区近 1000px,撑满会把标签与控件拉得过开
- 分区之间加细线隔断,功能边界不再只靠留白区分
- 子分组标题原先比它统领的设置项标签还小还淡,层级倒置;改用组标签
  质感(caption + 字距 + 延伸线)
- 移除「显示模式 / 界面语言」:右上角已有同样入口,此处重复
- 新版界面预览改用概念图:原先是 24KB 真实截图,与经典版的抽象示意
  图风格割裂;仓库里原有的 modern.svg 实为带虚线框的加号占位图,
  已按 classic.svg 的色板与抽象度重画为「左侧栏 + 卡片」
- 系统信息标签列定宽右对齐:原先 dt 用 flex:none 按各自文字宽度收缩,
  「数据库」与「管理员密钥来源」差近一倍,取值起点全部错开;
  三种更新状态也合并为一行

* refactor(modern): 梳理设置页布局与交互细节

* feat(modern): 完善吉祥物彩蛋与版本更新提示

* feat(modern): 补全总览与路由检查页面

* refactor(modern): 精简首页并整合路由检查

* feat(modern): 重做首页为接入优先布局

首页原本只展示分组、模型、凭据、密钥四个库存计数,这些数字一周不变,
且每个都对应侧边栏的一个导航项。真正回答「现在跑得怎么样」的
/api/home/statistics 接口后端早已实现,经典版在用,新版一直没接。

- 顶部换成一行状态条:服务状态 + 库存事实 + 版本与运行时长
- 接入客户端重做成左目录 + 按客户端类型分化的配置输出:
  CLI 客户端给可一次粘贴执行的终端命令(Codex 的 toml 改写成 heredoc),
  图形客户端给设置界面示意图与编号字段对照,可深链的把一键导入置顶
- 修掉 Cline 的缺口:它需要模型名但输出形态返回空块,
  界面上选了模型却没有任何地方显示,现在模型名是字段表的一行
- 协议不匹配改为选择前就在目录里禁用,提示里点名具体协议
- 路由检查折叠成工具条,不再以完整工作台占据首页
- 右栏依次为订阅账号、需要处理、24 小时请求趋势、访问密钥;
  需要处理由已有的分组数据推出,不额外请求健康接口,
  额度预警不重复(订阅账号已按账号画了额度条)

* refactor(modern): 精调首页接入面板与侧栏排版

上一版实现丢了设计稿里的若干排版细节,逐条补回:

- 客户端目录恢复协议标签。协议枚举全称有 16 个字符放不下,
  给 AppProtocolTag 加 short 模式取短名(Responses / Completions),
  颜色仍由同一处定义,不另写一套
- 界面示意图补上窗口标题栏文字,此前只画了三个点
- 没登记字段原文的客户端补两行无标签占位,示意图要读起来像一个设置表单,
  而不是「这个界面只有三个框」;不替没核实过的客户端编字段名
- 补上「把右边的值按编号填到左图对应的位置」的引导语
- 一键导入横幅不再复用确认对话框的文案(那句带问号),改用常驻横幅的说法
- 整段配置块的标题从裸的 JSON 改成「也可以直接复制完整配置」
- 状态条加入待办入口,与右栏面板共用 collectAttention 的同一份计数
- 订阅账号卡去掉「更新于」(页头已有整页刷新时间)、重置券并进页脚,
  并限制为前 3 个:账号多时这块会把下面三块全顶到折叠线以下

* fix(modern): 修正首页待办取材与侧栏面板的溢出

- 需要处理改读 /api/health。此前只按分组凭据计数判定,推不出额度将尽、
  充值卡临期、访问密钥被费用额度挡住这几类,健康实例上永远是空的,
  看起来就像坏了。现在覆盖无可用凭据、凭据被拉黑、额度将尽、
  充值卡临期、密钥受限五类,状态条计数与面板读同一份结果
- 订阅账号按设计改成一个账号一条进度条,取剩余最少的那个窗口;
  额度宽裕时不写重置时刻,可用是常态不出徽章
- 访问密钥不再溢出:grid 与 flex 子项的 min-width:auto 会让长密钥名
  把徽章顶出卡片,链路上每层都要显式解开;徽章与结论数字不参与压缩
- 补回 HomeAccounts 漏掉的 AppPanel 导入。Vue 对未注册组件不报错,
  整块面板被渲染成 <apppanel> 字面标签,标题与边框全部丢失
- 「还有 N 项」不再套用条目行的三列栅格,此前文字被挤进 14px 的
  图标列,一个字一行
- 待办条目以分组名优先收缩,说明文字先被截断;说明文字同时收短
- 接入面板去掉无用的副标题

* refactor(modern): 精调首页布局与接入交互

* fix(modern): 收紧首页布局并移除客户端协议限制

* fix(modern): 优化首页接入对齐与侧栏密度

* fix(modern): 记住客户端接入选择并修正订阅额度展示

* fix(modern): 统一客户端接入输入尺寸并精简说明

* fix(modern): 优化分组排序与订阅账号周期及并行同步

* fix(modern): 优化订阅额度周期与同步反馈

* fix(modern): 完善首页健康提醒与独立路由检查

* feat(modern): 增加额度窗口满额预估

* refactor(modern): decouple shared frontend utilities

* docs(readme): refresh localized screenshots

* fix(web): harden startup fallback and Group ID parsing
2026-09-16 21:56:51 +08:00

553 lines
19 KiB
Go

package requestlog
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"gpt-load/internal/platform/epochms"
"gpt-load/internal/pricing"
"gpt-load/internal/storage/dbtx"
"gpt-load/internal/storage/models"
"gpt-load/internal/usage"
)
const (
usageDistributionLimit = 5
usageRollbackTimeout = time.Second
)
func (service *Service) QueryUsage(ctx context.Context, input UsageQuery) (UsageReport, error) {
if service == nil || service.db == nil {
return UsageReport{}, fmt.Errorf("query usage: database is nil")
}
if ctx == nil {
ctx = context.Background()
}
bucketWidthMS, err := validateUsageQuery(input)
if err != nil {
return UsageReport{}, err
}
var report UsageReport
err = dbtx.Run(ctx, service.db, dbtx.Options{
Mode: dbtx.ReadSnapshot,
CleanupTimeout: usageRollbackTimeout,
Operation: "usage read transaction",
}, func(connection *gorm.DB) error {
scope := usageWindowScope(connection, input)
if err := validateUsageIntegrity(scope, 0); err != nil {
return err
}
summary, err := queryUsageSummary(scope.Session(&gorm.Session{}))
if err != nil {
return err
}
series, err := queryUsageWindowSeries(scope.Session(&gorm.Session{}), input, bucketWidthMS)
if err != nil {
return err
}
distributions, err := queryUsageDistributions(
scope.Session(&gorm.Session{}), summary, input.SelfScoped,
)
if err != nil {
return err
}
report = UsageReport{
Summary: summary, Series: series, Distributions: distributions,
}
return nil
})
if err != nil {
return UsageReport{}, fmt.Errorf("query usage: %w", err)
}
return report, nil
}
func validateUsageQuery(input UsageQuery) (int64, error) {
_, bucketWidthMS, err := ResolveUsageTimeBucket(input.FromMS, input.ToMS)
if err != nil {
return 0, err
}
if input.AccessKeyID != nil && *input.AccessKeyID == 0 {
return 0, fmt.Errorf("query usage: invalid access key scope")
}
if input.ChannelID != "" && !validChannelID(string(input.ChannelID)) {
return 0, fmt.Errorf("query usage: invalid channel scope")
}
if input.CredentialID != nil && *input.CredentialID == 0 {
return 0, fmt.Errorf("query usage: invalid credential scope")
}
return bucketWidthMS, nil
}
func queryUsageDistributions(
scope *gorm.DB,
summary UsageAggregate,
selfScoped bool,
) (UsageDistributions, error) {
result := UsageDistributions{
Group: make(map[UsageDistributionMetric]UsageDistribution, 3),
Model: make(map[UsageDistributionMetric]UsageDistribution, 3),
AccessKey: make(map[UsageDistributionMetric]UsageDistribution, 3),
}
dimensions := []UsageDistributionDimension{
UsageDistributionDimensionGroup,
UsageDistributionDimensionModel,
UsageDistributionDimensionAccessKey,
}
if selfScoped {
dimensions = []UsageDistributionDimension{UsageDistributionDimensionModel}
}
for _, dimension := range dimensions {
for _, metric := range []UsageDistributionMetric{
UsageDistributionMetricRequests,
UsageDistributionMetricTokens,
UsageDistributionMetricCost,
} {
distribution, err := queryUsageDistribution(scope, summary, dimension, metric)
if err != nil {
return UsageDistributions{}, err
}
switch dimension {
case UsageDistributionDimensionGroup:
result.Group[metric] = distribution
case UsageDistributionDimensionModel:
result.Model[metric] = distribution
case UsageDistributionDimensionAccessKey:
result.AccessKey[metric] = distribution
}
}
}
return result, nil
}
func usageStatScope(db *gorm.DB, input UsageQuery, groupIDs ...uint) *gorm.DB {
scope := db.Session(&gorm.Session{NewDB: true}).Model(&models.UsageStat{}).
Where("bucket_start_ms >= ? AND bucket_start_ms < ?", input.FromMS, input.ToMS).
// Older versions aggregated zero-attempt requests under the unbound
// (group_id=0, model='') key. Keep those derived rows invisible so home
// and monitor share the current contract.
Where("NOT (group_id = ? AND model = ?)", 0, "")
if len(groupIDs) > 0 {
scope = scope.Where("group_id IN ?", groupIDs)
}
if input.GroupID != nil {
scope = scope.Where("group_id = ?", *input.GroupID)
}
if input.ChannelID != "" {
scope = scope.Where("channel_id = ?", input.ChannelID)
}
if input.CredentialID != nil {
scope = scope.Where("credential_id = ?", *input.CredentialID)
}
if input.AccessKeyID != nil {
scope = scope.Where("access_key_id = ?", *input.AccessKeyID)
}
if input.UpstreamModel != "" {
scope = scope.Where("model = ?", input.UpstreamModel)
}
return scope
}
func validateUsageStatIntegrity(scope *gorm.DB) error {
return validateUsageIntegrity(scope, epochms.MillisecondsPerHour)
}
func validateUsageIntegrity(scope *gorm.DB, bucketAlignmentMS int64) error {
var integrity usageStatIntegrity
alignmentExpression := "?"
var arguments []any
if bucketAlignmentMS == 0 {
// 混合行集携带原始来源的桶宽,不能把损坏的小时记录按分钟桶放行。
alignmentExpression = "bucket_alignment_ms"
} else {
arguments = append(arguments, bucketAlignmentMS)
}
if err := scope.Session(&gorm.Session{}).Select(`
COALESCE(MAX(CASE
WHEN bucket_start_ms < 0
OR bucket_start_ms % `+alignmentExpression+` != 0
THEN 1 ELSE 0 END), 0) AS invalid_bucket,
COALESCE(MAX(CASE
WHEN request_count < 0 OR success_count < 0 OR failure_count < 0
OR usage_missing_count < 0 OR partial_count < 0 OR unpriced_request_count < 0
OR pricing_partial_count < 0
OR CASE
WHEN request_count < success_count THEN 1
WHEN request_count - success_count != failure_count THEN 1
ELSE 0
END = 1
THEN 1 ELSE 0 END), 0) AS invalid_count,
COALESCE(MAX(CASE
WHEN uncached_input_tokens < 0 OR output_tokens < 0 OR cache_read_tokens < 0
OR cache_write_5m_tokens < 0 OR cache_write_1h_tokens < 0
OR cache_write_unknown_tokens < 0
THEN 1 ELSE 0 END), 0) AS invalid_token,
COALESCE(MAX(CASE
WHEN estimated_cost_nano_usd < 0
THEN 1 ELSE 0 END), 0) AS invalid_cost
`, arguments...).Find(&integrity).Error; err != nil {
return fmt.Errorf("check usage stat integrity: %w", err)
}
if integrity.InvalidBucket != 0 || integrity.InvalidCount != 0 ||
integrity.InvalidToken != 0 || integrity.InvalidCost != 0 {
return fmt.Errorf("check usage stat integrity: corrupt row")
}
return nil
}
func queryUsageSummary(scope *gorm.DB) (UsageAggregate, error) {
var summary UsageAggregate
if err := scope.Select(usageAggregateSelect).Find(&summary).Error; err != nil {
return UsageAggregate{}, fmt.Errorf("query usage summary: %w", err)
}
if err := validateUsageAggregate(summary); err != nil {
return UsageAggregate{}, fmt.Errorf("validate usage summary: %w", err)
}
return summary, nil
}
func queryUsageSeries(scope *gorm.DB, bucketWidthMS int64) ([]UsageSeriesPoint, error) {
if bucketWidthMS < epochms.MillisecondsPerHour ||
bucketWidthMS > epochms.MillisecondsPerDay ||
bucketWidthMS%epochms.MillisecondsPerHour != 0 {
return nil, fmt.Errorf("query usage series: invalid bucket width %d", bucketWidthMS)
}
var source []usageHourPoint
if err := scope.Select("bucket_start_ms, " + usageAggregateSelect).
Group("bucket_start_ms").
Order("bucket_start_ms ASC").
Find(&source).Error; err != nil {
return nil, fmt.Errorf("query usage source series: %w", err)
}
for _, point := range source {
if err := validateUsageAggregate(point.UsageAggregate); err != nil {
return nil, fmt.Errorf("validate usage source series: %w", err)
}
}
if bucketWidthMS == epochms.MillisecondsPerHour {
series := make([]UsageSeriesPoint, 0, len(source))
for _, point := range source {
series = append(series, UsageSeriesPoint{
BucketStartMS: point.BucketStartMS,
BucketEndMS: point.BucketStartMS + epochms.MillisecondsPerHour,
UsageAggregate: point.UsageAggregate,
})
}
return series, nil
}
return mergeUsageHours(source, bucketWidthMS)
}
func queryUsageDistribution(
scope *gorm.DB,
summary UsageAggregate,
dimension UsageDistributionDimension,
metric UsageDistributionMetric,
) (UsageDistribution, error) {
var rows []usageDistributionRow
query := scope.Session(&gorm.Session{})
switch dimension {
case UsageDistributionDimensionGroup:
query = query.Select("group_id, 0 AS access_key_id, '' AS model, "+usageDistributionAggregateSelect).
Where("group_id IN (?)", scope.Session(&gorm.Session{NewDB: true}).
Model(&models.Group{}).Select("id")).
Group("group_id")
case UsageDistributionDimensionModel:
query = query.Select("0 AS group_id, 0 AS access_key_id, model, " + usageDistributionAggregateSelect).
Group("model")
case UsageDistributionDimensionAccessKey:
query = query.Select("0 AS group_id, access_key_id, '' AS model, "+usageDistributionAggregateSelect).
Where("access_key_id IN (?)", scope.Session(&gorm.Session{NewDB: true}).
Model(&models.AccessKey{}).Select("id")).
Group("access_key_id")
default:
return UsageDistribution{}, fmt.Errorf(
"query usage distribution: unsupported dimension %q",
dimension,
)
}
switch metric {
case UsageDistributionMetricRequests:
query = query.
Order("SUM(request_count) DESC").
Order("SUM(estimated_cost_nano_usd) DESC")
case UsageDistributionMetricTokens:
query = query.
Order(usageDistributionTotalTokensExpression + " DESC").
Order("SUM(request_count) DESC").
Order("SUM(estimated_cost_nano_usd) DESC")
case UsageDistributionMetricCost:
query = query.
Order("SUM(estimated_cost_nano_usd) DESC").
Order("SUM(request_count) DESC")
default:
return UsageDistribution{}, fmt.Errorf(
"query usage distribution: unsupported metric %q",
metric,
)
}
switch dimension {
case UsageDistributionDimensionGroup:
query = query.Order("group_id ASC")
case UsageDistributionDimensionModel:
query = query.Order("model ASC")
case UsageDistributionDimensionAccessKey:
query = query.Order("access_key_id ASC")
}
if err := query.
Limit(usageDistributionLimit).
Find(&rows).Error; err != nil {
return UsageDistribution{}, fmt.Errorf("query usage distribution: %w", err)
}
result := UsageDistribution{
Dimension: dimension,
Metric: metric,
Items: make([]UsageDistributionItem, 0, len(rows)),
}
visible := UsageDistributionAggregate{}
for _, row := range rows {
var err error
visible, err = addUsageDistributionAggregates(visible, row.UsageDistributionAggregate)
if err != nil {
return UsageDistribution{}, fmt.Errorf("sum usage distribution: %w", err)
}
result.Items = append(result.Items, UsageDistributionItem{
GroupID: row.GroupID, AccessKeyID: row.AccessKeyID, Model: row.Model,
UsageDistributionAggregate: row.UsageDistributionAggregate,
})
}
other, err := subtractUsageDistributionAggregate(summary, visible)
if err != nil {
return UsageDistribution{}, fmt.Errorf("calculate other usage distribution: %w", err)
}
if other.RequestCount > 0 || other.TotalTokens > 0 || other.EstimatedCostNanoUSD > 0 {
result.Other = &other
}
return result, nil
}
func mergeUsageHours(source []usageHourPoint, bucketWidthMS int64) ([]UsageSeriesPoint, error) {
// queryUsageSeries orders source by bucket_start_ms ASC; adjacent-only
// bucket folding depends on it.
series := make([]UsageSeriesPoint, 0, len(source))
for _, point := range source {
bucketStartMS, err := epochms.AlignDown(
point.BucketStartMS,
bucketWidthMS,
)
if err != nil {
return nil, fmt.Errorf("align usage bucket: %w", err)
}
if len(series) == 0 || series[len(series)-1].BucketStartMS != bucketStartMS {
series = append(series, UsageSeriesPoint{
BucketStartMS: bucketStartMS,
BucketEndMS: bucketStartMS + bucketWidthMS,
UsageAggregate: point.UsageAggregate,
})
continue
}
merged, err := addUsageAggregates(series[len(series)-1].UsageAggregate, point.UsageAggregate)
if err != nil {
return nil, fmt.Errorf("merge usage bucket %d: %w", bucketStartMS, err)
}
series[len(series)-1].UsageAggregate = merged
}
return series, nil
}
func addUsageAggregates(left, right UsageAggregate) (UsageAggregate, error) {
result := UsageAggregate{}
fields := []struct {
name string
left, right int64
target *int64
}{
{"request count", left.RequestCount, right.RequestCount, &result.RequestCount},
{"success count", left.SuccessCount, right.SuccessCount, &result.SuccessCount},
{"failure count", left.FailureCount, right.FailureCount, &result.FailureCount},
{"uncached input tokens", left.UncachedInputTokens, right.UncachedInputTokens, &result.UncachedInputTokens},
{"cache read tokens", left.CacheReadTokens, right.CacheReadTokens, &result.CacheReadTokens},
{"cache write 5m tokens", left.CacheWrite5MTokens, right.CacheWrite5MTokens, &result.CacheWrite5MTokens},
{"cache write 1h tokens", left.CacheWrite1HTokens, right.CacheWrite1HTokens, &result.CacheWrite1HTokens},
{"cache write unknown tokens", left.CacheWriteUnknownTokens, right.CacheWriteUnknownTokens, &result.CacheWriteUnknownTokens},
{"output tokens", left.OutputTokens, right.OutputTokens, &result.OutputTokens},
{"usage missing count", left.UsageMissingCount, right.UsageMissingCount, &result.UsageMissingCount},
{"partial count", left.PartialCount, right.PartialCount, &result.PartialCount},
{"unpriced request count", left.UnpricedRequestCount, right.UnpricedRequestCount, &result.UnpricedRequestCount},
{"pricing partial count", left.PricingPartialCount, right.PricingPartialCount, &result.PricingPartialCount},
}
for _, field := range fields {
value, ok := usage.CheckedAdd(field.left, field.right)
if !ok {
return UsageAggregate{}, fmt.Errorf("%s overflow or negative", field.name)
}
*field.target = value
}
cost, ok := pricing.CheckedAddNanoUSD(
pricing.NanoUSD(left.EstimatedCostNanoUSD),
pricing.NanoUSD(right.EstimatedCostNanoUSD),
)
if !ok {
return UsageAggregate{}, fmt.Errorf("estimated cost nano USD overflow or negative")
}
result.EstimatedCostNanoUSD = int64(cost)
if err := validateUsageAggregate(result); err != nil {
return UsageAggregate{}, err
}
return result, nil
}
func addUsageDistributionAggregates(
left, right UsageDistributionAggregate,
) (UsageDistributionAggregate, error) {
requests, ok := usage.CheckedAdd(left.RequestCount, right.RequestCount)
if !ok {
return UsageDistributionAggregate{}, fmt.Errorf("request count overflow or negative")
}
tokens, ok := usage.CheckedAdd(left.TotalTokens, right.TotalTokens)
if !ok {
return UsageDistributionAggregate{}, fmt.Errorf("total tokens overflow or negative")
}
cost, ok := pricing.CheckedAddNanoUSD(
pricing.NanoUSD(left.EstimatedCostNanoUSD),
pricing.NanoUSD(right.EstimatedCostNanoUSD),
)
if !ok {
return UsageDistributionAggregate{}, fmt.Errorf("estimated cost overflow or negative")
}
return UsageDistributionAggregate{
RequestCount: requests, TotalTokens: tokens, EstimatedCostNanoUSD: int64(cost),
}, nil
}
func subtractUsageDistributionAggregate(
total UsageAggregate,
part UsageDistributionAggregate,
) (UsageDistributionAggregate, error) {
totalTokens, err := usageAggregateTotalTokens(total)
if err != nil {
return UsageDistributionAggregate{}, err
}
if part.RequestCount < 0 || part.RequestCount > total.RequestCount ||
part.TotalTokens < 0 || part.TotalTokens > totalTokens ||
part.EstimatedCostNanoUSD < 0 || part.EstimatedCostNanoUSD > total.EstimatedCostNanoUSD {
return UsageDistributionAggregate{}, fmt.Errorf("invalid distribution remainder")
}
return UsageDistributionAggregate{
RequestCount: total.RequestCount - part.RequestCount,
TotalTokens: totalTokens - part.TotalTokens,
EstimatedCostNanoUSD: total.EstimatedCostNanoUSD - part.EstimatedCostNanoUSD,
}, nil
}
func validateUsageAggregate(aggregate UsageAggregate) error {
fields := []struct {
name string
value int64
}{
{"request count", aggregate.RequestCount},
{"success count", aggregate.SuccessCount},
{"failure count", aggregate.FailureCount},
{"uncached input tokens", aggregate.UncachedInputTokens},
{"cache read tokens", aggregate.CacheReadTokens},
{"cache write 5m tokens", aggregate.CacheWrite5MTokens},
{"cache write 1h tokens", aggregate.CacheWrite1HTokens},
{"cache write unknown tokens", aggregate.CacheWriteUnknownTokens},
{"output tokens", aggregate.OutputTokens},
{"usage missing count", aggregate.UsageMissingCount},
{"partial count", aggregate.PartialCount},
{"unpriced request count", aggregate.UnpricedRequestCount},
{"pricing partial count", aggregate.PricingPartialCount},
}
for _, field := range fields {
if field.value < 0 {
return fmt.Errorf("negative %s", field.name)
}
}
requestCount, ok := usage.CheckedAdd(aggregate.SuccessCount, aggregate.FailureCount)
if !ok || requestCount != aggregate.RequestCount {
return fmt.Errorf("request count does not equal success plus failure")
}
if _, err := usageAggregateTotalTokens(aggregate); err != nil {
return err
}
if aggregate.EstimatedCostNanoUSD < 0 {
return fmt.Errorf("invalid cost")
}
return nil
}
func usageAggregateTotalTokens(aggregate UsageAggregate) (int64, error) {
totalTokens := int64(0)
for _, value := range []int64{
aggregate.UncachedInputTokens,
aggregate.CacheReadTokens,
aggregate.CacheWrite5MTokens,
aggregate.CacheWrite1HTokens,
aggregate.CacheWriteUnknownTokens,
aggregate.OutputTokens,
} {
var added bool
totalTokens, added = usage.CheckedAdd(totalTokens, value)
if !added {
return 0, fmt.Errorf("total tokens overflow")
}
}
return totalTokens, nil
}
// Aliases follow GORM's snake-case mapping for embedded UsageAggregate fields (for example, 5M -> 5_m).
const usageAggregateSelect = "" +
"COALESCE(SUM(request_count), 0) AS request_count, " +
"COALESCE(SUM(success_count), 0) AS success_count, " +
"COALESCE(SUM(failure_count), 0) AS failure_count, " +
"COALESCE(SUM(uncached_input_tokens), 0) AS uncached_input_tokens, " +
"COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens, " +
"COALESCE(SUM(cache_write_5m_tokens), 0) AS cache_write5_m_tokens, " +
"COALESCE(SUM(cache_write_1h_tokens), 0) AS cache_write1_h_tokens, " +
"COALESCE(SUM(cache_write_unknown_tokens), 0) AS cache_write_unknown_tokens, " +
"COALESCE(SUM(output_tokens), 0) AS output_tokens, " +
"COALESCE(SUM(estimated_cost_nano_usd), 0) AS estimated_cost_nano_usd, " +
"COALESCE(SUM(usage_missing_count), 0) AS usage_missing_count, " +
"COALESCE(SUM(partial_count), 0) AS partial_count, " +
"COALESCE(SUM(unpriced_request_count), 0) AS unpriced_request_count, " +
"COALESCE(SUM(pricing_partial_count), 0) AS pricing_partial_count"
const usageDistributionTotalTokensExpression = "" +
"COALESCE(SUM(uncached_input_tokens), 0) + " +
"COALESCE(SUM(cache_read_tokens), 0) + " +
"COALESCE(SUM(cache_write_5m_tokens), 0) + " +
"COALESCE(SUM(cache_write_1h_tokens), 0) + " +
"COALESCE(SUM(cache_write_unknown_tokens), 0) + " +
"COALESCE(SUM(output_tokens), 0)"
const usageDistributionAggregateSelect = "" +
"COALESCE(SUM(request_count), 0) AS request_count, " +
usageDistributionTotalTokensExpression + " AS total_tokens, " +
"COALESCE(SUM(estimated_cost_nano_usd), 0) AS estimated_cost_nano_usd"
type usageHourPoint struct {
BucketStartMS int64
UsageAggregate
}
type usageStatIntegrity struct {
InvalidBucket int64
InvalidCount int64
InvalidToken int64
InvalidCost int64
}
type usageDistributionRow struct {
GroupID uint
AccessKeyID uint
Model string
UsageDistributionAggregate
}