Merge upstream main and preserve mobile stylesheet cache version

This commit is contained in:
Sycun committed 2026-10-16 11:12:11 +08:00
commit b8d65aa1e3
37 files changed
+1513 -71

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
# 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 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅显示缓存命中率(缓存读取 Token / 输入 Token),调用次数和每日用量保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。
详情页采用四项核心指标(总量、输入、输出、缓存读取占输入)与四项次级指标(调用、缓存读取、缓存写入、推理)的层级布局。趋势可切换总 Token、模型调用与缓存命中率,鼠标悬停或键盘聚焦柱形可查看日期与精确数字;切换图表指标不再请求 API。缓存环形图拆分缓存读取与其余输入,不把缓存或推理再次叠加到总量。模型明细展示占比条,每日数字明细默认折叠,最近运行区分完整与部分记录。统计口径可展开查看。
每日图表沿用 API 返回的服务端日期,只补齐已返回日期之间无记录的空档,不根据浏览器时区猜测查询终点。小屏图表和宽表在自己的区域内横向滚动。没有记录和无法获取统计分别展示空状态与不可用状态。
### UI 调研依据(2026-10-12)
| 参考 | 采用的设计 |
| --- | --- |
| [DeepSeek Context Caching](https://api-docs.deepseek.com/guides/kv_cache/) | 使用其明确的缓存命中/未命中输入定义,设计缓存构成视图;比例按 Token 而非请求次数计算 |
| [LiteLLM Usage](https://docs.litellm.ai/docs/proxy/ui/chat_ui) | 周期选择、核心统计与使用日志分层;本项目保留已有数据范围,不新增无法准确计量的费用或成功率 |
| [Langfuse Custom Dashboards](https://langfuse.com/docs/metrics/features/custom-dashboards) | 将汇总、时间趋势、模型分布拆为独立可读区域,并让趋势指标可切换 |
DeepSeek 和 GLM 控制台的完整页面本轮未能公开读取,因此本轮视觉设计以能核实的开源面板为依据。没有声称复刻它们当前登录后的界面。项目也没有新增供应商账单、预算或节省金额推算。
运行事件 → 流式/非流式用量累加 → `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 手机、深色模式、模型名称转义、空数据与接口不可用状态;没有读取真实运行数据库,也没有调用付费模型。
+6 -6
View File
@@ -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
+12
View File
@@ -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=
+16 -10
View File
@@ -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)
}
+39 -2
View File
@@ -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,
@@ -0,0 +1,75 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// Apply the classic history repair to native function-call blocks too. Providers
// may parse arguments embedded in otherwise valid request JSON and reject the
// next turn if a previous model response contained truncated arguments. Repair
// only historical input; never repair or execute an incomplete new tool call.
type agenticToolCallArgumentsSanitizerMiddleware struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
logger *zap.Logger
phase string
}
func newAgenticToolCallArgumentsSanitizerMiddleware(logger *zap.Logger, phase string) adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
return &agenticToolCallArgumentsSanitizerMiddleware{
TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{},
logger: logger, phase: phase,
}
}
func (m *agenticToolCallArgumentsSanitizerMiddleware) BeforeModelRewriteState(
ctx context.Context, state *adk.TypedChatModelAgentState[*schema.AgenticMessage],
_ *adk.TypedModelContext[*schema.AgenticMessage],
) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
if m == nil || state == nil {
return ctx, state, nil
}
var out []*schema.AgenticMessage
repaired := 0
for i, msg := range state.Messages {
if msg == nil || msg.Role != schema.AgenticRoleTypeAssistant {
continue
}
var blocks []*schema.ContentBlock
for j, block := range msg.ContentBlocks {
if block == nil || block.FunctionToolCall == nil || validToolArgumentsJSONObject(block.FunctionToolCall.Arguments) {
continue
}
if blocks == nil {
blocks = append([]*schema.ContentBlock(nil), msg.ContentBlocks...)
}
clonedBlock := *block
clonedCall := *block.FunctionToolCall
clonedCall.Arguments = repairedMalformedToolArguments
clonedBlock.FunctionToolCall = &clonedCall
blocks[j] = &clonedBlock
repaired++
}
if blocks == nil {
continue
}
if out == nil {
out = append([]*schema.AgenticMessage(nil), state.Messages...)
}
clonedMessage := *msg
clonedMessage.ContentBlocks = blocks
out[i] = &clonedMessage
}
if repaired == 0 {
return ctx, state, nil
}
if m.logger != nil {
m.logger.Warn("eino agentic malformed tool-call arguments repaired before model call", zap.String("phase", m.phase), zap.Int("repaired_calls", repaired))
}
clonedState := *state
clonedState.Messages = out
return ctx, &clonedState, nil
}
@@ -0,0 +1,214 @@
package multiagent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestAgenticToolArgumentsSanitizerPreservesHistory(t *testing.T) {
for _, args := range []string{`{"command":"unterminated`, `[]`, `null`, ``, `{"command":`} {
t.Run(args, func(t *testing.T) {
good := schema.NewContentBlock(&schema.FunctionToolCall{CallID: "good", Name: "exec", Arguments: `{"command":"pwd"}`})
bad := schema.NewContentBlock(&schema.FunctionToolCall{CallID: "bad", Name: "exec", Arguments: args})
text := schema.NewContentBlock(&schema.AssistantGenText{Text: "keep findings"})
user := schema.UserAgenticMessage("latest instruction")
result := schema.UserAgenticMessage("execution failed")
msg := &schema.AgenticMessage{Role: schema.AgenticRoleTypeAssistant, ContentBlocks: []*schema.ContentBlock{nil, text, good, bad}}
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: []*schema.AgenticMessage{nil, msg, result, user}}
mw := newAgenticToolCallArgumentsSanitizerMiddleware(nil, "test")
_, out, err := mw.BeforeModelRewriteState(context.Background(), state, nil)
if err != nil {
t.Fatal(err)
}
if out == state || out.Messages[1] == msg || out.Messages[1].ContentBlocks[3] == bad {
t.Fatal("repair must copy history")
}
repaired := out.Messages[1].ContentBlocks[3].FunctionToolCall
if repaired.Arguments != `{}` || repaired.Name != "exec" || repaired.CallID != "bad" {
t.Fatalf("repair changed call identity: %+v", repaired)
}
if bad.FunctionToolCall.Arguments != args {
t.Fatal("mutated original arguments")
}
if out.Messages[1].ContentBlocks[1] != text || out.Messages[1].ContentBlocks[2] != good || out.Messages[2] != result || out.Messages[3] != user {
t.Fatal("changed unrelated history")
}
_, unchanged, err := mw.BeforeModelRewriteState(context.Background(), out, nil)
if err != nil || unchanged != out {
t.Fatal("valid history should remain unchanged")
}
})
}
}
// A real SDK round trip reproduces a gateway that parses tool argument strings
// on subsequent turns: valid outer JSON alone does not prevent this HTTP 400.
func TestAgenticToolArgumentsSanitizerPreventsMultiTurnHTTP400(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []struct {
Role string `json:"role"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
w.WriteHeader(400)
return
}
calls := 0
for _, msg := range body.Messages {
for _, call := range msg.ToolCalls {
calls++
if !validToolArgumentsJSONObject(call.Function.Arguments) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
fmt.Fprint(w, `{"error":{"message":"Unterminated string starting at: line 1 column 13 (char 12)","type":"invalid_request_error"}}`)
return
}
if call.ID != "broken" || call.Function.Arguments != `{}` {
t.Errorf("unexpected history call: %+v", call)
}
}
}
if calls != 1 {
t.Errorf("history calls=%d, want 1", calls)
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"continued\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
}))
defer server.Close()
ctx := context.Background()
base, err := newEinoAgenticChatModelFactory(server.Client(), nil, nil)(ctx, config.OpenAIConfig{Provider: "openai", APIKey: "test-key", BaseURL: server.URL, Model: "space-bunny-free"}, einoModelModeNormal)
if err != nil {
t.Fatal(err)
}
broken := schema.AssistantMessage("", []schema.ToolCall{{ID: "broken", Type: "function", Function: schema.FunctionCall{Name: "exec", Arguments: `{"command":"unterminated`}}})
input := []*schema.Message{schema.UserMessage("start"), broken, schema.ToolMessage("Invalid JSON arguments; tool was not executed", "broken"), schema.UserMessage("continue")}
for _, repair := range []bool{false, true} {
t.Run(fmt.Sprint(repair), func(t *testing.T) {
var handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage]
if repair {
handlers = appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{phase: "test"})
}
agent, err := newEinoAgenticChatModelAgentAdapter(ctx, einoAgenticChatModelAgentConfig{Name: "test", Model: base, Handlers: handlers})
if err != nil {
t.Fatal(err)
}
it := agent.Run(ctx, &adk.AgentInput{Messages: input, EnableStreaming: true})
var runErr error
var answer strings.Builder
for {
ev, ok := it.Next()
if !ok {
break
}
if ev.Err != nil {
runErr = ev.Err
break
}
if ev.Output != nil && ev.Output.MessageOutput != nil && ev.Output.MessageOutput.MessageStream != nil {
stream := ev.Output.MessageOutput.MessageStream
for {
msg, err := stream.Recv()
if err != nil {
if !errors.Is(err, io.EOF) {
runErr = err
}
break
}
if msg != nil {
answer.WriteString(msg.Content)
}
}
stream.Close()
}
}
if repair && runErr != nil {
t.Fatalf("repaired history rejected: %v", runErr)
}
if repair && !strings.Contains(answer.String(), "continued") {
t.Fatalf("agent did not continue: %q", answer.String())
}
if !repair && runErr == nil {
t.Fatal("expected HTTP 400 without sanitizer")
}
if broken.ToolCalls[0].Function.Arguments != `{"command":"unterminated` {
t.Fatal("original history mutated")
}
})
}
}
// Summarization makes its own model call and can rewrite retained history, so
// guard both its input and the final model input rather than just one boundary.
type agenticSummaryArgumentsProbe struct {
*adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]
checked bool
}
func (p *agenticSummaryArgumentsProbe) BeforeModelRewriteState(ctx context.Context, state *adk.TypedChatModelAgentState[*schema.AgenticMessage], _ *adk.TypedModelContext[*schema.AgenticMessage]) (context.Context, *adk.TypedChatModelAgentState[*schema.AgenticMessage], error) {
for _, msg := range state.Messages {
for _, call := range agenticFunctionToolCalls(msg) {
if !validToolArgumentsJSONObject(call.Arguments) {
return ctx, state, fmt.Errorf("invalid arguments reached summarization")
}
p.checked = true
}
}
out := *state
out.Messages = append([]*schema.AgenticMessage(nil), state.Messages...)
for i, msg := range out.Messages {
if len(agenticFunctionToolCalls(msg)) == 0 {
continue
}
calls := []*schema.FunctionToolCall{{CallID: "broken", Name: "exec", Arguments: `{"command":"truncated`}}
out.Messages[i] = cloneAgenticMessageWithCalls(msg, calls)
}
return ctx, &out, nil
}
func TestAgenticToolArgumentsSanitizerAroundSummarization(t *testing.T) {
probe := &agenticSummaryArgumentsProbe{TypedBaseChatModelAgentMiddleware: &adk.TypedBaseChatModelAgentMiddleware[*schema.AgenticMessage]{}}
history := EinoMessagesToAgentic([]*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "broken", Type: "function", Function: schema.FunctionCall{Name: "exec", Arguments: `{"command":"truncated`}}}),
schema.ToolMessage("invalid JSON; not executed", "broken"),
schema.UserMessage("continue"),
})
state := &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: history}
handlers := appendEinoAgenticChatModelTailMiddlewares(nil, einoChatModelTailConfig{agenticSummarization: probe})
ctx := context.Background()
for _, handler := range handlers {
var err error
ctx, state, err = handler.BeforeModelRewriteState(ctx, state, nil)
if err != nil {
t.Fatal(err)
}
}
if !probe.checked {
t.Fatal("summary probe did not inspect retained call")
}
calls := agenticFunctionToolCalls(state.Messages[0])
if len(calls) != 1 || calls[0].Arguments != `{}` {
t.Fatalf("final input contains malformed call: %+v", calls)
}
if agenticFunctionToolCalls(history[0])[0].Arguments != `{"command":"truncated` {
t.Fatal("mutated original history")
}
}
@@ -10,19 +10,20 @@ import (
)
// appendEinoAgenticChatModelTailMiddlewares appends protocol-neutral handlers for
// TypedChatModelAgent[*schema.AgenticMessage]. Classic ReAct history repair
// handlers stay on the schema.Message path because AgenticMessage has native
// content blocks for function calls/results.
// TypedChatModelAgent[*schema.AgenticMessage]. History repair operates
// directly on native function-call/result blocks without converting protocols.
func appendEinoAgenticChatModelTailMiddlewares(
handlers []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage],
cfg einoChatModelTailConfig,
) []adk.TypedChatModelAgentMiddleware[*schema.AgenticMessage] {
handlers = append(handlers, newAgenticSystemMessageNormalizerMiddleware(cfg.logger, cfg.phase))
handlers = append(handlers, newAgenticContinuationUserDedupMiddleware(cfg.logger, cfg.phase))
handlers = append(handlers, newAgenticToolCallArgumentsSanitizerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
if cfg.agenticSummarization != nil {
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase+"_pre_summarization"))
handlers = append(handlers, cfg.agenticSummarization)
}
handlers = append(handlers, newAgenticToolCallArgumentsSanitizerMiddleware(cfg.logger, cfg.phase))
handlers = append(handlers, newAgenticToolPairReconcilerMiddleware(cfg.logger, cfg.phase))
if !cfg.skipOrphanPruner {
handlers = append(handlers, newAgenticOrphanToolPrunerMiddleware(cfg.logger, cfg.phase))
@@ -106,8 +106,8 @@ func TestAppendEinoAgenticChatModelTailMiddlewares(t *testing.T) {
phase: "agentic",
trace: holder,
})
// system + continuation + reconciler + orphan_pruner + trace
if len(handlers) != 5 {
t.Fatalf("handlers = %d, want system + continuation + reconciler + orphan_pruner + trace", len(handlers))
// system + continuation + two argument sanitizers + reconciler + orphan_pruner + trace
if len(handlers) != 7 {
t.Fatalf("handlers = %d, want system + continuation + two argument sanitizers + reconciler + orphan_pruner + trace", len(handlers))
}
}
@@ -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)
}
}
@@ -50,6 +50,20 @@ func einoSummarizationModelRawErrorText(err error) (string, bool) {
return strings.TrimSpace(summaryErr.err.Error()), true
}
// Output-limit failures need a different prompt, rather than repeating the same
// overlong summary or accepting truncated text as replacement conversation history.
type einoSummaryOutputLimitError struct{ err error }
func (e *einoSummaryOutputLimitError) Error() string { return e.err.Error() }
func (e *einoSummaryOutputLimitError) Unwrap() error { return e.err }
const conciseSummaryRetryInstruction = `The previous summarization request exhausted its output token budget. Generate a new, substantially shorter, complete summary from the original conversation above. Omit the <analysis> block and output only <summary>...</summary>. Preserve authorization, user constraints, confirmed findings, essential technical details, current progress and next steps. Merge repetitions and summarize long logs and asset lists. Do not repeat the full transcript or call tools. Finish all sections and the closing tag within the available output budget.`
func shouldRetryConciseSummary(ctx context.Context, err error) bool {
var limitErr *einoSummaryOutputLimitError
return ctx.Err() == nil && errors.As(err, &limitErr)
}
type nonEmptySummaryChatModel struct {
base model.BaseChatModel
}
@@ -59,6 +73,16 @@ func newNonEmptySummaryChatModel(base model.BaseChatModel) model.BaseChatModel {
}
func (m *nonEmptySummaryChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
out, err := m.generateOnce(ctx, input, opts...)
if !shouldRetryConciseSummary(ctx, err) {
return out, err
}
// Copy the slice: retry instructions must not mutate caller-owned history.
retryInput := append(append([]*schema.Message(nil), input...), schema.UserMessage(conciseSummaryRetryInstruction))
return m.generateOnce(ctx, retryInput, opts...)
}
func (m *nonEmptySummaryChatModel) generateOnce(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
if err := ctx.Err(); err != nil {
return nil, newEinoSummarizationModelError(err)
}
@@ -70,11 +94,15 @@ func (m *nonEmptySummaryChatModel) Generate(ctx context.Context, input []*schema
if err != nil {
return nil, newEinoSummarizationModelError(err)
}
completionErr := validateClassicSummaryCompletion(out)
if shouldRetryConciseSummary(ctx, completionErr) {
return nil, newEinoSummarizationModelError(completionErr)
}
if strings.TrimSpace(classicAssistantTextContent(out)) == "" {
return nil, newEinoSummarizationModelError(fmt.Errorf("summary content is empty: %s", classicSummaryEmptyDiagnostics(out)))
}
if err := validateClassicSummaryCompletion(out); err != nil {
return nil, newEinoSummarizationModelError(err)
if completionErr != nil {
return nil, newEinoSummarizationModelError(completionErr)
}
return out, nil
}
@@ -92,6 +120,16 @@ func newNonEmptyAgenticSummaryModel(base model.BaseModel[*schema.AgenticMessage]
}
func (m *nonEmptyAgenticSummaryModel) Generate(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
out, err := m.generateOnce(ctx, input, opts...)
if !shouldRetryConciseSummary(ctx, err) {
return out, err
}
// Copy the slice: retry instructions must not mutate caller-owned history.
retryInput := append(append([]*schema.AgenticMessage(nil), input...), schema.UserAgenticMessage(conciseSummaryRetryInstruction))
return m.generateOnce(ctx, retryInput, opts...)
}
func (m *nonEmptyAgenticSummaryModel) generateOnce(ctx context.Context, input []*schema.AgenticMessage, opts ...model.Option) (*schema.AgenticMessage, error) {
if err := ctx.Err(); err != nil {
return nil, newEinoSummarizationModelError(err)
}
@@ -103,11 +141,15 @@ func (m *nonEmptyAgenticSummaryModel) Generate(ctx context.Context, input []*sch
if err != nil {
return nil, newEinoSummarizationModelError(err)
}
completionErr := validateAgenticSummaryCompletion(out)
if shouldRetryConciseSummary(ctx, completionErr) {
return nil, newEinoSummarizationModelError(completionErr)
}
if strings.TrimSpace(agenticAssistantTextContent(out)) == "" {
return nil, newEinoSummarizationModelError(fmt.Errorf("summary content is empty: %s", agenticSummaryEmptyDiagnostics(out)))
}
if err := validateAgenticSummaryCompletion(out); err != nil {
return nil, newEinoSummarizationModelError(err)
if completionErr != nil {
return nil, newEinoSummarizationModelError(completionErr)
}
return out, nil
}
@@ -278,6 +320,9 @@ func validateClassicSummaryCompletion(msg *schema.Message) error {
if msg.ResponseMeta != nil {
reason = msg.ResponseMeta.FinishReason
}
if reason == "length" && len(msg.ToolCalls) == 0 {
return &einoSummaryOutputLimitError{err: fmt.Errorf("summary did not complete: finish_reason=%q tool_calls=%d", reason, len(msg.ToolCalls))}
}
if reason != "stop" || len(msg.ToolCalls) != 0 {
return fmt.Errorf("summary did not complete: finish_reason=%q tool_calls=%d", reason, len(msg.ToolCalls))
}
@@ -290,13 +335,21 @@ func validateAgenticSummaryCompletion(msg *schema.AgenticMessage) error {
if ext.FinishReason == "stop" {
return nil
}
return fmt.Errorf("summary did not complete: openai chat finish_reason=%q", ext.FinishReason)
err := fmt.Errorf("summary did not complete: openai chat finish_reason=%q", ext.FinishReason)
if ext.FinishReason == "length" {
return &einoSummaryOutputLimitError{err: err}
}
return err
}
if ext := meta.ClaudeExtension; ext != nil {
if ext.StopReason == "end_turn" {
return nil
}
return fmt.Errorf("summary did not complete: claude stop_reason=%q", ext.StopReason)
err := fmt.Errorf("summary did not complete: claude stop_reason=%q", ext.StopReason)
if ext.StopReason == "max_tokens" {
return &einoSummaryOutputLimitError{err: err}
}
return err
}
if ext := meta.OpenAIExtension; ext != nil {
if ext.Status == schemaopenai.ResponseStatusCompleted && ext.Error == nil && ext.IncompleteDetails == nil {
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -208,13 +209,15 @@ func TestSummaryStreamFailuresDoNotReturnPartialSummary(t *testing.T) {
}
func TestClaudeSummaryLargeBudgetStreamsThroughNativeSDK(t *testing.T) {
for _, mode := range []string{"complete", "truncated", "early_eof", "cancel_read"} {
for _, mode := range []string{"complete", "recovers", "truncated", "early_eof", "cancel_read"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
started := make(chan struct{})
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request := requests.Add(1)
var body struct {
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens"`
@@ -240,6 +243,10 @@ func TestClaudeSummaryLargeBudgetStreamsThroughNativeSDK(t *testing.T) {
`{"type":"message_stop"}`,
}
switch mode {
case "recovers":
if request == 1 {
events[5] = strings.ReplaceAll(events[5], "end_turn", "max_tokens")
}
case "truncated":
events[5] = strings.ReplaceAll(events[5], "end_turn", "max_tokens")
case "early_eof", "cancel_read":
@@ -283,7 +290,14 @@ func TestClaudeSummaryLargeBudgetStreamsThroughNativeSDK(t *testing.T) {
defer timer.Stop()
}
out, err := newNonEmptyAgenticSummaryModel(native).Generate(ctx, input, opts...)
if mode != "complete" {
wantRequests := int32(1)
if mode == "recovers" || mode == "truncated" {
wantRequests = 2
}
if requests.Load() != wantRequests {
t.Fatalf("requests=%d, want %d", requests.Load(), wantRequests)
}
if mode != "complete" && mode != "recovers" {
if out != nil || err == nil {
t.Fatalf("accepted partial summary: out=%+v err=%v", out, err)
}
@@ -3,9 +3,11 @@ package multiagent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"cyberstrike-ai/internal/config"
@@ -148,3 +150,118 @@ func TestEinoSummarizationModelOptionsSetOnlyMaxCompletionTokens(t *testing.T) {
t.Fatalf("max_completion_tokens = %#v, want %d", body["max_completion_tokens"], outputReserve)
}
}
// Exercise both production factories and their real SDK options, not just a
// fake model: a truncated summary must be replaced only by a complete retry.
func TestSummaryOutputLimitConciseRetry(t *testing.T) {
for _, kind := range []string{"classic", "agentic"} {
for _, tc := range []struct {
name string
firstReason string
retryReason string
firstText string
wantCalls int
wantErr bool
}{
{"complete", "stop", "", "complete summary", 1, false},
{"recovers", "length", "stop", "truncated summary", 2, false},
{"reasoning_only_recovers", "length", "stop", "", 2, false},
{"still_truncated", "length", "length", "truncated summary", 2, true},
{"retry_filtered", "length", "content_filter", "truncated summary", 2, true},
{"filtered", "content_filter", "", "filtered summary", 1, true},
{"missing_completion", "", "", "partial summary", 1, true},
} {
t.Run(kind+"/"+tc.name, func(t *testing.T) {
var calls atomic.Int32
bodies := make(chan map[string]any, 3)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := calls.Add(1)
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
w.WriteHeader(400)
return
}
bodies <- body
if r.Header.Get("X-CyberStrike-Summarization") != "1" {
t.Error("missing summary request header")
}
text, reason := tc.firstText, tc.firstReason
if n > 1 {
text, reason = "complete retry summary", tc.retryReason
}
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprintf(w, "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":%q},\"finish_reason\":null}]}\n\n", text)
fmt.Fprintf(w, "data: {\"id\":\"test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":%q}]}\n\n", reason)
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer server.Close()
oa := config.OpenAIConfig{Provider: "openai", APIKey: "test-key", BaseURL: server.URL, Model: "deepseek-chat", MaxCompletionTokens: 128}
const reserve = 512
opts := newEinoSummarizationModelOptions(reserve, oa.Model, kind, &oa, nil)
ctx := context.Background()
var err error
var text string
if kind == "classic" {
base, modelErr := newEinoToolCallingChatModelFactory(server.Client(), nil, nil)(ctx, oa, einoModelModeNormal)
if modelErr != nil {
t.Fatal(modelErr)
}
input := []*schema.Message{schema.UserMessage("original conversation")}
out, genErr := newNonEmptySummaryChatModel(base).Generate(ctx, input, opts...)
err = genErr
text = classicAssistantTextContent(out)
if len(input) != 1 || input[0].Content != "original conversation" {
t.Fatal("mutated original history")
}
} else {
base, modelErr := newEinoAgenticChatModelFactory(server.Client(), nil, nil)(ctx, oa, einoModelModeNormal)
if modelErr != nil {
t.Fatal(modelErr)
}
input := []*schema.AgenticMessage{schema.UserAgenticMessage("original conversation")}
out, genErr := newNonEmptyAgenticSummaryModel(base).Generate(ctx, input, opts...)
err = genErr
text = agenticAssistantTextContent(out)
if len(input) != 1 {
t.Fatal("mutated original history")
}
}
if (err != nil) != tc.wantErr {
t.Fatalf("error = %v, wantErr = %v", err, tc.wantErr)
}
if tc.wantErr && text != "" {
t.Fatalf("accepted partial summary %q", text)
}
if !tc.wantErr && tc.wantCalls == 2 && text != "complete retry summary" {
t.Fatalf("summary = %q", text)
}
if int(calls.Load()) != tc.wantCalls {
t.Fatalf("calls = %d, want %d", calls.Load(), tc.wantCalls)
}
for i := 0; i < tc.wantCalls; i++ {
body := <-bodies
if body["max_completion_tokens"] != float64(reserve) {
t.Fatalf("output budget changed: %#v", body)
}
if _, ok := body["max_tokens"]; ok {
t.Fatal("duplicate output limits")
}
if _, ok := body["reasoning_effort"]; ok {
t.Fatal("reasoning enabled for summary")
}
msgs := body["messages"].([]any)
if i == 1 {
last := msgs[len(msgs)-1].(map[string]any)
if last["role"] != "user" || !strings.Contains(fmt.Sprint(last["content"]), conciseSummaryRetryInstruction) {
t.Fatalf("missing concise retry instruction: %#v", last)
}
if strings.Contains(fmt.Sprint(msgs), "truncated summary") {
t.Fatal("partial response leaked into retry history")
}
}
}
})
}
}
}
+1
View File
@@ -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
}
+6 -1
View File
@@ -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
+90
View File
@@ -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) }
+77
View File
@@ -47219,3 +47219,80 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
.update-adopt-confirm-btn {
align-self: flex-start;
}
/* Provider-reported token analytics in System Settings. */
.token-usage-panel { --token-cache-color: #0d9488; max-width: 1200px; margin: 0 auto 24px; }
html[data-theme="dark"] .token-usage-panel { --token-cache-color: #2dd4bf; }
.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
.token-usage-subtitle { font-size: 13px; color: var(--text-secondary); margin: 8px 0 0; }
.token-usage-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.token-usage-scope { font-size: 11px; padding: 4px 8px; border-radius: 6px; color: var(--text-secondary); background: var(--bg-secondary); }
.token-usage-toolbar label { white-space: nowrap; font-size: 13px; }
.token-usage-toolbar select { padding: 7px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary); }
.token-usage-note { color: var(--text-secondary); font-size: 12px; line-height: 1.7; margin: 16px 0 20px; }
.token-usage-note summary { cursor: pointer; width: fit-content; }
.token-usage-note p { margin: 8px 0 0; max-width: 960px; }
.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.token-usage-metric { min-width: 0; padding: 16px; border: 1px solid var(--border-color); border-radius: 12px; }
.token-usage-metric span { display: block; color: var(--text-secondary); font-size: 12px; }
.token-usage-metric strong { display: block; margin-top: 10px; font-size: clamp(20px, 1.65vw, 28px); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; letter-spacing: -.025em; }
.token-usage-primary [data-metric="usageTotal"] { background: rgba(0, 102, 255, .05); border-color: rgba(0, 102, 255, .18); }
.token-usage-primary [data-metric="usageTotal"] strong { color: var(--accent-color); }
.token-usage-primary [data-metric="usageCacheRate"] strong { color: var(--token-cache-color); }
.token-usage-secondary { margin: 12px 0 24px; gap: 0; padding: 14px 0; border: 1px solid var(--border-color); border-radius: 12px; }
.token-usage-secondary .token-usage-metric { border: 0; border-radius: 0; padding: 0 16px; }
.token-usage-secondary .token-usage-metric + .token-usage-metric { border-left: 1px solid var(--border-color); }
.token-usage-secondary strong { margin-top: 6px; font-size: 18px; }
.token-usage-charts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(235px, 1fr); gap: 16px; margin-bottom: 24px; }
.token-usage-chart-card, .token-usage-data-card { min-width: 0; border: 1px solid var(--border-color); border-radius: 12px; padding: 18px; }
.token-usage-panel h4 { margin: 0 0 16px; font-size: 14px; }
.token-usage-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.token-usage-chart-head h4 { margin: 0; }
.token-usage-chart-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
.token-usage-chart-tabs button { cursor: pointer; border: 0; border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 11px; color: var(--text-secondary); background: transparent; }
.token-usage-chart-tabs button[aria-pressed="true"] { color: var(--accent-color); background: rgba(0, 102, 255, .08); }
.token-usage-chart-tabs button:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }
.token-usage-trend-svg { display: block; width: 100%; height: auto; overflow: visible; }
.token-usage-svg-wrap { overflow-x: auto; }
.token-usage-chart-grid { stroke: var(--border-color); stroke-dasharray: 3 4; }
.token-usage-chart-label { fill: var(--text-secondary); font-size: 11px; }
.token-usage-chart-tooltip { font-size: 11px; color: var(--text-secondary); line-height: 1.6; min-height: 35px; margin: 8px 0 0; }
.token-usage-chart-bar { fill: var(--accent-color); opacity: .8; }
.token-usage-chart-bar:hover, .token-usage-chart-bar:focus { opacity: 1; outline: none; stroke: var(--text-primary); stroke-width: 1; }
.token-usage-cache-ring { --cache-ratio: 0%; position: relative; width: 148px; height: 148px; margin: 8px auto 18px; border-radius: 50%; background: conic-gradient(var(--token-cache-color) var(--cache-ratio), var(--border-color) 0); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
.token-usage-cache-ring::before { content: ''; position: absolute; inset: 12px; border-radius: 50%; background: var(--bg-primary); }
.token-usage-cache-ring strong, .token-usage-cache-ring span { position: relative; }
.token-usage-cache-ring strong { font-size: 28px; font-variant-numeric: tabular-nums; }
.token-usage-cache-ring span { color: var(--text-secondary); font-size: 12px; }
.token-usage-cache-legend { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; margin: 10px 0; }
.token-usage-cache-legend > span::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 2px; background: var(--border-color); margin-right: 7px; }
.token-usage-cache-legend.is-cached > span::before { background: var(--token-cache-color); }
.token-usage-cache-legend strong { font-variant-numeric: tabular-nums; }
.token-usage-chart-note { font-size: 11px; line-height: 1.6; color: var(--text-secondary); margin: 14px 0 0; }
.token-usage-data-card { margin-bottom: 16px; }
.token-usage-data-card > summary { cursor: pointer; font-size: 14px; font-weight: 600; }
.token-usage-data-card[open] > summary { margin-bottom: 16px; }
.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: 12px 10px; text-align: right; border-bottom: 1px solid var(--border-color); white-space: nowrap; }
.token-usage-table-wrap th { font-weight: 500; color: var(--text-secondary); background: var(--bg-secondary); }
.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: 220px; overflow: hidden; text-overflow: ellipsis; }
.token-usage-table-wrap tbody tr:last-child td { border-bottom: 0; }
.token-usage-share { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
.token-usage-share-track { width: 48px; height: 4px; border-radius: 4px; background: var(--border-color); overflow: hidden; }
.token-usage-share-track > span { display: block; height: 100%; background: var(--accent-color); }
.token-usage-status { display: inline-block; padding: 3px 7px; border-radius: 5px; background: var(--bg-secondary); color: var(--text-secondary); }
.token-usage-status.is-final { background: rgba(13, 148, 136, .1); color: var(--token-cache-color); }
.token-usage-status.is-partial { background: rgba(217, 119, 6, .1); color: #d97706; }
.token-usage-empty { text-align: center; padding: 36px 16px; color: var(--text-secondary); font-size: 13px; }
@media (max-width: 1000px) { .token-usage-charts { grid-template-columns: 1fr; } }
@media (max-width: 640px) {
.token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.token-usage-metric { padding: 14px; }
.token-usage-secondary { gap: 14px 0; }
.token-usage-secondary .token-usage-metric:nth-child(3) { border-left: 0; }
.token-usage-chart-card, .token-usage-data-card { padding: 14px; }
.token-usage-subtitle { max-width: 220px; line-height: 1.6; }
.token-usage-trend-svg { min-width: 480px; }
}
+46 -5
View File
@@ -277,6 +277,19 @@
"capabilities": "Capabilities",
"administration": "Administration"
},
"tokenUsageUI": {
"chartHint": "Hover or focus a bar for exact usage",
"scopeProject": "Current project",
"scopeAccessible": "Accessible conversations",
"subtitle": "Explore model consumption, caching and usage trends",
"scopeNote": "Scope and counting rules",
"trendMetric": "Trend metric",
"noDailyData": "No daily usage records",
"cacheComposition": "Input cache breakdown",
"cacheHit": "Cache hit rate",
"uncachedInput": "Input without cache reads",
"cacheNote": "Cache reads are part of input, not added to the total again. Cache writes are listed separately."
},
"dashboard": {
"title": "Dashboard",
"refresh": "Refresh",
@@ -287,7 +300,8 @@
"toolCalls": "Tool invocations",
"successRate": "Tool success rate",
"tokenUsage": "Token usage",
"tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}",
"usageCacheHitRate": "Cache hit {{rate}}",
"tokenUsageSub": "Cache hit {{rate}}",
"noTokenUsageYet": "No usage yet",
"clickToViewTasks": "Click to view tasks",
"clickToViewChat": "Click to view conversations",
@@ -433,7 +447,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 +835,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 +1019,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",
@@ -1942,6 +1983,7 @@
"settings": {
"title": "System settings",
"nav": {
"tokenUsage": "Token usage",
"basic": "Basic",
"hitl": "Human-in-the-loop",
"knowledge": "Knowledge base",
@@ -3722,7 +3764,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)",
+46 -4
View File
@@ -277,6 +277,19 @@
"capabilities": "Возможности",
"administration": "Администрирование"
},
"tokenUsageUI": {
"chartHint": "Наведите или выберите столбец для точных значений",
"scopeProject": "Текущий проект",
"scopeAccessible": "Доступные диалоги",
"subtitle": "Расход моделей, использование кэша и динамика",
"scopeNote": "Область и правила подсчёта",
"trendMetric": "Показатель графика",
"noDailyData": "Нет ежедневных данных",
"cacheComposition": "Кэш входных токенов",
"cacheHit": "Доля попаданий в кэш",
"uncachedInput": "Вход без чтения кэша",
"cacheNote": "Чтение кэша входит во входные токены и не прибавляется к итогу. Запись кэша указана отдельно."
},
"dashboard": {
"title": "Обзор",
"refresh": "Обновить",
@@ -287,7 +300,8 @@
"toolCalls": "Вызовы инструментов",
"successRate": "Успешность инструментов",
"tokenUsage": "Расход токенов",
"tokenUsageSub": "За 7 дней {{calls}} вызовов · сегодня {{today}}",
"usageCacheHitRate": "Попадания в кэш {{rate}}",
"tokenUsageSub": "Кэш {{rate}}",
"noTokenUsageYet": "Расхода пока нет",
"clickToViewTasks": "Открыть задачи",
"clickToViewChat": "Открыть диалоги",
@@ -445,7 +459,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 +847,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 +1031,8 @@
"hitlTimeoutUnlimited": "Без ограничения",
"hitlTimeoutHint": "Запросы без ответа автоматически отклоняются по истечении времени; на карточках одобрения виден отсчёт.",
"hitlStatusOff": "Контроль человеком: выкл.",
"rolePanelTitle": "Выбор роли"
"rolePanelTitle": "Выбор роли",
"einoUsageCacheWriteTokens": "Токены записи кэша"
},
"toolGuard": {
"invalidTestResponse": "Сервер вернул некорректный результат проверки",
@@ -1955,6 +1996,7 @@
"settings": {
"title": "Системные настройки",
"nav": {
"tokenUsage": "Расход токенов",
"basic": "Основные",
"hitl": "Человек в контуре",
"knowledge": "База знаний",
+46 -5
View File
@@ -277,6 +277,19 @@
"capabilities": "能力中心",
"administration": "平台管理"
},
"tokenUsageUI": {
"chartHint": "悬停或聚焦柱形查看精确用量",
"scopeProject": "当前项目",
"scopeAccessible": "可访问会话",
"subtitle": "查看模型消耗、缓存利用与使用趋势",
"scopeNote": "统计口径与说明",
"trendMetric": "趋势指标",
"noDailyData": "暂无每日用量记录",
"cacheComposition": "输入缓存构成",
"cacheHit": "缓存命中率",
"uncachedInput": "未命中缓存的输入",
"cacheNote": "缓存读取是输入的一部分,不重复计入总量。缓存写入单独列示。"
},
"dashboard": {
"title": "仪表盘",
"refresh": "刷新",
@@ -287,7 +300,8 @@
"toolCalls": "工具调用次数",
"successRate": "工具执行成功率",
"tokenUsage": "Token 用量",
"tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}",
"usageCacheHitRate": "缓存命中 {{rate}}",
"tokenUsageSub": "缓存命中 {{rate}}",
"noTokenUsageYet": "暂无用量",
"clickToViewTasks": "点击查看任务管理",
"clickToViewChat": "点击查看对话",
@@ -421,7 +435,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 +823,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 +1007,8 @@
"hitlTimeoutUnlimited": "不限制",
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
"hitlStatusOff": "人机协同:关闭",
"rolePanelTitle": "选择角色"
"rolePanelTitle": "选择角色",
"einoUsageCacheWriteTokens": "缓存写入 tokens"
},
"toolGuard": {
"invalidTestResponse": "服务端返回的测试结果格式不正确",
@@ -1930,6 +1971,7 @@
"settings": {
"title": "系统设置",
"nav": {
"tokenUsage": "Token 用量",
"basic": "基本设置",
"hitl": "人机协同",
"knowledge": "知识库",
@@ -3710,7 +3752,6 @@
"hitlPolicy": "工具审批",
"hitlHint": "队列内所有任务(含后续添加的任务)使用此设置;白名单、审计策略和等待时限沿用全局配置。",
"hitlSubtaskHint": "新增任务沿用队列的工具审批设置,可在队列详情中修改。",
"title": "新建任务",
"queueTitle": "任务标题",
"queueTitlePlaceholder": "请输入任务标题(可选,用于标识和筛选)",
+1 -1
View File
@@ -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);
+7
View File
@@ -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 || ''
+5 -6
View File
@@ -162,7 +162,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=7&limit=20'))
]);
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
@@ -441,6 +441,7 @@ async function refreshDashboard() {
setEl('dashboard-kpi-success-rate', '-');
setEl('dashboard-kpi-tools-calls', '-');
setEl('dashboard-kpi-token-usage', '-');
setKpiSubText('dashboard-kpi-token-sub-text', '-');
setKpiSubText('dashboard-kpi-tasks-sub-text', '-');
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
@@ -714,9 +715,8 @@ function renderDashboardTokenUsage(res) {
setKpiSubText('dashboard-kpi-token-sub-text', '-');
return;
}
const cacheRate = tokenUsageCacheRate(summary);
const total = Number(summary.totalTokens || 0);
const calls = Number(summary.modelCalls || 0);
const today = res && res.today ? Number(res.today.totalTokens || 0) : 0;
if (!Number.isFinite(total) || total <= 0) {
setEl('dashboard-kpi-token-usage', '0');
setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.noTokenUsageYet', null, '暂无用量'));
@@ -725,9 +725,8 @@ function renderDashboardTokenUsage(res) {
setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total));
setKpiSubText('dashboard-kpi-token-sub-text',
dt('dashboard.tokenUsageSub', {
today: formatTokenUsageCompact(today),
calls: Number.isFinite(calls) ? calls : 0
}, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today)));
rate: cacheRate
}, '缓存命中 ' + cacheRate));
}
function formatTokenUsageCompact(num) {
+2 -2
View File
@@ -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._-]+/);
+5 -1
View File
@@ -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);
+13 -4
View File
@@ -1,9 +1,9 @@
// 页面路由管理
let currentPage = null;
/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id=) */
/** 对话、漏洞管理和设置页切换时保留查询串(会话 ID 或设置分区)。 */
function buildHashForPage(pageId) {
if (pageId !== 'chat' && pageId !== 'vulnerabilities') {
if (pageId !== 'chat' && pageId !== 'vulnerabilities' && pageId !== 'settings') {
return pageId;
}
const full = window.location.hash.slice(1);
@@ -111,7 +111,7 @@ function initRouter() {
let pageId = hashParts[0].replace(/^#?\//, '');
if (pageId === 'c2') pageId = 'c2-listeners';
// 一键更新已并进系统设置页的分区菜单:#system-update 这条老深链落到设置页的那一区。
const settingsSection = pageId === 'system-update' ? 'update' : '';
const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : '';
if (settingsSection) pageId = 'settings';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
@@ -541,6 +541,12 @@ async function initPage(pageId) {
if (updateSection && updateSection.classList.contains('active') && typeof loadUpdateConsole === 'function') {
loadUpdateConsole();
}
// Returning through the regular settings nav reloads a retained usage panel.
// Deep links are loaded by switchSettingsSection after switchPage returns.
const tokenSection = document.getElementById('settings-section-token-usage');
if (tokenSection && tokenSection.classList.contains('active') && window.location.hash !== '#settings?section=token-usage' && typeof refreshTokenUsage === 'function') {
refreshTokenUsage();
}
break;
case 'roles-management':
// 初始化角色管理页面
@@ -622,6 +628,9 @@ async function initPage(pageId) {
if (pageId !== 'settings' && typeof stopUpdatePolling === 'function') {
stopUpdatePolling();
}
if (pageId !== 'settings' && typeof stopTokenUsageRequests === 'function') {
stopTokenUsageRequests();
}
}
// 页面加载完成后初始化路由
@@ -639,7 +648,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (pageId === 'c2') pageId = 'c2-listeners';
// 与首屏解析同一套别名:#system-update 落到系统设置页的「一键更新」分区。
const settingsSection = pageId === 'system-update' ? 'update' : '';
const settingsSection = pageId === 'system-update' ? 'update' : pageId === 'settings' && new URLSearchParams(hashParts.slice(1).join('?')).get('section') === 'token-usage' ? 'token-usage' : '';
if (settingsSection) pageId = 'settings';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
+12
View File
@@ -661,6 +661,13 @@ function switchSettingsSection(section) {
return;
}
// Unknown hash sections must not hide every settings panel.
const requestedContent = document.getElementById(`settings-section-${section}`);
if (!requestedContent) return;
if (window.location.hash.split('?')[0] === '#settings') {
history.replaceState(null, '', section === 'token-usage' ? '#settings?section=token-usage' : '#settings');
}
// 更新导航项状态
document.querySelectorAll('.settings-nav-item').forEach(item => {
item.classList.remove('active');
@@ -678,6 +685,11 @@ function switchSettingsSection(section) {
if (activeContent) {
activeContent.classList.add('active');
initSettingsCustomSelects(activeContent);
}
if (section === 'token-usage' && typeof refreshTokenUsage === 'function') {
refreshTokenUsage();
} else if (typeof stopTokenUsageRequests === 'function') {
stopTokenUsageRequests();
}
if (section === 'robots' && typeof window.loadVulnerabilityAlertSubscription === 'function') {
window.loadVulnerabilityAlertSubscription();
@@ -0,0 +1,93 @@
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/token-usage.js', 'utf8');
const harness = () => {
const context = { document: { addEventListener() {} }, window: {}, AbortController };
vm.createContext(context);
vm.runInContext(source, context);
return context;
};
test('cache and reasoning details do not inflate total or double-count input', () => {
const ctx = harness();
const metrics = ctx.tokenUsageMetrics({ 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.tokenUsageMetrics({})).usageCacheRate, '—');
assert.equal(ctx.tokenUsageCount(Infinity), 0);
assert.equal(ctx.tokenUsageCount(-12), 0);
});
test('period selection refreshes once and rejects arbitrary periods', () => {
const ctx = harness();
let calls = 0;
ctx.refreshTokenUsage = () => { calls++; };
ctx.document.getElementById = () => ({ textContent: '' });
ctx.changeTokenUsagePeriod('30');
ctx.changeTokenUsagePeriod('999');
assert.equal(ctx.tokenUsageState.days, 30);
assert.equal(calls, 1);
});
test('cache hit ratio uses input tokens, clamps invalid detail and handles missing input', () => {
const ctx = harness();
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 930, totalTokens: 1100 }), '93.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 1000, cachedTokens: 0 }), '0.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 10, cachedTokens: 30 }), '100.0%');
assert.equal(ctx.tokenUsageCacheRate({ promptTokens: 0, cachedTokens: 30 }), '—');
assert.equal(ctx.tokenUsageCacheRate(null), '—');
});
test('daily trends retain server day keys and fill only gaps within returned history', () => {
const ctx = harness();
const rows = ctx.tokenUsageTimeline([{ key: '2026-10-12', totalTokens: 12 }, { key: '2026-10-10', totalTokens: 10 }, { key: 'invalid', totalTokens: 20 }]);
assert.deepEqual(Array.from(rows, row => row.key), ['2026-10-10', '2026-10-11', '2026-10-12']);
assert.equal(rows[1].totalTokens, 0);
assert.equal(rows[1].events, 0);
assert.equal(rows[2].totalTokens, 12);
assert.equal(ctx.tokenUsageTimeline([]).length, 0);
assert.equal(ctx.tokenUsageTimeline([{ key: '2026-10-10', totalTokens: 10 }]).length, 1);
});
test('detail requests stay project scoped and discard late results after a period change', async () => {
const ctx = harness();
const host = { setAttribute() {}, removeAttribute() {}, textContent: '' };
ctx.document.getElementById = id => id === 'settings-token-analysis' ? host : null;
ctx.getActiveProjectId = () => 'project / sample';
const requests = [];
ctx.apiFetch = (url, options) => new Promise(resolve => requests.push({ url, options, resolve }));
const rendered = [];
ctx.renderTokenUsageAnalysis = value => rendered.push(value);
const first = ctx.refreshTokenUsage();
ctx.tokenUsageState.days = 30;
const second = ctx.refreshTokenUsage();
assert.ok(requests[0].options.signal.aborted);
assert.match(requests[1].url, /days=30.*project_id=project%20%2F%20sample/);
requests[1].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 30 } }) });
await second;
requests[0].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 7 } }) });
await first;
assert.equal(rendered.length, 1);
assert.equal(rendered[0].summary.totalTokens, 30);
const pending = ctx.refreshTokenUsage();
ctx.stopTokenUsageRequests();
requests[2].resolve({ ok: true, json: async () => ({ summary: { totalTokens: 90 } }) });
await pending;
assert.equal(rendered.length, 1, 'leaving the section must invalidate outstanding results');
});
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', 'usageCacheHitRate']) assert.ok(data.dashboard[key]);
assert.ok(data.settings.nav.tokenUsage);
for (const key of ['subtitle', 'scopeNote', 'trendMetric', 'cacheComposition', 'cacheHit', 'uncachedInput', 'cacheNote', 'scopeProject', 'scopeAccessible', 'noDailyData']) assert.ok(data.tokenUsageUI[key]);
assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/);
assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/);
assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/);
}
});
+291
View File
@@ -0,0 +1,291 @@
// Token detail data belongs to the settings section; the dashboard keeps a
// separate fixed seven-day summary.
var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null, trendMetric: 'totalTokens' };
function tokenUsageText(key, opts, fallback) {
if (typeof window.t === 'function') {
const text = window.t(key, opts);
if (text && text !== key) return text;
}
return fallback == null ? key : fallback;
}
function openDashboardTokenUsage() {
if (window.location.hash === '#settings?section=token-usage') {
switchPage('settings');
switchSettingsSection('token-usage');
} else {
window.location.hash = 'settings?section=token-usage';
}
}
function stopTokenUsageRequests() {
tokenUsageState.requestSequence++;
if (tokenUsageState.controller) tokenUsageState.controller.abort();
tokenUsageState.controller = null;
}
async function refreshTokenUsage() {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
stopTokenUsageRequests();
const sequence = tokenUsageState.requestSequence;
const controller = new AbortController();
tokenUsageState.controller = controller;
host.setAttribute('aria-busy', 'true');
host.textContent = tokenUsageText('common.loading', null, '加载中…');
const select = document.getElementById('settings-token-days');
if (select) select.value = String(tokenUsageState.days);
let url = '/api/usage/tokens?days=' + tokenUsageState.days + '&limit=20';
const projectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() : '';
const scope = document.getElementById('settings-token-scope');
if (scope) scope.textContent = tokenUsageText('tokenUsageUI.' + (projectId ? 'scopeProject' : 'scopeAccessible'), null, projectId ? '当前项目' : '可访问会话');
if (projectId) url += '&project_id=' + encodeURIComponent(projectId);
try {
const response = await apiFetch(url, { signal: controller.signal });
const result = response && response.ok ? await response.json() : null;
if (sequence !== tokenUsageState.requestSequence) return;
tokenUsageState.result = result;
renderTokenUsageAnalysis(result);
} catch (_) {
if (sequence !== tokenUsageState.requestSequence) return;
tokenUsageState.result = null;
renderTokenUsageAnalysis(null);
} finally {
if (sequence === tokenUsageState.requestSequence) {
tokenUsageState.controller = null;
host.removeAttribute('aria-busy');
}
}
}
function changeTokenUsagePeriod(value) {
const days = Number(value);
if (![7, 30, 90].includes(days)) return;
tokenUsageState.days = days;
refreshTokenUsage();
}
function tokenUsageCacheRate(summary) {
const input = tokenUsageCount(summary && summary.promptTokens);
const cached = tokenUsageCount(summary && summary.cachedTokens);
return input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—';
}
function tokenUsageCount(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
}
function tokenUsageMetrics(summary) {
const input = tokenUsageCount(summary.promptTokens);
const cached = tokenUsageCount(summary.cachedTokens);
return [
['usageTotal', summary.totalTokens],
['usageInput', input],
['usageOutput', summary.completionTokens],
['usageCacheRead', cached],
['usageCacheWrite', summary.cacheWriteTokens],
['usageReasoning', summary.reasoningTokens],
['usageCalls', summary.modelCalls],
['usageCacheRate', tokenUsageCacheRate(summary)]
];
}
document.addEventListener('languagechange', function () {
if (tokenUsageState.controller) return;
const page = document.getElementById('page-settings');
const section = document.getElementById('settings-section-token-usage');
if (page && page.classList.contains('active') && section && section.classList.contains('active')) {
renderTokenUsageAnalysis(tokenUsageState.result);
}
});
function tokenUsageNode(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
// Fill only gaps between returned dates. The API's server-local day keys are
// authoritative; do not invent a browser-timezone endpoint for the period.
function tokenUsageTimeline(rows) {
const dated = (rows || []).filter(row => /^\d{4}-\d{2}-\d{2}$/.test(row.key || '')).sort((a, b) => a.key.localeCompare(b.key));
if (!dated.length) return [];
const byDay = new Map(dated.map(row => [row.key, row]));
const start = Date.parse(dated[0].key + 'T00:00:00Z');
const end = Date.parse(dated[dated.length - 1].key + 'T00:00:00Z');
if (!Number.isFinite(start) || !Number.isFinite(end)) return dated;
const days = [];
for (let time = start; time <= end && days.length < 365; time += 86400000) {
const key = new Date(time).toISOString().slice(0, 10);
days.push(byDay.get(key) || { key, events: 0, totalTokens: 0, modelCalls: 0, promptTokens: 0 });
}
return days;
}
function setTokenUsageTrend(metric) {
if (!['totalTokens', 'modelCalls', 'cacheRate'].includes(metric)) return;
tokenUsageState.trendMetric = metric;
renderTokenUsageAnalysis(tokenUsageState.result);
const button = document.querySelector('[data-token-trend="' + metric + '"]');
if (button) button.focus();
}
function renderTokenUsageAnalysis(res) {
const host = document.getElementById('settings-token-analysis');
if (!host) return;
host.replaceChildren();
const label = key => tokenUsageText('dashboard.' + key, null, key);
const ui = key => tokenUsageText('tokenUsageUI.' + key, null, key);
const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN');
if (!res || !res.summary) {
const empty = tokenUsageNode('div', 'token-usage-empty', label('usageUnavailable'));
host.append(empty);
return;
}
const summary = res.summary;
const metrics = new Map(tokenUsageMetrics(summary));
const primary = tokenUsageNode('div', 'token-usage-metrics token-usage-primary');
const secondary = tokenUsageNode('div', 'token-usage-metrics token-usage-secondary');
const primaryKeys = ['usageTotal', 'usageInput', 'usageOutput', 'usageCacheRate'];
[...primaryKeys, 'usageCalls', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning'].forEach(key => {
const value = metrics.get(key);
const card = tokenUsageNode('div', 'token-usage-metric');
card.dataset.metric = key;
card.append(tokenUsageNode('span', '', label(key)), tokenUsageNode('strong', '', typeof value === 'string' ? value : exact(value)));
(primaryKeys.includes(key) ? primary : secondary).append(card);
});
host.append(primary, secondary);
if (!tokenUsageCount(summary.events)) {
host.append(tokenUsageNode('div', 'token-usage-empty', label('noTokenUsageYet')));
return;
}
const chartLayout = tokenUsageNode('div', 'token-usage-charts');
const chart = tokenUsageNode('section', 'token-usage-chart-card');
const chartHead = tokenUsageNode('div', 'token-usage-chart-head');
chartHead.append(tokenUsageNode('h4', '', label('usageTrend')));
const tabs = tokenUsageNode('div', 'token-usage-chart-tabs');
tabs.setAttribute('role', 'group');
tabs.setAttribute('aria-label', ui('trendMetric'));
for (const [metric, key] of [['totalTokens', 'usageTotal'], ['modelCalls', 'usageCalls'], ['cacheRate', 'usageCacheRate']]) {
const button = tokenUsageNode('button', '', label(key));
button.type = 'button';
button.dataset.tokenTrend = metric;
button.setAttribute('aria-pressed', String(tokenUsageState.trendMetric === metric));
button.addEventListener('click', () => setTokenUsageTrend(metric));
tabs.append(button);
}
chartHead.append(tabs);
chart.append(chartHead);
const tooltip = tokenUsageNode('p', 'token-usage-chart-tooltip', ui('chartHint'));
tooltip.setAttribute('aria-live', 'polite');
const days = tokenUsageTimeline(res.byDay);
const metric = tokenUsageState.trendMetric;
const valueOf = row => metric === 'cacheRate' ? (tokenUsageCount(row.promptTokens) ? Math.min(tokenUsageCount(row.cachedTokens), tokenUsageCount(row.promptTokens)) / tokenUsageCount(row.promptTokens) * 100 : 0) : tokenUsageCount(row[metric]);
const peak = metric === 'cacheRate' ? 100 : Math.max(1, ...days.map(valueOf));
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 640 230');
svg.setAttribute('role', 'group');
svg.setAttribute('aria-label', label('usageTrend'));
svg.classList.add('token-usage-trend-svg');
const element = (tag, attrs, text) => {
const node = document.createElementNS(svgNS, tag);
Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, String(value)));
if (text != null) node.textContent = text;
return node;
};
for (let tick = 0; tick <= 4; tick++) {
const y = 18 + tick * 44;
svg.append(element('line', { x1: 54, x2: 624, y1: y, y2: y, class: 'token-usage-chart-grid' }));
svg.append(element('text', { x: 46, y: y + 4, 'text-anchor': 'end', class: 'token-usage-chart-label' }, metric === 'cacheRate' ? (100 - tick * 25) + '%' : formatTokenUsageCompact(peak * (4 - tick) / 4)));
}
const slot = 570 / Math.max(days.length, 1);
const every = Math.max(1, Math.ceil(days.length / 7));
days.forEach((row, index) => {
const x = 54 + slot * (index + .5);
const height = valueOf(row) / peak * 176;
const detail = row.key + ' · ' + label('usageTotal') + ' ' + exact(row.totalTokens) + ' · ' + label('usageCalls') + ' ' + exact(row.modelCalls) + ' · ' + label('usageCacheRate') + ' ' + tokenUsageCacheRate(row);
const bar = element('rect', { x: x - Math.min(24, slot * .55) / 2, y: 194 - height, width: Math.min(24, slot * .55), height: Math.max(height, 1), rx: 3, class: 'token-usage-chart-bar', tabindex: 0, 'aria-label': detail });
bar.append(element('title', {}, detail));
for (const event of ['mouseenter', 'focus']) bar.addEventListener(event, () => { tooltip.textContent = detail; });
for (const event of ['mouseleave', 'blur']) bar.addEventListener(event, () => { tooltip.textContent = ui('chartHint'); });
svg.append(bar);
if (index % every === 0 || index === days.length - 1) {
svg.append(element('text', { x, y: 218, 'text-anchor': 'middle', class: 'token-usage-chart-label' }, row.key.slice(5)));
}
});
const plot = tokenUsageNode('div', 'token-usage-svg-wrap');
plot.append(svg);
chart.append(plot);
chart.append(tooltip);
if (!days.length) chart.append(tokenUsageNode('p', 'token-usage-empty', ui('noDailyData')));
chartLayout.append(chart);
const cache = tokenUsageNode('section', 'token-usage-chart-card token-usage-cache-card');
cache.append(tokenUsageNode('h4', '', ui('cacheComposition')));
const input = tokenUsageCount(summary.promptTokens);
const cached = Math.min(input, tokenUsageCount(summary.cachedTokens));
const ratio = input ? cached / input * 100 : 0;
const ring = tokenUsageNode('div', 'token-usage-cache-ring');
ring.style.setProperty('--cache-ratio', ratio + '%');
ring.append(tokenUsageNode('strong', '', tokenUsageCacheRate(summary)), tokenUsageNode('span', '', ui('cacheHit')));
cache.append(ring);
for (const [key, value, className] of [['usageCacheRead', cached, 'is-cached'], ['uncachedInput', input - cached, 'is-uncached']]) {
const line = tokenUsageNode('div', 'token-usage-cache-legend ' + className);
line.append(tokenUsageNode('span', '', key === 'uncachedInput' ? ui(key) : label(key)), tokenUsageNode('strong', '', exact(value)));
cache.append(line);
}
cache.append(tokenUsageNode('p', 'token-usage-chart-note', ui('cacheNote')));
chartLayout.append(cache);
host.append(chartLayout);
// Provider labels are untrusted: render all values as text or constructed
// nodes, never as HTML. Full precision is retained in every table.
const table = (titleKey, headings, rows, collapsed = false) => {
const section = tokenUsageNode(collapsed ? 'details' : 'section', 'token-usage-data-card');
const title = tokenUsageNode(collapsed ? 'summary' : 'h4', '', label(titleKey));
const wrap = tokenUsageNode('div', 'token-usage-table-wrap');
const grid = tokenUsageNode('table');
const caption = tokenUsageNode('caption', 'sr-only', label(titleKey));
const head = tokenUsageNode('thead');
const hr = tokenUsageNode('tr');
headings.forEach(key => { const th = tokenUsageNode('th', '', label(key)); th.scope = 'col'; hr.append(th); });
head.append(hr);
const body = tokenUsageNode('tbody');
rows.forEach(values => {
const tr = tokenUsageNode('tr');
values.forEach(value => {
const td = tokenUsageNode('td');
if (value && value.nodeType) td.append(value);
else 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 => {
const ratio = tokenUsageCount(summary.totalTokens) ? tokenUsageCount(row.totalTokens) / tokenUsageCount(summary.totalTokens) * 100 : 0;
const share = tokenUsageNode('div', 'token-usage-share');
share.append(tokenUsageNode('span', '', ratio.toFixed(1) + '%'));
const track = tokenUsageNode('span', 'token-usage-share-track');
const fill = tokenUsageNode('span'); fill.style.width = Math.min(100, ratio) + '%'; track.append(fill); share.append(track);
return [row.label || row.key, share, ...counts(row)];
}));
table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)]), true);
table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => {
const time = new Date(row.createdAt);
const status = tokenUsageNode('span', 'token-usage-status', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'));
if (row.reason === 'final' || row.reason === 'partial') status.classList.add('is-' + row.reason);
return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', row.model || '—', status, exact(row.totalTokens), exact(row.modelCalls)];
}));
}
@@ -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/);
});
+1 -1
View File
@@ -435,7 +435,7 @@ test('the console lives inside the settings page as its own section', () => {
// Both hash entry points carry the alias, and the retired page id is gone from the whitelists:
// #system-update has to land on 系统设置 with its update section selected.
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : '';/g)];
const aliases = [...router.matchAll(/const settingsSection = pageId === 'system-update' \? 'update' : /g)];
assert.equal(aliases.length, 2, 'both hash parsers must normalize #system-update into the settings section');
const selects = [...router.matchAll(/if \(settingsSection && typeof switchSettingsSection === 'function'\) \{[\s\S]{0,80}?switchSettingsSection\(settingsSection\);/g)];
assert.equal(selects.length, 2, 'both hash paths must open the update section after switching the page');
+3
View File
@@ -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]);
}
+30 -8
View File
@@ -39,14 +39,14 @@
}
})();
</script>
<link rel="stylesheet" href="/static/css/style.css?v=20261010-layout-overflow-1">
<link rel="stylesheet" href="/static/css/style.css?v=20261012-token-analytics">
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
<link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css">
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
<link rel="stylesheet" href="/static/css/mobile.css?v=20261015-project-table-3">
<script src="/static/js/router.js?v=20261009-retest-1"></script>
<script src="/static/js/router.js?v=20261011-token-settings"></script>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display: none;">
@@ -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>
@@ -3602,6 +3602,9 @@
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
<span data-i18n="settings.nav.basic">基本设置</span>
</div>
<div class="settings-nav-item" data-section="token-usage" role="button" tabindex="0" onclick="switchSettingsSection('token-usage')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchSettingsSection('token-usage'); }">
<span data-i18n="settings.nav.tokenUsage">Token 用量</span>
</div>
<div class="settings-nav-item" data-section="hitl" onclick="switchSettingsSection('hitl')">
<span data-i18n="settings.nav.hitl">人机协同</span>
</div>
@@ -3637,6 +3640,24 @@
<!-- 右侧内容区域 -->
<div class="settings-content">
<div id="settings-section-token-usage" class="settings-section-content token-usage-panel">
<div class="settings-section-header token-usage-header">
<div><h3 data-i18n="settings.nav.tokenUsage">Token 用量</h3><p class="token-usage-subtitle" data-i18n="tokenUsageUI.subtitle">查看模型消耗、缓存利用与使用趋势</p></div>
<button id="settings-token-refresh" type="button" class="btn-secondary" onclick="refreshTokenUsage()" data-i18n="common.refresh">刷新</button>
</div>
<div class="token-usage-toolbar">
<label for="settings-token-days" data-i18n="dashboard.usagePeriod">统计周期</label>
<select id="settings-token-days" onchange="changeTokenUsagePeriod(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>
<span id="settings-token-scope" class="token-usage-scope"></span>
</div>
<details class="token-usage-note"><summary data-i18n="tokenUsageUI.scopeNote">统计口径与说明</summary><p data-i18n="dashboard.usageScope">统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。</p></details>
<div id="settings-token-analysis" aria-live="polite"></div>
</div>
<!-- 基本设置 -->
<div id="settings-section-basic" class="settings-section-content active">
<div class="settings-section-header">
@@ -6996,14 +7017,15 @@
<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-settings-cache-only"></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>
<script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/token-usage.js?v=20261012-token-analytics"></script>
<script src="/static/js/settings.js?v=20261011-token-settings"></script>
<script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script>
<script src="/static/js/storage.js"></script>
@@ -7016,7 +7038,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>