mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 05:31:19 +02:00
feat: add detailed token usage monitoring and cache write metrics
This commit is contained in:
1 parent
deda642bd6
commit
b3d3f48c40
26 files changed
+666
-45
No files matched your search
@@ -0,0 +1,54 @@
|
||||
# Token 用量监控:调研与实现
|
||||
|
||||
调研时间:2026-10-11。
|
||||
|
||||
## 调研结论
|
||||
|
||||
| 参考 | 官方能力 | 本项目采用的设计 |
|
||||
| --- | --- | --- |
|
||||
| [ZCode 使用统计](https://zcode.z.ai/cn/docs/usage-stats) | 本地会话统计与远端编程套餐额度分别展示;支持时间范围、日趋势和模型占比 | 首页增加可展开的本地用量面板,提供 7/30/90 天、日趋势、模型占比与明细 |
|
||||
| [DeepSeek 上下文缓存](https://api-docs.deepseek.com/guides/kv_cache/) | 响应 usage 的 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` 区分输入缓存命中和未命中 | 将命中字段补入 OpenAI 标准缓存读取字段;不把未命中错误映射为缓存写入 |
|
||||
| [Eino v0.9.21](https://github.com/cloudwego/eino/releases/tag/v0.9.21)、[schema PR #1309](https://github.com/cloudwego/eino/pull/1309) | 稳定版新增 `PromptTokenDetails.CacheWriteTokens`,原有缓存读取、输入、输出、推理字段继续保留 | 使用稳定版的标准字段贯穿流式归并、运行汇总、数据库和 UI |
|
||||
| [Eino-ext PR #1015](https://github.com/cloudwego/eino-ext/pull/1015) | 模型适配器传递缓存写入计数 | 升级 agenticclaude 0.1.7、agenticopenai 0.2.4 与对应 OpenAI ACL,而非仅升级框架 |
|
||||
| [Eino 回调与 Trace](https://www.cloudwego.io/docs/eino/quick_start/chapter_06_callback_and_trace/) | 组件回调可收集模型生命周期、用量与耗时 | 保留现有运行事件采集;未来要覆盖所有内部调用,应另外设计统一模型调用账本 |
|
||||
|
||||
版本已通过 Go module 解析和本地源码确认;未采用 0.10 alpha。DeepSeek 原始字段当前不会自动进入 Eino 的标准缓存读取计数,因此在项目现有兼容 HTTP 层补齐,覆盖普通 JSON 和 SSE 流式响应。已有标准 `cached_tokens` 优先,未知字段使用 RawMessage 保存,避免浮点转换损失数字精度。
|
||||
|
||||
## 统计口径
|
||||
|
||||
- `promptTokens`:模型返回的全部输入。
|
||||
- `completionTokens`:全部输出,包括供应商计入其中的推理。
|
||||
- `totalTokens`:使用供应商的总量;缺省时由输入加输出补齐。
|
||||
- `cachedTokens`:输入中的缓存读取部分。
|
||||
- `cacheWriteTokens`:输入中的缓存写入部分。
|
||||
- `reasoningTokens`:输出中的推理部分。
|
||||
- 缓存读取、写入、推理都是细分,不能再加到总量。缓存读取占输入的比例为 `cachedTokens / promptTokens`;没有输入时显示破折号。
|
||||
- 一个流式模型输出可能多次返回累计计数,沿用按字段取最大值的归并方式,整条输出结束后只加入一次运行汇总。部分输出发生错误时,仍可保留已返回用量。
|
||||
|
||||
DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本长度推导账单级计数,也不能根据缺失字段断言供应商未使用缓存。旧记录的新增字段默认为 0,表示未记录该细分。
|
||||
|
||||
## 功能入口与数据流
|
||||
|
||||
点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。
|
||||
|
||||
运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。
|
||||
|
||||
新增 `model_token_usage.cache_write_tokens INTEGER NOT NULL DEFAULT 0`。启动时现有声明式 SQLite schema reconciliation 自动补列;旧数据库无需手工执行迁移。现有事件 ID 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。
|
||||
|
||||
面板采用文本节点渲染供应商模型名称,表格在手机上局部横向滚动,提供中文、英文、俄文标签。聊天轮次 tooltip、主对话与 WebShell 时间线均增加缓存写入字段。
|
||||
|
||||
## 当前边界与后续方向
|
||||
|
||||
统计基于已记录的 Agent 输出事件,包括已有采集链路中的子 Agent,**不是全站 API 请求账本**。摘要压缩、检索 embedding、隐藏的模型调用、没有返回 usage 的请求不能由当前链路完整覆盖;因请求取消丢失最终 usage 的消耗也无法还原。
|
||||
|
||||
模型排行按运行配置的模型名称归类,发生 failover 的运行可能混合多个模型,不能据此核算逐模型账单。近期列表每项是运行汇总,不是单次 HTTP 请求;模型调用次数也仅统计返回了非空 usage 的输出。
|
||||
|
||||
本次不展示费用或套餐剩余额度。自定义中转渠道和订阅套餐的价格、计费方式不同,token 乘固定单价无法可靠代表费用。下一步如需要完整成本管理,应先建设按请求记录的账本:调用来源、真实供应商与模型、成功/错误/取消、耗时、TTFT、usage 是否返回、价格版本和币种;统一覆盖主/子 Agent、压缩、重试、embedding,单独对接供应商额度接口。
|
||||
|
||||
## 验证
|
||||
|
||||
- `go test ./...`:全仓通过。
|
||||
- `node --test web/static/js/*.test.cjs`:295 项通过。
|
||||
- 针对缓存写入的保存/分组/近期记录、旧 schema 补列、用户可见范围、流式去重和总量回退添加回归测试。
|
||||
- DeepSeek 普通 JSON/SSE 的缓存字段转换、标准字段优先、错误/终止块保持、超大数值精度验证通过。
|
||||
- 浏览器使用合成数据验证桌面、390px 手机、深色模式、模型名称转义、空数据与接口不可用状态;没有读取真实运行数据库,也没有调用付费模型。
|
||||
@@ -7,14 +7,14 @@ go 1.25.0
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/cloudwego/eino v0.9.14
|
||||
github.com/cloudwego/eino v0.9.21
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/markdown v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b
|
||||
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3
|
||||
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1
|
||||
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7
|
||||
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
@@ -74,7 +74,7 @@ require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
@@ -105,11 +105,11 @@ require (
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.6 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
github.com/openai/openai-go/v3 v3.35.0 // indirect
|
||||
github.com/openai/openai-go/v3 v3.44.0 // indirect
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
@@ -81,6 +81,8 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.9.14 h1:suNVibjtkPMiW7csFBdBqN3FRG0nRlCqoGKE7t0UDwY=
|
||||
github.com/cloudwego/eino v0.9.14/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
|
||||
github.com/cloudwego/eino v0.9.21 h1:kBAyAjsnPq5NDWjt0VdmP9HY/XVUHs05LoeqybudPRQ=
|
||||
github.com/cloudwego/eino v0.9.21/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6 h1:pxKAWcveIBWrbz28H2bDAMyJZnhysa9bh2Kh5s7iH7s=
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.2.6/go.mod h1:uJlpYclMA2ScgUlHWHu2om+DDguV2Lzc+TNulWBXqfo=
|
||||
github.com/cloudwego/eino-ext/components/document/loader/file v0.0.0-20260427010451-749e3706378b h1:GIOC/VnXuSQx79mnQ3HgMvECjtyqvpJipmSUTFFfVsc=
|
||||
@@ -93,12 +95,18 @@ github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-
|
||||
github.com/cloudwego/eino-ext/components/embedding/openai v0.0.0-20260427010451-749e3706378b/go.mod h1:zyPrZT2bO6LyRJgVksQowR18jVgyLSvqK93hnO53/Lc=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3 h1:nfO9jUPhIKTHDPyL7rso6y7Mx8cAmrIgkeJC6KqTuXI=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.3/go.mod h1:/dX1P7HNC7D3/nYLDoXBvdKAFnGS4tBbtJ8NrkaXuUk=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7 h1:oxvyuAdZLlKSWYrzXwXS63AokgC3TqSljrzCGNRi4JA=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticclaude v0.1.7/go.mod h1:LsIyLkRviQtfJsGYP6MX9PwqCX4jMtVxR4zR2A7ZB+g=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1 h1:bIMyUxEZdBEofWbXQCozfGxXCsbyNjpZcRMfW+Kt5u0=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.1/go.mod h1:84jUcVAcmlGDRVX0ExABVJEfrIaplaAeRbjFYW9mzkI=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4 h1:M5ODiB1UaYV0I/dL2dmgc/HfZ2WUBV0saM9Tkhk2IDg=
|
||||
github.com/cloudwego/eino-ext/components/model/agenticopenai v0.2.4/go.mod h1:AfLNKxrTFFkJbCWSvOtqKK1ZXEp0hpe26CBQ8GSf5zo=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6 h1:ES/xufN5eqJ3h+9tw/tq6F8kkgnAxBAHVUB6nqKsIDU=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260527084435-846f52bd97c6/go.mod h1:5Xj74dGrfHo1z7I07Fzp3SlTF7Bt4tss3A2FSt8SqQ4=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d h1:FaBHOMj/ZDBmtsI9UMoDMDu9CNZPbjJwK/O26JFuZ8k=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.18-0.20260924031408-10df7cac3f6d/go.mod h1:6yg0Rv7B3jZCQPc/C5s13PNnVewQdaRxOp2OMru7Yw4=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
@@ -272,6 +280,8 @@ github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.6 h1:c74nk9F3+/agOd09eHeQt/Ayty4pw54X2Dj5JHdxcys=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.6/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI=
|
||||
@@ -300,6 +310,8 @@ github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc=
|
||||
github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A=
|
||||
github.com/openai/openai-go/v3 v3.35.0 h1:109x3epXMSE423KW2euR506GGFezcEt0s87MoWejpH0=
|
||||
github.com/openai/openai-go/v3 v3.35.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/openai/openai-go/v3 v3.44.0 h1:kkGh+jb/sKfSh5P74Jk5mCRufaQ0q7oH+lq+pNlWjsk=
|
||||
github.com/openai/openai-go/v3 v3.44.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
|
||||
@@ -30,6 +30,7 @@ type ModelTokenUsage struct {
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
CacheWriteTokens int64 `json:"cacheWriteTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
ReasoningTokens int64 `json:"reasoningTokens"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
@@ -43,6 +44,7 @@ type ModelTokenUsageSummary struct {
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
CacheWriteTokens int64 `json:"cacheWriteTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
ReasoningTokens int64 `json:"reasoningTokens"`
|
||||
}
|
||||
@@ -56,6 +58,7 @@ type ModelTokenUsageBreakdown struct {
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
TotalTokens int64 `json:"totalTokens"`
|
||||
CacheWriteTokens int64 `json:"cacheWriteTokens"`
|
||||
CachedTokens int64 `json:"cachedTokens"`
|
||||
ReasoningTokens int64 `json:"reasoningTokens"`
|
||||
}
|
||||
@@ -100,6 +103,7 @@ func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID
|
||||
CompletionTokens: usageInt64(m["completionTokens"]),
|
||||
TotalTokens: usageInt64(m["totalTokens"]),
|
||||
CachedTokens: usageInt64(m["cachedTokens"]),
|
||||
CacheWriteTokens: usageInt64(m["cacheWriteTokens"]),
|
||||
ReasoningTokens: usageInt64(m["reasoningTokens"]),
|
||||
}
|
||||
if usage.TotalTokens == 0 && (usage.PromptTokens > 0 || usage.CompletionTokens > 0) {
|
||||
@@ -108,7 +112,7 @@ func modelTokenUsageFromProcessDetail(messageID, conversationID, processDetailID
|
||||
if usage.ProcessDetailID == "" || usage.MessageID == "" || usage.ConversationID == "" {
|
||||
return ModelTokenUsage{}, false
|
||||
}
|
||||
if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.ReasoningTokens == 0 {
|
||||
if usage.ModelCalls == 0 && usage.TotalTokens == 0 && usage.PromptTokens == 0 && usage.CompletionTokens == 0 && usage.CachedTokens == 0 && usage.CacheWriteTokens == 0 && usage.ReasoningTokens == 0 {
|
||||
return ModelTokenUsage{}, false
|
||||
}
|
||||
return usage, true
|
||||
@@ -224,9 +228,9 @@ func (db *DB) UpsertModelTokenUsage(usage ModelTokenUsage) error {
|
||||
INSERT INTO model_token_usage (
|
||||
id, process_detail_id, message_id, conversation_id, project_id,
|
||||
source, orchestration, reason, model, model_calls,
|
||||
prompt_tokens, completion_tokens, total_tokens, cached_tokens, reasoning_tokens,
|
||||
prompt_tokens, completion_tokens, total_tokens, cached_tokens, cache_write_tokens, reasoning_tokens,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(process_detail_id) DO UPDATE SET
|
||||
message_id = excluded.message_id,
|
||||
conversation_id = excluded.conversation_id,
|
||||
@@ -240,12 +244,13 @@ ON CONFLICT(process_detail_id) DO UPDATE SET
|
||||
completion_tokens = excluded.completion_tokens,
|
||||
total_tokens = excluded.total_tokens,
|
||||
cached_tokens = excluded.cached_tokens,
|
||||
cache_write_tokens = excluded.cache_write_tokens,
|
||||
reasoning_tokens = excluded.reasoning_tokens,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
usage.ID, usage.ProcessDetailID, usage.MessageID, usage.ConversationID, projectValue,
|
||||
usage.Source, usage.Orchestration, usage.Reason, usage.Model, usage.ModelCalls,
|
||||
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.ReasoningTokens,
|
||||
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens, usage.CachedTokens, usage.CacheWriteTokens, usage.ReasoningTokens,
|
||||
createdAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -318,7 +323,7 @@ func (db *DB) GetModelTokenUsageStats(filter ModelTokenUsageFilter) (*ModelToken
|
||||
return nil, err
|
||||
}
|
||||
byDay, err := db.queryModelTokenUsageBreakdown(
|
||||
"SELECT date(mtu.created_at) AS k, date(mtu.created_at) AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at) ORDER BY k DESC LIMIT ?",
|
||||
"SELECT date(mtu.created_at, 'localtime') AS k, date(mtu.created_at, 'localtime') AS label, "+modelTokenUsageSummarySelect("mtu")+" FROM model_token_usage mtu JOIN conversations c ON c.id = mtu.conversation_id"+where+" GROUP BY date(mtu.created_at, 'localtime') ORDER BY k DESC LIMIT ?",
|
||||
append(args, filter.Days)...,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -363,7 +368,8 @@ COALESCE(SUM(%sprompt_tokens), 0),
|
||||
COALESCE(SUM(%scompletion_tokens), 0),
|
||||
COALESCE(SUM(%stotal_tokens), 0),
|
||||
COALESCE(SUM(%scached_tokens), 0),
|
||||
COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p)
|
||||
COALESCE(SUM(%scache_write_tokens), 0),
|
||||
COALESCE(SUM(%sreasoning_tokens), 0)`, p, p, p, p, p, p, p, p)
|
||||
}
|
||||
|
||||
func buildModelTokenUsageWhere(filter ModelTokenUsageFilter, usageAlias, convAlias string) (string, []interface{}) {
|
||||
@@ -394,7 +400,7 @@ func (db *DB) queryModelTokenUsageSummary(query string, args ...interface{}) (Mo
|
||||
var s ModelTokenUsageSummary
|
||||
err := db.QueryRow(query, args...).Scan(
|
||||
&s.Events, &s.ModelCalls, &s.PromptTokens, &s.CompletionTokens,
|
||||
&s.TotalTokens, &s.CachedTokens, &s.ReasoningTokens,
|
||||
&s.TotalTokens, &s.CachedTokens, &s.CacheWriteTokens, &s.ReasoningTokens,
|
||||
)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("查询模型Token用量汇总失败: %w", err)
|
||||
@@ -413,7 +419,7 @@ func (db *DB) queryModelTokenUsageBreakdown(query string, args ...interface{}) (
|
||||
var row ModelTokenUsageBreakdown
|
||||
if err := rows.Scan(
|
||||
&row.Key, &row.Label, &row.Events, &row.ModelCalls, &row.PromptTokens,
|
||||
&row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.ReasoningTokens,
|
||||
&row.CompletionTokens, &row.TotalTokens, &row.CachedTokens, &row.CacheWriteTokens, &row.ReasoningTokens,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("扫描模型Token用量分组失败: %w", err)
|
||||
}
|
||||
@@ -438,7 +444,7 @@ func (db *DB) ListModelTokenUsage(filter ModelTokenUsageFilter) ([]ModelTokenUsa
|
||||
SELECT mtu.id, mtu.process_detail_id, mtu.message_id, mtu.conversation_id,
|
||||
COALESCE(mtu.project_id, ''), mtu.source, mtu.orchestration, mtu.reason, mtu.model,
|
||||
mtu.model_calls, mtu.prompt_tokens, mtu.completion_tokens, mtu.total_tokens,
|
||||
mtu.cached_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at
|
||||
mtu.cached_tokens, mtu.cache_write_tokens, mtu.reasoning_tokens, mtu.created_at, mtu.updated_at
|
||||
FROM model_token_usage mtu
|
||||
JOIN conversations c ON c.id = mtu.conversation_id`+where+`
|
||||
ORDER BY mtu.created_at DESC, mtu.rowid DESC
|
||||
@@ -455,7 +461,7 @@ LIMIT ?`, args...)
|
||||
&u.ID, &u.ProcessDetailID, &u.MessageID, &u.ConversationID, &u.ProjectID,
|
||||
&u.Source, &u.Orchestration, &u.Reason, &u.Model, &u.ModelCalls,
|
||||
&u.PromptTokens, &u.CompletionTokens, &u.TotalTokens, &u.CachedTokens,
|
||||
&u.ReasoningTokens, &createdAt, &updatedAt,
|
||||
&u.CacheWriteTokens, &u.ReasoningTokens, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("扫描模型Token用量明细失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) {
|
||||
"totalTokens": 13,
|
||||
"cachedTokens": 4,
|
||||
"reasoningTokens": 1,
|
||||
"cacheWriteTokens": 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail: %v", err)
|
||||
}
|
||||
@@ -36,12 +37,15 @@ func TestModelTokenUsagePersistsFromUsageProcessDetail(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelTokenUsageStats: %v", err)
|
||||
}
|
||||
if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 {
|
||||
if stats.Summary.Events != 1 || stats.Summary.ModelCalls != 2 || stats.Summary.TotalTokens != 13 || stats.Summary.CachedTokens != 4 || stats.Summary.ReasoningTokens != 1 || stats.Summary.CacheWriteTokens != 2 {
|
||||
t.Fatalf("summary = %#v", stats.Summary)
|
||||
}
|
||||
if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 {
|
||||
if len(stats.ByModel) != 1 || stats.ByModel[0].Key != "gpt-test" || stats.ByModel[0].TotalTokens != 13 || stats.ByModel[0].CacheWriteTokens != 2 {
|
||||
t.Fatalf("by model = %#v", stats.ByModel)
|
||||
}
|
||||
if len(stats.Recent) != 1 || stats.Recent[0].CacheWriteTokens != 2 || stats.Today.CacheWriteTokens != 2 || len(stats.ByDay) != 1 || stats.ByDay[0].CacheWriteTokens != 2 {
|
||||
t.Fatalf("cache write usage lost: %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelTokenUsageBackfillIsIdempotent(t *testing.T) {
|
||||
@@ -83,3 +87,36 @@ func newModelTokenUsageTestDB(t *testing.T) *DB {
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestModelTokenUsageStatsRespectConversationAccess(t *testing.T) {
|
||||
db := newModelTokenUsageTestDB(t)
|
||||
var ownID string
|
||||
for _, owner := range []string{"alice", "bob"} {
|
||||
conv, err := db.CreateConversation(owner, ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner == "alice" {
|
||||
ownID = conv.ID
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE conversations SET owner_user_id = ? WHERE id = ?`, owner, conv.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, err := db.AddMessage(conv.ID, "assistant", "done", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AddProcessDetail(msg.ID, conv.ID, modelTokenUsageEventType, "usage", map[string]interface{}{
|
||||
"model": owner, "modelCalls": 1, "promptTokens": 100, "completionTokens": 20, "cacheWriteTokens": 30,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stats, err := db.GetModelTokenUsageStats(ModelTokenUsageFilter{Access: RBACListAccess{UserID: "alice", Scope: "own"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Summary.Events != 1 || stats.Summary.TotalTokens != 120 || stats.Summary.CacheWriteTokens != 30 || len(stats.Recent) != 1 || stats.Recent[0].ConversationID != ownID || len(stats.ByModel) != 1 || stats.ByModel[0].Key != "alice" {
|
||||
t.Fatalf("usage leaks across users: %#v", stats)
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ func TestStartupRepairsTablesAndUnlistedColumnsPreservesData(t *testing.T) {
|
||||
ALTER TABLE hitl_interrupts DROP COLUMN payload;
|
||||
ALTER TABLE hitl_conversation_configs DROP COLUMN reviewer;
|
||||
ALTER TABLE model_token_usage DROP COLUMN cached_tokens;
|
||||
ALTER TABLE model_token_usage DROP COLUMN cache_write_tokens;
|
||||
ALTER TABLE rbac_users DROP COLUMN display_name;
|
||||
ALTER TABLE messages DROP COLUMN reasoning_content;`)
|
||||
db.Close()
|
||||
|
||||
@@ -321,6 +321,7 @@ CREATE TABLE model_token_usage (
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cached_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
|
||||
@@ -13,6 +13,7 @@ type einoRunUsageSummary struct {
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
CachedTokens int
|
||||
CacheWriteTokens int
|
||||
ReasoningTokens int
|
||||
}
|
||||
|
||||
@@ -42,8 +43,13 @@ func (a *einoRunUsageAccumulator) AddUsage(usage *schema.TokenUsage) bool {
|
||||
a.summary.ModelCalls++
|
||||
a.summary.PromptTokens += usage.PromptTokens
|
||||
a.summary.CompletionTokens += usage.CompletionTokens
|
||||
a.summary.TotalTokens += usage.TotalTokens
|
||||
total := usage.TotalTokens
|
||||
if total == 0 {
|
||||
total = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
a.summary.TotalTokens += total
|
||||
a.summary.CachedTokens += usage.PromptTokenDetails.CachedTokens
|
||||
a.summary.CacheWriteTokens += usage.PromptTokenDetails.CacheWriteTokens
|
||||
a.summary.ReasoningTokens += usage.CompletionTokensDetails.ReasoningTokens
|
||||
return true
|
||||
}
|
||||
@@ -88,6 +94,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
"completionTokens": s.CompletionTokens,
|
||||
"totalTokens": s.TotalTokens,
|
||||
"cachedTokens": s.CachedTokens,
|
||||
"cacheWriteTokens": s.CacheWriteTokens,
|
||||
"reasoningTokens": s.ReasoningTokens,
|
||||
}
|
||||
if progress != nil {
|
||||
@@ -104,6 +111,7 @@ func (a *einoRunUsageAccumulator) EmitOnce(
|
||||
zap.Int("completionTokens", s.CompletionTokens),
|
||||
zap.Int("totalTokens", s.TotalTokens),
|
||||
zap.Int("cachedTokens", s.CachedTokens),
|
||||
zap.Int("cacheWriteTokens", s.CacheWriteTokens),
|
||||
zap.Int("reasoningTokens", s.ReasoningTokens),
|
||||
)
|
||||
}
|
||||
@@ -129,6 +137,9 @@ func maxEinoTokenUsage(dst *schema.TokenUsage, src *schema.TokenUsage) *schema.T
|
||||
if src.PromptTokenDetails.CachedTokens > dst.PromptTokenDetails.CachedTokens {
|
||||
dst.PromptTokenDetails.CachedTokens = src.PromptTokenDetails.CachedTokens
|
||||
}
|
||||
if src.PromptTokenDetails.CacheWriteTokens > dst.PromptTokenDetails.CacheWriteTokens {
|
||||
dst.PromptTokenDetails.CacheWriteTokens = src.PromptTokenDetails.CacheWriteTokens
|
||||
}
|
||||
if src.CompletionTokensDetails.ReasoningTokens > dst.CompletionTokensDetails.ReasoningTokens {
|
||||
dst.CompletionTokensDetails.ReasoningTokens = src.CompletionTokensDetails.ReasoningTokens
|
||||
}
|
||||
@@ -149,5 +160,6 @@ func tokenUsageEmpty(u *schema.TokenUsage) bool {
|
||||
u.CompletionTokens == 0 &&
|
||||
u.TotalTokens == 0 &&
|
||||
u.PromptTokenDetails.CachedTokens == 0 &&
|
||||
u.PromptTokenDetails.CacheWriteTokens == 0 &&
|
||||
u.CompletionTokensDetails.ReasoningTokens == 0)
|
||||
}
|
||||
@@ -79,3 +79,25 @@ func TestMaxEinoTokenUsageUsesLargestStreamChunkValues(t *testing.T) {
|
||||
t.Fatalf("usage = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoRunUsageCacheWriteDoesNotInflateTotal(t *testing.T) {
|
||||
acc := newEinoRunUsageAccumulator()
|
||||
var stream *schema.TokenUsage
|
||||
for i := 0; i < 2; i++ {
|
||||
stream = maxEinoTokenUsage(stream, &schema.TokenUsage{
|
||||
PromptTokens: 100, CompletionTokens: 20,
|
||||
PromptTokenDetails: schema.PromptTokenDetails{CachedTokens: 40, CacheWriteTokens: 30},
|
||||
CompletionTokensDetails: schema.CompletionTokensDetails{ReasoningTokens: 10},
|
||||
})
|
||||
}
|
||||
acc.AddUsage(stream)
|
||||
got := acc.Summary()
|
||||
if got.ModelCalls != 1 || got.TotalTokens != 120 || got.CacheWriteTokens != 30 || got.CachedTokens != 40 || got.ReasoningTokens != 10 {
|
||||
t.Fatalf("summary = %#v", got)
|
||||
}
|
||||
var emitted map[string]interface{}
|
||||
acc.EmitOnce("conv", "deep", "final", "claude-test", func(_, _ string, data interface{}) { emitted = data.(map[string]interface{}) }, nil)
|
||||
if emitted["cacheWriteTokens"] != 30 || emitted["totalTokens"] != 120 {
|
||||
t.Fatalf("event = %#v", emitted)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client
|
||||
transport = &reasoningToolChoiceCompatRoundTripper{base: transport, cfg: cfg}
|
||||
transport = &einoSSEErrorRoundTripper{base: transport}
|
||||
transport = &einoSSESanitizingRoundTripper{base: transport}
|
||||
transport = &einoUsageNormalizingRoundTripper{base: transport}
|
||||
cloned.Transport = transport
|
||||
return &cloned
|
||||
}
|
||||
@@ -19,7 +19,7 @@ package openai
|
||||
//
|
||||
// 该层对调用方完全透明:
|
||||
// - 仅当响应 Content-Type 是 text/event-stream 时介入;普通 JSON 响应原样透传
|
||||
// - data: payload (含 [DONE] 与 {"error":...}) 一字节不改
|
||||
// - data: payload 仅补齐 DeepSeek 缓存用量字段,其余内容原样透传
|
||||
// - 上游真断流 (EOF / connection reset / context cancel) 原样透传
|
||||
|
||||
import (
|
||||
@@ -103,6 +103,11 @@ func (b *einoSSESanitizingBody) Read(p []byte) (int, error) {
|
||||
if line[len(line)-1] != '\n' {
|
||||
line = append(line, '\n')
|
||||
}
|
||||
payload := bytes.TrimSpace(bytes.TrimLeft(line, " \t")[5:])
|
||||
normalized := normalizeEinoUsagePayload(payload)
|
||||
if !bytes.Equal(payload, normalized) {
|
||||
line = append(append([]byte("data: "), normalized...), '\n')
|
||||
}
|
||||
b.pending = line
|
||||
if err != nil {
|
||||
b.err = err
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// normalizeEinoUsagePayload bridges DeepSeek's cache counters to the OpenAI
|
||||
// details understood by Eino. Standard provider fields always take precedence.
|
||||
func normalizeEinoUsagePayload(payload []byte) []byte {
|
||||
if !bytes.Contains(payload, []byte(`"prompt_cache_hit_tokens"`)) {
|
||||
return payload
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if json.Unmarshal(payload, &root) != nil {
|
||||
return payload
|
||||
}
|
||||
var usage map[string]json.RawMessage
|
||||
if json.Unmarshal(root["usage"], &usage) != nil {
|
||||
return payload
|
||||
}
|
||||
hit, ok := usage["prompt_cache_hit_tokens"]
|
||||
if !ok {
|
||||
return payload
|
||||
}
|
||||
var count int64
|
||||
if json.Unmarshal(hit, &count) != nil || count < 0 {
|
||||
return payload
|
||||
}
|
||||
details := map[string]json.RawMessage{}
|
||||
if raw := usage["prompt_tokens_details"]; len(raw) > 0 && string(raw) != "null" {
|
||||
if json.Unmarshal(raw, &details) != nil {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
if _, exists := details["cached_tokens"]; exists {
|
||||
return payload
|
||||
}
|
||||
details["cached_tokens"] = hit
|
||||
usage["prompt_tokens_details"], _ = json.Marshal(details)
|
||||
root["usage"], _ = json.Marshal(usage)
|
||||
out, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
return payload
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JSON responses are normalized lazily so RoundTrip retains its normal timing.
|
||||
// Streaming responses are normalized one data line at a time by the SSE reader.
|
||||
type einoUsageNormalizingRoundTripper struct{ base http.RoundTripper }
|
||||
|
||||
func (rt *einoUsageNormalizingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
resp, err := rt.base.RoundTrip(req)
|
||||
if err == nil && resp != nil && resp.Body != nil && resp.StatusCode == http.StatusOK && strings.HasPrefix(strings.ToLower(resp.Header.Get("Content-Type")), "application/json") {
|
||||
resp.Body = &einoUsageJSONBody{upstream: resp.Body}
|
||||
resp.ContentLength = -1
|
||||
resp.Header.Del("Content-Length")
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
type einoUsageJSONBody struct {
|
||||
upstream io.ReadCloser
|
||||
reader *bytes.Reader
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *einoUsageJSONBody) Read(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if b.reader == nil {
|
||||
raw, err := io.ReadAll(b.upstream)
|
||||
b.err = err
|
||||
if err == nil {
|
||||
raw = normalizeEinoUsagePayload(raw)
|
||||
}
|
||||
b.reader = bytes.NewReader(raw)
|
||||
}
|
||||
n, err := b.reader.Read(p)
|
||||
if err == io.EOF && b.err != nil {
|
||||
return n, b.err
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
func (b *einoUsageJSONBody) Close() error { return b.upstream.Close() }
|
||||
@@ -0,0 +1,70 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeEinoDeepSeekCacheUsage(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"id":9007199254740993,"usage":{"prompt_tokens":100,"prompt_cache_hit_tokens":80,"prompt_cache_miss_tokens":20}}`,
|
||||
`{"usage":{"prompt_tokens_details":null,"prompt_cache_hit_tokens":80}}`,
|
||||
} {
|
||||
out := normalizeEinoUsagePayload([]byte(raw))
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(out, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var usage struct {
|
||||
Details struct {
|
||||
Cached int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
}
|
||||
if err := json.Unmarshal(root["usage"], &usage); err != nil || usage.Details.Cached != 80 {
|
||||
t.Fatalf("normalized = %s, err = %v", out, err)
|
||||
}
|
||||
if id := root["id"]; id != nil && string(id) != "9007199254740993" {
|
||||
t.Fatalf("ID precision lost: %s", id)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`[DONE]`, `{"error":{"message":"failed"}}`, `{"usage":{"prompt_cache_hit_tokens":-1}}`,
|
||||
`{"usage":{"prompt_cache_hit_tokens":80,"prompt_tokens_details":{"cached_tokens":0}}}`,
|
||||
} {
|
||||
if out := string(normalizeEinoUsagePayload([]byte(raw))); out != raw {
|
||||
t.Fatalf("unexpected change: %s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEinoDeepSeekCacheUsageJSONAndStream(t *testing.T) {
|
||||
payload := `{"usage":{"prompt_tokens":100,"completion_tokens":5,"total_tokens":105,"prompt_cache_hit_tokens":80}}`
|
||||
for _, stream := range []bool{false, true} {
|
||||
contentType, body := "application/json", payload
|
||||
if stream {
|
||||
contentType, body = "text/event-stream", ": ping\ndata: "+payload+"\n\ndata: [DONE]\n"
|
||||
}
|
||||
client := NewEinoHTTPClient(nil, &http.Client{Transport: usageTestTransport(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
})})
|
||||
resp, err := client.Get("http://example.invalid/v1/chat/completions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil || !strings.Contains(string(out), `"cached_tokens":80`) {
|
||||
t.Fatalf("stream=%v body=%s err=%v", stream, out, err)
|
||||
}
|
||||
if stream && !strings.Contains(string(out), "data: [DONE]") {
|
||||
t.Fatal("lost stream terminator")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type usageTestTransport func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f usageTestTransport) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
@@ -47219,3 +47219,25 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
|
||||
.update-adopt-confirm-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* Provider-reported token analysis, scoped by the dashboard project selector. */
|
||||
.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; }
|
||||
.token-usage-panel > summary { cursor: pointer; }
|
||||
.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; }
|
||||
.token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; }
|
||||
.token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; }
|
||||
.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.token-usage-metrics > div { padding: 12px; border: 1px solid var(--border-color, #dce2ea); border-radius: 8px; min-width: 0; }
|
||||
.token-usage-metrics span { display: block; font-size: 12px; opacity: .7; }
|
||||
.token-usage-metrics strong { display: block; margin-top: 6px; font-size: 20px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.token-usage-table-wrap { overflow-x: auto; }
|
||||
.token-usage-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 10px; text-align: right; border-bottom: 1px solid var(--border-color, #dce2ea); white-space: nowrap; }
|
||||
.token-usage-table-wrap th:first-child, .token-usage-table-wrap td:first-child { text-align: left; }
|
||||
.token-usage-table-wrap td:first-child { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
|
||||
@media (max-width: 640px) { .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .token-usage-panel { padding: 14px; } }
|
||||
.token-usage-trend { display: flex; align-items: flex-end; gap: 8px; overflow-x: auto; padding: 12px 0; }
|
||||
.token-usage-day { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; flex: 1 0 38px; height: 128px; gap: 6px; font-size: 11px; }
|
||||
.token-usage-day-bar { width: 70%; max-width: 48px; background: var(--accent-color, #3b82f6); border-radius: 4px 4px 0 0; }
|
||||
|
||||
.token-usage-panel h4 { margin: 20px 0 8px; }
|
||||
@@ -287,7 +287,7 @@
|
||||
"toolCalls": "Tool invocations",
|
||||
"successRate": "Tool success rate",
|
||||
"tokenUsage": "Token usage",
|
||||
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
|
||||
"tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}",
|
||||
"noTokenUsageYet": "No usage yet",
|
||||
"clickToViewTasks": "Click to view tasks",
|
||||
"clickToViewChat": "Click to view conversations",
|
||||
@@ -433,7 +433,33 @@
|
||||
"mcpPartialDown_other": "{{count}} stopped",
|
||||
"mcpAllDown": "All stopped",
|
||||
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
|
||||
"startScanBtn": "Go to chat to scan"
|
||||
"startScanBtn": "Go to chat to scan",
|
||||
"usageView": "View token usage",
|
||||
"usagePeriod": "Period",
|
||||
"usage7Days": "Last 7 days",
|
||||
"usage30Days": "Last 30 days",
|
||||
"usage90Days": "Last 90 days",
|
||||
"usageTotal": "Total tokens",
|
||||
"usageInput": "Input tokens",
|
||||
"usageOutput": "Output tokens",
|
||||
"usageCacheRead": "Cache read",
|
||||
"usageCacheWrite": "Cache write",
|
||||
"usageReasoning": "Reasoning tokens",
|
||||
"usageCalls": "Model calls",
|
||||
"usageCacheRate": "Cache read / input",
|
||||
"usageByModel": "Usage by model (up to 20)",
|
||||
"usageByDay": "Daily usage",
|
||||
"usageRecent": "Recent runs (up to 20)",
|
||||
"usageDate": "Date",
|
||||
"usageTime": "Time",
|
||||
"usageModel": "Model",
|
||||
"usageStatus": "End reason",
|
||||
"usageUnavailable": "Token usage is unavailable",
|
||||
"usageScope": "Provider-reported usage from recorded Agent runs, including sub-agents. Cache reads and writes are part of input; reasoning is part of output. These details are not added to totals again. Unreported or uncollected usage is excluded. This is not a provider bill. Models are grouped by run configuration; runs with model failover may contain mixed usage.",
|
||||
"usageShare": "Token share",
|
||||
"usageTrend": "Daily token trend",
|
||||
"usageFinal": "Completed",
|
||||
"usagePartial": "Partial usage"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
@@ -795,7 +821,7 @@
|
||||
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
||||
"turnProcessAria": "{{state}}; expand or collapse execution details",
|
||||
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)",
|
||||
"turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls) · Cache write {{cacheWrite}}",
|
||||
"turnNumber": "Turn {{number}}",
|
||||
"turnPending": "Processing…",
|
||||
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
||||
@@ -979,7 +1005,8 @@
|
||||
"hitlTimeoutUnlimited": "No limit",
|
||||
"hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off",
|
||||
"rolePanelTitle": "Select role"
|
||||
"rolePanelTitle": "Select role",
|
||||
"einoUsageCacheWriteTokens": "Cache write tokens"
|
||||
},
|
||||
"toolGuard": {
|
||||
"invalidTestResponse": "The server returned an invalid test result",
|
||||
@@ -3722,7 +3749,6 @@
|
||||
"hitlPolicy": "Tool approval",
|
||||
"hitlHint": "Applies to all tasks in this queue, including tasks added later. Uses the global whitelist, audit strategy and timeout.",
|
||||
"hitlSubtaskHint": "New tasks use the queue’s tool approval setting. Change it in queue details.",
|
||||
|
||||
"title": "New task",
|
||||
"queueTitle": "Queue title",
|
||||
"queueTitlePlaceholder": "Enter queue title (optional, for identification and filtering)",
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"toolCalls": "Вызовы инструментов",
|
||||
"successRate": "Успешность инструментов",
|
||||
"tokenUsage": "Расход токенов",
|
||||
"tokenUsageSub": "За 7 дней {{calls}} вызовов · сегодня {{today}}",
|
||||
"tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}",
|
||||
"noTokenUsageYet": "Расхода пока нет",
|
||||
"clickToViewTasks": "Открыть задачи",
|
||||
"clickToViewChat": "Открыть диалоги",
|
||||
@@ -445,7 +445,33 @@
|
||||
"recoApproveHitl": "Одобрить запросы HITL: {{count}}",
|
||||
"recoRestartMcp": "Проверить не запущенные внешние MCP: {{count}}",
|
||||
"recoCheckMonitor": "Разобрать неудачные вызовы инструментов: {{count}}",
|
||||
"mcpPartialDown": "Не запущено: {{count}}"
|
||||
"mcpPartialDown": "Не запущено: {{count}}",
|
||||
"usageView": "Расход токенов",
|
||||
"usagePeriod": "Период",
|
||||
"usage7Days": "За 7 дней",
|
||||
"usage30Days": "За 30 дней",
|
||||
"usage90Days": "За 90 дней",
|
||||
"usageTotal": "Всего токенов",
|
||||
"usageInput": "Входные токены",
|
||||
"usageOutput": "Выходные токены",
|
||||
"usageCacheRead": "Чтение кэша",
|
||||
"usageCacheWrite": "Запись кэша",
|
||||
"usageReasoning": "Токены рассуждения",
|
||||
"usageCalls": "Вызовы модели",
|
||||
"usageCacheRate": "Чтение кэша / ввод",
|
||||
"usageByModel": "По моделям (до 20)",
|
||||
"usageByDay": "По дням",
|
||||
"usageRecent": "Последние запуски (до 20)",
|
||||
"usageDate": "Дата",
|
||||
"usageTime": "Время",
|
||||
"usageModel": "Модель",
|
||||
"usageStatus": "Причина завершения",
|
||||
"usageUnavailable": "Статистика недоступна",
|
||||
"usageShare": "Доля токенов",
|
||||
"usageTrend": "Расход по дням",
|
||||
"usageScope": "Данные провайдера из сохранённых запусков Agent, включая субагентов. Чтение и запись кэша входят во ввод, рассуждение — в вывод; они не прибавляются к сумме повторно. Неполученные данные исключены. Это не счёт провайдера. Группировка по модели в конфигурации запуска; при переключении модели расход может быть смешан.",
|
||||
"usageFinal": "Завершено",
|
||||
"usagePartial": "Частичный расход"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Проекты",
|
||||
@@ -807,7 +833,7 @@
|
||||
"turnDurationHours": "{{hours}} ч {{minutes}} мин",
|
||||
"turnProcessAria": "{{state}}; развернуть или свернуть подробности выполнения",
|
||||
"turnTokenUsageLabel": "{{tokens}} токенов",
|
||||
"turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}})",
|
||||
"turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}}) · Запись кэша {{cacheWrite}}",
|
||||
"turnNumber": "Ход {{number}}",
|
||||
"turnPending": "Обработка…",
|
||||
"expandDetailLazyHint": "Развернуть подробности (итерации загружаются по щелчку)",
|
||||
@@ -991,7 +1017,8 @@
|
||||
"hitlTimeoutUnlimited": "Без ограничения",
|
||||
"hitlTimeoutHint": "Запросы без ответа автоматически отклоняются по истечении времени; на карточках одобрения виден отсчёт.",
|
||||
"hitlStatusOff": "Контроль человеком: выкл.",
|
||||
"rolePanelTitle": "Выбор роли"
|
||||
"rolePanelTitle": "Выбор роли",
|
||||
"einoUsageCacheWriteTokens": "Токены записи кэша"
|
||||
},
|
||||
"toolGuard": {
|
||||
"invalidTestResponse": "Сервер вернул некорректный результат проверки",
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
"toolCalls": "工具调用次数",
|
||||
"successRate": "工具执行成功率",
|
||||
"tokenUsage": "Token 用量",
|
||||
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
|
||||
"tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}",
|
||||
"noTokenUsageYet": "暂无用量",
|
||||
"clickToViewTasks": "点击查看任务管理",
|
||||
"clickToViewChat": "点击查看对话",
|
||||
@@ -421,7 +421,33 @@
|
||||
"mcpPartialDown": "{{count}} 个未运行",
|
||||
"mcpAllDown": "全部未运行",
|
||||
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
|
||||
"startScanBtn": "前往对话发起扫描"
|
||||
"startScanBtn": "前往对话发起扫描",
|
||||
"usageView": "查看用量详情",
|
||||
"usagePeriod": "统计周期",
|
||||
"usage7Days": "近 7 天",
|
||||
"usage30Days": "近 30 天",
|
||||
"usage90Days": "近 90 天",
|
||||
"usageTotal": "总 Token",
|
||||
"usageInput": "输入 Token",
|
||||
"usageOutput": "输出 Token",
|
||||
"usageCacheRead": "缓存读取",
|
||||
"usageCacheWrite": "缓存写入",
|
||||
"usageReasoning": "推理 Token",
|
||||
"usageCalls": "模型调用",
|
||||
"usageCacheRate": "缓存读取占输入",
|
||||
"usageByModel": "模型用量(最多 20 项)",
|
||||
"usageByDay": "每日用量",
|
||||
"usageRecent": "最近运行(最多 20 项)",
|
||||
"usageDate": "日期",
|
||||
"usageTime": "时间",
|
||||
"usageModel": "模型",
|
||||
"usageStatus": "结束原因",
|
||||
"usageUnavailable": "用量统计暂不可用",
|
||||
"usageScope": "统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。 模型按运行配置归类,切换模型的运行可能混合用量。",
|
||||
"usageShare": "用量占比",
|
||||
"usageTrend": "每日用量趋势",
|
||||
"usageFinal": "已完成",
|
||||
"usagePartial": "部分用量"
|
||||
},
|
||||
"projects": {
|
||||
"title": "项目管理",
|
||||
@@ -783,7 +809,7 @@
|
||||
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
||||
"turnProcessAria": "{{state}},展开或收起执行过程",
|
||||
"turnTokenUsageLabel": "{{tokens}} tokens",
|
||||
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)",
|
||||
"turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次) · 缓存写入 {{cacheWrite}}",
|
||||
"turnNumber": "第 {{number}} 轮",
|
||||
"turnPending": "正在处理…",
|
||||
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
||||
@@ -967,7 +993,8 @@
|
||||
"hitlTimeoutUnlimited": "不限制",
|
||||
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
|
||||
"hitlStatusOff": "人机协同:关闭",
|
||||
"rolePanelTitle": "选择角色"
|
||||
"rolePanelTitle": "选择角色",
|
||||
"einoUsageCacheWriteTokens": "缓存写入 tokens"
|
||||
},
|
||||
"toolGuard": {
|
||||
"invalidTestResponse": "服务端返回的测试结果格式不正确",
|
||||
@@ -3710,7 +3737,6 @@
|
||||
"hitlPolicy": "工具审批",
|
||||
"hitlHint": "队列内所有任务(含后续添加的任务)使用此设置;白名单、审计策略和等待时限沿用全局配置。",
|
||||
"hitlSubtaskHint": "新增任务沿用队列的工具审批设置,可在队列详情中修改。",
|
||||
|
||||
"title": "新建任务",
|
||||
"queueTitle": "任务标题",
|
||||
"queueTitlePlaceholder": "请输入任务标题(可选,用于标识和筛选)",
|
||||
|
||||
@@ -341,7 +341,7 @@ test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
|
||||
|
||||
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
|
||||
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260815-1');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260907-blocked-1');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20261011-token-usage');
|
||||
|
||||
assert.notEqual(scrollIndex, -1);
|
||||
assert.notEqual(monitorIndex, -1);
|
||||
|
||||
@@ -4945,6 +4945,7 @@ function normalizeAssistantTurnTokenUsage(data) {
|
||||
completionTokens: assistantTurnUsageNumber(source.completionTokens),
|
||||
totalTokens: assistantTurnUsageNumber(source.totalTokens),
|
||||
cachedTokens: assistantTurnUsageNumber(source.cachedTokens),
|
||||
cacheWriteTokens: assistantTurnUsageNumber(source.cacheWriteTokens),
|
||||
reasoningTokens: assistantTurnUsageNumber(source.reasoningTokens),
|
||||
model: source.model != null ? String(source.model).trim() : ''
|
||||
};
|
||||
@@ -4962,6 +4963,7 @@ function mergeAssistantTurnTokenUsage(target, usage) {
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cachedTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
model: ''
|
||||
};
|
||||
@@ -4970,6 +4972,7 @@ function mergeAssistantTurnTokenUsage(target, usage) {
|
||||
out.completionTokens += usage.completionTokens || 0;
|
||||
out.totalTokens += usage.totalTokens || 0;
|
||||
out.cachedTokens += usage.cachedTokens || 0;
|
||||
out.cacheWriteTokens += usage.cacheWriteTokens || 0;
|
||||
out.reasoningTokens += usage.reasoningTokens || 0;
|
||||
if (!out.model && usage.model) out.model = usage.model;
|
||||
return out.totalTokens > 0 ? out : null;
|
||||
@@ -4998,6 +5001,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) {
|
||||
delete messageElement.dataset.turnCompletionTokens;
|
||||
delete messageElement.dataset.turnTotalTokens;
|
||||
delete messageElement.dataset.turnCachedTokens;
|
||||
delete messageElement.dataset.turnCacheWriteTokens;
|
||||
delete messageElement.dataset.turnReasoningTokens;
|
||||
delete messageElement.dataset.turnModel;
|
||||
syncAssistantTurnSummary(messageElement);
|
||||
@@ -5008,6 +5012,7 @@ function setAssistantTurnTokenUsage(messageElementOrId, usage) {
|
||||
messageElement.dataset.turnCompletionTokens = String(normalized.completionTokens || 0);
|
||||
messageElement.dataset.turnTotalTokens = String(normalized.totalTokens || 0);
|
||||
messageElement.dataset.turnCachedTokens = String(normalized.cachedTokens || 0);
|
||||
messageElement.dataset.turnCacheWriteTokens = String(normalized.cacheWriteTokens || 0);
|
||||
messageElement.dataset.turnReasoningTokens = String(normalized.reasoningTokens || 0);
|
||||
if (normalized.model) {
|
||||
messageElement.dataset.turnModel = normalized.model;
|
||||
@@ -5025,6 +5030,7 @@ function getAssistantTurnTokenUsage(messageElement) {
|
||||
completionTokens: messageElement.dataset.turnCompletionTokens,
|
||||
totalTokens: messageElement.dataset.turnTotalTokens,
|
||||
cachedTokens: messageElement.dataset.turnCachedTokens,
|
||||
cacheWriteTokens: messageElement.dataset.turnCacheWriteTokens,
|
||||
reasoningTokens: messageElement.dataset.turnReasoningTokens,
|
||||
model: messageElement.dataset.turnModel
|
||||
});
|
||||
@@ -5051,6 +5057,7 @@ function formatAssistantTurnTokenUsageTitle(usage) {
|
||||
prompt: formatAssistantTurnTokenCount(safeUsage.promptTokens),
|
||||
completion: formatAssistantTurnTokenCount(safeUsage.completionTokens),
|
||||
cached: formatAssistantTurnTokenCount(safeUsage.cachedTokens),
|
||||
cacheWrite: formatAssistantTurnTokenCount(safeUsage.cacheWriteTokens),
|
||||
reasoning: formatAssistantTurnTokenCount(safeUsage.reasoningTokens),
|
||||
calls: formatAssistantTurnTokenCount(safeUsage.modelCalls),
|
||||
model: safeUsage.model || ''
|
||||
|
||||
+139
-2
@@ -17,6 +17,7 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi
|
||||
var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive'];
|
||||
|
||||
var dashboardState = {
|
||||
tokenDays: 7,
|
||||
currentController: null, // 当前正在进行的 fetch 的 AbortController
|
||||
pollTimer: null, // 自动轮询的 setInterval id
|
||||
staleTimer: null, // 过期检查的 setInterval id
|
||||
@@ -162,7 +163,7 @@ async function refreshDashboard() {
|
||||
fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')),
|
||||
fetchJson('/api/projects/dashboard-summary?fact_limit=10'),
|
||||
selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null),
|
||||
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5'))
|
||||
fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20'))
|
||||
]);
|
||||
|
||||
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||
@@ -440,6 +441,7 @@ async function refreshDashboard() {
|
||||
setDashboardOverviewPlaceholder('-');
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
setEl('dashboard-kpi-tools-calls', '-');
|
||||
renderDashboardTokenAnalysis(null);
|
||||
setEl('dashboard-kpi-token-usage', '-');
|
||||
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
|
||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||
@@ -708,6 +710,7 @@ function setKpiRateBadge(id, rate, failedCount) {
|
||||
}
|
||||
|
||||
function renderDashboardTokenUsage(res) {
|
||||
renderDashboardTokenAnalysis(res);
|
||||
const summary = res && res.summary ? res.summary : null;
|
||||
if (!summary) {
|
||||
setEl('dashboard-kpi-token-usage', '-');
|
||||
@@ -725,9 +728,10 @@ function renderDashboardTokenUsage(res) {
|
||||
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
|
||||
setKpiSubText('dashboard-kpi-token-sub-text',
|
||||
dt('dashboard.tokenUsageSub', {
|
||||
days: dashboardState.tokenDays,
|
||||
today: formatTokenUsageCompact(today),
|
||||
calls: Number.isFinite(calls) ? calls : 0
|
||||
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
|
||||
}, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
|
||||
}
|
||||
|
||||
function formatTokenUsageCompact(num) {
|
||||
@@ -2524,3 +2528,136 @@ document.addEventListener('click', function (ev) {
|
||||
var banner = document.getElementById('dashboard-alert-banner');
|
||||
if (banner) banner.hidden = true;
|
||||
});
|
||||
|
||||
function openDashboardTokenUsage() {
|
||||
const panel = document.getElementById('dashboard-token-details');
|
||||
if (!panel) return;
|
||||
panel.open = true;
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function changeDashboardTokenPeriod(value) {
|
||||
const days = Number(value);
|
||||
if (![7, 30, 90].includes(days)) return;
|
||||
dashboardState.tokenDays = days;
|
||||
const host = document.getElementById('dashboard-token-analysis');
|
||||
if (host) host.textContent = '…';
|
||||
refreshDashboard();
|
||||
}
|
||||
|
||||
function dashboardUsageCount(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
|
||||
}
|
||||
|
||||
function dashboardUsageMetrics(summary) {
|
||||
const input = dashboardUsageCount(summary.promptTokens);
|
||||
const cached = dashboardUsageCount(summary.cachedTokens);
|
||||
return [
|
||||
['usageTotal', summary.totalTokens],
|
||||
['usageInput', input],
|
||||
['usageOutput', summary.completionTokens],
|
||||
['usageCacheRead', cached],
|
||||
['usageCacheWrite', summary.cacheWriteTokens],
|
||||
['usageReasoning', summary.reasoningTokens],
|
||||
['usageCalls', summary.modelCalls],
|
||||
['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—']
|
||||
];
|
||||
}
|
||||
|
||||
function renderDashboardTokenAnalysis(res) {
|
||||
const host = document.getElementById('dashboard-token-analysis');
|
||||
if (!host) return;
|
||||
host.replaceChildren();
|
||||
if (!res || !res.summary) {
|
||||
host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用');
|
||||
return;
|
||||
}
|
||||
const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
|
||||
const label = key => dt('dashboard.' + key, null, key);
|
||||
const metrics = document.createElement('div');
|
||||
metrics.className = 'token-usage-metrics';
|
||||
dashboardUsageMetrics(res.summary).forEach(([key, value]) => {
|
||||
const card = document.createElement('div');
|
||||
const title = document.createElement('span');
|
||||
title.textContent = label(key);
|
||||
const count = document.createElement('strong');
|
||||
count.textContent = typeof value === 'string' ? value : exact(value);
|
||||
card.append(title, count);
|
||||
metrics.append(card);
|
||||
});
|
||||
host.append(metrics);
|
||||
if (!dashboardUsageCount(res.summary.events)) {
|
||||
const empty = document.createElement('p');
|
||||
empty.textContent = dt('dashboard.noTokenUsageYet', null, '暂无用量');
|
||||
host.append(empty);
|
||||
return;
|
||||
}
|
||||
// Tables use text nodes: provider model names and conversation IDs are untrusted.
|
||||
const table = (titleKey, headings, rows) => {
|
||||
const section = document.createElement('section');
|
||||
const title = document.createElement('h4');
|
||||
title.textContent = label(titleKey);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'token-usage-table-wrap';
|
||||
const grid = document.createElement('table');
|
||||
const caption = document.createElement('caption');
|
||||
caption.className = 'sr-only';
|
||||
caption.textContent = title.textContent;
|
||||
const head = document.createElement('thead');
|
||||
const hr = document.createElement('tr');
|
||||
headings.forEach(key => {
|
||||
const th = document.createElement('th');
|
||||
th.scope = 'col';
|
||||
th.textContent = label(key);
|
||||
hr.append(th);
|
||||
});
|
||||
head.append(hr);
|
||||
const body = document.createElement('tbody');
|
||||
rows.forEach(values => {
|
||||
const tr = document.createElement('tr');
|
||||
values.forEach(value => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = String(value == null ? '' : value);
|
||||
tr.append(td);
|
||||
});
|
||||
body.append(tr);
|
||||
});
|
||||
grid.append(caption, head, body);
|
||||
wrap.append(grid);
|
||||
section.append(title, wrap);
|
||||
host.append(section);
|
||||
};
|
||||
const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens),
|
||||
exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)];
|
||||
const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls'];
|
||||
table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key,
|
||||
dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)]));
|
||||
const trendTitle = document.createElement('h4');
|
||||
trendTitle.textContent = label('usageTrend');
|
||||
const trend = document.createElement('div');
|
||||
trend.className = 'token-usage-trend';
|
||||
const days = (res.byDay || []).slice().reverse();
|
||||
const peak = Math.max(1, ...days.map(row => dashboardUsageCount(row.totalTokens)));
|
||||
days.forEach(row => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'token-usage-day';
|
||||
const count = document.createElement('span');
|
||||
count.textContent = formatTokenUsageCompact(row.totalTokens);
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'token-usage-day-bar';
|
||||
fill.style.height = Math.max(1, dashboardUsageCount(row.totalTokens) / peak * 90) + 'px';
|
||||
const date = document.createElement('span');
|
||||
date.textContent = String(row.key || '').slice(5);
|
||||
bar.title = row.key + ': ' + exact(row.totalTokens);
|
||||
bar.append(count, fill, date);
|
||||
trend.append(bar);
|
||||
});
|
||||
host.append(trendTitle, trend);
|
||||
table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]));
|
||||
table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => {
|
||||
const time = new Date(row.createdAt);
|
||||
return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—',
|
||||
row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)];
|
||||
}));
|
||||
}
|
||||
@@ -107,7 +107,7 @@ test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () =>
|
||||
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
|
||||
assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/);
|
||||
assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/);
|
||||
assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/);
|
||||
assert.match(fs.readFileSync('internal/database/schemas/application.sql', 'utf8'), /reviewer TEXT NOT NULL DEFAULT 'human'/);
|
||||
assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/);
|
||||
assert.match(monitor, /function isAgentReviewedHitl\(data\)/);
|
||||
assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/);
|
||||
@@ -293,7 +293,7 @@ test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状
|
||||
assert.match(chat, /let loadConversationAbortController = null/);
|
||||
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,900}const conversationLoadController = new AbortController\(\)/);
|
||||
assert.match(chat, /signal: conversationLoadController\.signal/);
|
||||
assert.match(template, /monitor\.js\?v=20260907-blocked-1/);
|
||||
assert.match(template, /monitor\.js\?v=20261011-token-usage/);
|
||||
assert.match(template, /chat-scroll\.js\?v=20260815-1/);
|
||||
assert.match(template, /chat\.js\?v=[A-Za-z0-9._-]+/);
|
||||
assert.match(template, /style\.css\?v=[A-Za-z0-9._-]+/);
|
||||
|
||||
@@ -2889,6 +2889,9 @@ function formatEinoUsageSummaryMessage(data) {
|
||||
if (Number(d.cachedTokens || 0) > 0) {
|
||||
rows.push([label('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
|
||||
}
|
||||
if (Number(d.cacheWriteTokens || 0) > 0) {
|
||||
rows.push([label('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]);
|
||||
}
|
||||
if (Number(d.reasoningTokens || 0) > 0) {
|
||||
rows.push([label('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
|
||||
}
|
||||
@@ -6734,7 +6737,7 @@ function addTimelineItem(timeline, type, options) {
|
||||
}
|
||||
if (type === 'eino_usage_summary' && options.data) {
|
||||
const d = options.data;
|
||||
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'reasoningTokens'].forEach(function (key) {
|
||||
['modelCalls', 'promptTokens', 'completionTokens', 'totalTokens', 'cachedTokens', 'cacheWriteTokens', 'reasoningTokens'].forEach(function (key) {
|
||||
if (d[key] != null) {
|
||||
item.dataset[key] = String(d[key]);
|
||||
}
|
||||
@@ -9651,6 +9654,7 @@ function refreshProgressAndTimelineI18n() {
|
||||
completionTokens: item.dataset.completionTokens,
|
||||
totalTokens: item.dataset.totalTokens,
|
||||
cachedTokens: item.dataset.cachedTokens,
|
||||
cacheWriteTokens: item.dataset.cacheWriteTokens,
|
||||
reasoningTokens: item.dataset.reasoningTokens
|
||||
};
|
||||
titleSpan.textContent = formatEinoUsageSummaryTitle(usageData);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8');
|
||||
const harness = () => {
|
||||
const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} };
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), context);
|
||||
return context;
|
||||
};
|
||||
|
||||
test('cache and reasoning details do not inflate total or double-count input', () => {
|
||||
const ctx = harness();
|
||||
const metrics = ctx.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 });
|
||||
const values = Object.fromEntries(metrics);
|
||||
assert.equal(values.usageTotal, 120);
|
||||
assert.equal(values.usageCacheWrite, 30);
|
||||
assert.equal(values.usageCacheRate, '40.0%');
|
||||
assert.equal(Object.fromEntries(ctx.dashboardUsageMetrics({})).usageCacheRate, '—');
|
||||
assert.equal(ctx.dashboardUsageCount(Infinity), 0);
|
||||
assert.equal(ctx.dashboardUsageCount(-12), 0);
|
||||
});
|
||||
|
||||
test('period selection refreshes once and rejects arbitrary periods', () => {
|
||||
const ctx = harness();
|
||||
let calls = 0;
|
||||
ctx.refreshDashboard = () => { calls++; };
|
||||
ctx.document.getElementById = () => ({ textContent: '' });
|
||||
ctx.changeDashboardTokenPeriod('30');
|
||||
ctx.changeDashboardTokenPeriod('999');
|
||||
assert.equal(ctx.dashboardState.tokenDays, 30);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('all supported languages expose usage labels and interpolation placeholders', () => {
|
||||
for (const locale of ['zh-CN', 'en-US', 'ru-RU']) {
|
||||
const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8'));
|
||||
for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend']) assert.ok(data.dashboard[key]);
|
||||
assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/);
|
||||
assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/);
|
||||
}
|
||||
});
|
||||
@@ -117,12 +117,14 @@ test('助手轮次摘要会显示持久化 token 用量', () => {
|
||||
promptTokens: 1200,
|
||||
completionTokens: 34,
|
||||
cachedTokens: 200,
|
||||
cacheWriteTokens: 100,
|
||||
reasoningTokens: 12,
|
||||
modelCalls: 1,
|
||||
model: 'deepseek-v3',
|
||||
});
|
||||
|
||||
assert.equal(message.dataset.turnTotalTokens, '1234');
|
||||
assert.equal(message.dataset.turnCacheWriteTokens, '100');
|
||||
assert.match(message.label.innerHTML, /1\.2K tokens/);
|
||||
assert.match(message.label.innerHTML, /turn-process-token-chip/);
|
||||
});
|
||||
|
||||
@@ -2203,6 +2203,9 @@ function formatWebshellEinoUsageSummaryMessage(data) {
|
||||
if (Number(d.cachedTokens || 0) > 0) {
|
||||
rows.push([wsTOr('chat.einoUsageCachedTokens', '缓存 tokens'), d.cachedTokens]);
|
||||
}
|
||||
if (Number(d.cacheWriteTokens || 0) > 0) {
|
||||
rows.push([wsTOr('chat.einoUsageCacheWriteTokens', '缓存写入 tokens'), d.cacheWriteTokens]);
|
||||
}
|
||||
if (Number(d.reasoningTokens || 0) > 0) {
|
||||
rows.push([wsTOr('chat.einoUsageReasoningTokens', '推理 tokens'), d.reasoningTokens]);
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@
|
||||
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('chat')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('chat'); }" data-i18n="dashboard.clickToViewChat" data-i18n-attr="title" title="点击查看对话">
|
||||
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="openDashboardTokenUsage()" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); openDashboardTokenUsage(); }" data-i18n="dashboard.usageView" data-i18n-attr="title" title="查看用量详情">
|
||||
<div class="dashboard-kpi-head">
|
||||
<div class="dashboard-kpi-label" data-i18n="dashboard.tokenUsage">Token 用量</div>
|
||||
<span class="dashboard-kpi-icon dashboard-kpi-icon-tokens" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/><path d="M8 3 6 21"/><path d="m18 3-2 18"/></svg></span>
|
||||
@@ -567,6 +567,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="dashboard-section token-usage-panel" id="dashboard-token-details">
|
||||
<summary class="dashboard-section-title" data-i18n="dashboard.usageView">查看用量详情</summary>
|
||||
<div class="token-usage-toolbar">
|
||||
<label for="dashboard-token-days" data-i18n="dashboard.usagePeriod">统计周期</label>
|
||||
<select id="dashboard-token-days" onchange="changeDashboardTokenPeriod(this.value)">
|
||||
<option value="7" data-i18n="dashboard.usage7Days">近 7 天</option>
|
||||
<option value="30" data-i18n="dashboard.usage30Days">近 30 天</option>
|
||||
<option value="90" data-i18n="dashboard.usage90Days">近 90 天</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="token-usage-note" data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p>
|
||||
<div id="dashboard-token-analysis" aria-live="polite"></div>
|
||||
</details>
|
||||
<!-- 两列主内容区 -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-main">
|
||||
@@ -6996,10 +7009,10 @@
|
||||
<script src="/static/js/info-collect.js?v=20260717-1"></script>
|
||||
<script src="/static/js/assets.js?v=20260717-7"></script>
|
||||
<script src="/static/js/agents.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
<script src="/static/js/dashboard.js?v=20261011-token-usage"></script>
|
||||
<script src="/static/js/chat-scroll.js?v=20260815-1"></script>
|
||||
<script src="/static/js/monitor.js?v=20260907-blocked-1"></script>
|
||||
<script src="/static/js/chat.js?v=20261009-retest-1"></script>
|
||||
<script src="/static/js/monitor.js?v=20261011-token-usage"></script>
|
||||
<script src="/static/js/chat.js?v=20261011-token-usage"></script>
|
||||
<script src="/static/js/chat-plan-progress.js?v=20260815-1"></script>
|
||||
<script src="/static/js/hitl.js?v=20260819-1"></script>
|
||||
<script src="/static/js/tool-guard.js?v=20261010-conflict"></script>
|
||||
@@ -7016,7 +7029,7 @@
|
||||
<script src="/static/js/fact-graph.js"></script>
|
||||
<script src="/static/js/projects.js?v=20260819-1"></script>
|
||||
<script src="/static/js/vulnerability.js?v=20261010-retest-2"></script>
|
||||
<script src="/static/js/webshell.js?v=20260907-blocked-1"></script>
|
||||
<script src="/static/js/webshell.js?v=20261011-token-usage"></script>
|
||||
<script src="/static/js/chat-files.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
<script src="/static/js/workflow-package-client.js"></script>
|
||||
|
||||
Reference in new issue
Block a user