From b3d3f48c40cc262a8066da726ca0cca14a3cd6d0 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:30:30 +0800 Subject: [PATCH 1/6] feat: add detailed token usage monitoring and cache write metrics --- docs/zh-CN/token-usage-monitoring.md | 54 +++++++ go.mod | 12 +- go.sum | 12 ++ internal/database/model_token_usage.go | 26 ++-- internal/database/model_token_usage_test.go | 41 ++++- internal/database/schema_reconcile_test.go | 1 + internal/database/schemas/application.sql | 1 + .../multiagent/eino_run_usage_accumulator.go | 14 +- .../eino_run_usage_accumulator_test.go | 22 +++ internal/openai/eino_http.go | 1 + internal/openai/eino_sse_sanitizer.go | 7 +- internal/openai/eino_usage_normalizer.go | 90 +++++++++++ internal/openai/eino_usage_normalizer_test.go | 70 +++++++++ web/static/css/style.css | 22 +++ web/static/i18n/en-US.json | 36 ++++- web/static/i18n/ru-RU.json | 35 ++++- web/static/i18n/zh-CN.json | 36 ++++- web/static/js/chat-scroll-refresh.test.cjs | 2 +- web/static/js/chat.js | 7 + web/static/js/dashboard.js | 141 +++++++++++++++++- web/static/js/hitl-approval-ui.test.cjs | 4 +- web/static/js/monitor.js | 6 +- web/static/js/token-usage-analysis.test.cjs | 43 ++++++ web/static/js/turn-duration-refresh.test.cjs | 2 + web/static/js/webshell.js | 3 + web/templates/index.html | 23 ++- 26 files changed, 666 insertions(+), 45 deletions(-) create mode 100644 docs/zh-CN/token-usage-monitoring.md create mode 100644 internal/openai/eino_usage_normalizer.go create mode 100644 internal/openai/eino_usage_normalizer_test.go create mode 100644 web/static/js/token-usage-analysis.test.cjs diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md new file mode 100644 index 00000000..f8c844b2 --- /dev/null +++ b/docs/zh-CN/token-usage-monitoring.md @@ -0,0 +1,54 @@ +# Token 用量监控:调研与实现 + +调研时间:2026-10-11。 + +## 调研结论 + +| 参考 | 官方能力 | 本项目采用的设计 | +| --- | --- | --- | +| [ZCode 使用统计](https://zcode.z.ai/cn/docs/usage-stats) | 本地会话统计与远端编程套餐额度分别展示;支持时间范围、日趋势和模型占比 | 首页增加可展开的本地用量面板,提供 7/30/90 天、日趋势、模型占比与明细 | +| [DeepSeek 上下文缓存](https://api-docs.deepseek.com/guides/kv_cache/) | 响应 usage 的 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` 区分输入缓存命中和未命中 | 将命中字段补入 OpenAI 标准缓存读取字段;不把未命中错误映射为缓存写入 | +| [Eino v0.9.21](https://github.com/cloudwego/eino/releases/tag/v0.9.21)、[schema PR #1309](https://github.com/cloudwego/eino/pull/1309) | 稳定版新增 `PromptTokenDetails.CacheWriteTokens`,原有缓存读取、输入、输出、推理字段继续保留 | 使用稳定版的标准字段贯穿流式归并、运行汇总、数据库和 UI | +| [Eino-ext PR #1015](https://github.com/cloudwego/eino-ext/pull/1015) | 模型适配器传递缓存写入计数 | 升级 agenticclaude 0.1.7、agenticopenai 0.2.4 与对应 OpenAI ACL,而非仅升级框架 | +| [Eino 回调与 Trace](https://www.cloudwego.io/docs/eino/quick_start/chapter_06_callback_and_trace/) | 组件回调可收集模型生命周期、用量与耗时 | 保留现有运行事件采集;未来要覆盖所有内部调用,应另外设计统一模型调用账本 | + +版本已通过 Go module 解析和本地源码确认;未采用 0.10 alpha。DeepSeek 原始字段当前不会自动进入 Eino 的标准缓存读取计数,因此在项目现有兼容 HTTP 层补齐,覆盖普通 JSON 和 SSE 流式响应。已有标准 `cached_tokens` 优先,未知字段使用 RawMessage 保存,避免浮点转换损失数字精度。 + +## 统计口径 + +- `promptTokens`:模型返回的全部输入。 +- `completionTokens`:全部输出,包括供应商计入其中的推理。 +- `totalTokens`:使用供应商的总量;缺省时由输入加输出补齐。 +- `cachedTokens`:输入中的缓存读取部分。 +- `cacheWriteTokens`:输入中的缓存写入部分。 +- `reasoningTokens`:输出中的推理部分。 +- 缓存读取、写入、推理都是细分,不能再加到总量。缓存读取占输入的比例为 `cachedTokens / promptTokens`;没有输入时显示破折号。 +- 一个流式模型输出可能多次返回累计计数,沿用按字段取最大值的归并方式,整条输出结束后只加入一次运行汇总。部分输出发生错误时,仍可保留已返回用量。 + +DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本长度推导账单级计数,也不能根据缺失字段断言供应商未使用缓存。旧记录的新增字段默认为 0,表示未记录该细分。 + +## 功能入口与数据流 + +点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。 + +运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 + +新增 `model_token_usage.cache_write_tokens INTEGER NOT NULL DEFAULT 0`。启动时现有声明式 SQLite schema reconciliation 自动补列;旧数据库无需手工执行迁移。现有事件 ID 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。 + +面板采用文本节点渲染供应商模型名称,表格在手机上局部横向滚动,提供中文、英文、俄文标签。聊天轮次 tooltip、主对话与 WebShell 时间线均增加缓存写入字段。 + +## 当前边界与后续方向 + +统计基于已记录的 Agent 输出事件,包括已有采集链路中的子 Agent,**不是全站 API 请求账本**。摘要压缩、检索 embedding、隐藏的模型调用、没有返回 usage 的请求不能由当前链路完整覆盖;因请求取消丢失最终 usage 的消耗也无法还原。 + +模型排行按运行配置的模型名称归类,发生 failover 的运行可能混合多个模型,不能据此核算逐模型账单。近期列表每项是运行汇总,不是单次 HTTP 请求;模型调用次数也仅统计返回了非空 usage 的输出。 + +本次不展示费用或套餐剩余额度。自定义中转渠道和订阅套餐的价格、计费方式不同,token 乘固定单价无法可靠代表费用。下一步如需要完整成本管理,应先建设按请求记录的账本:调用来源、真实供应商与模型、成功/错误/取消、耗时、TTFT、usage 是否返回、价格版本和币种;统一覆盖主/子 Agent、压缩、重试、embedding,单独对接供应商额度接口。 + +## 验证 + +- `go test ./...`:全仓通过。 +- `node --test web/static/js/*.test.cjs`:295 项通过。 +- 针对缓存写入的保存/分组/近期记录、旧 schema 补列、用户可见范围、流式去重和总量回退添加回归测试。 +- DeepSeek 普通 JSON/SSE 的缓存字段转换、标准字段优先、错误/终止块保持、超大数值精度验证通过。 +- 浏览器使用合成数据验证桌面、390px 手机、深色模式、模型名称转义、空数据与接口不可用状态;没有读取真实运行数据库,也没有调用付费模型。 diff --git a/go.mod b/go.mod index 894b3864..7d319ebd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c10b3fa3..cc4267ea 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/database/model_token_usage.go b/internal/database/model_token_usage.go index 426bf5f5..6452b717 100644 --- a/internal/database/model_token_usage.go +++ b/internal/database/model_token_usage.go @@ -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) } diff --git a/internal/database/model_token_usage_test.go b/internal/database/model_token_usage_test.go index 5fa1a09f..51b94aae 100644 --- a/internal/database/model_token_usage_test.go +++ b/internal/database/model_token_usage_test.go @@ -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) + } +} diff --git a/internal/database/schema_reconcile_test.go b/internal/database/schema_reconcile_test.go index 3ed2cd01..9c346e94 100644 --- a/internal/database/schema_reconcile_test.go +++ b/internal/database/schema_reconcile_test.go @@ -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() diff --git a/internal/database/schemas/application.sql b/internal/database/schemas/application.sql index 2bba4c23..33754916 100644 --- a/internal/database/schemas/application.sql +++ b/internal/database/schemas/application.sql @@ -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, diff --git a/internal/multiagent/eino_run_usage_accumulator.go b/internal/multiagent/eino_run_usage_accumulator.go index 1cc06cc7..5c468440 100644 --- a/internal/multiagent/eino_run_usage_accumulator.go +++ b/internal/multiagent/eino_run_usage_accumulator.go @@ -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) } diff --git a/internal/multiagent/eino_run_usage_accumulator_test.go b/internal/multiagent/eino_run_usage_accumulator_test.go index a26e3c3b..374c04a2 100644 --- a/internal/multiagent/eino_run_usage_accumulator_test.go +++ b/internal/multiagent/eino_run_usage_accumulator_test.go @@ -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) + } +} diff --git a/internal/openai/eino_http.go b/internal/openai/eino_http.go index a4d761e0..625da8ac 100644 --- a/internal/openai/eino_http.go +++ b/internal/openai/eino_http.go @@ -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 } diff --git a/internal/openai/eino_sse_sanitizer.go b/internal/openai/eino_sse_sanitizer.go index 340b3a04..6f6319c2 100644 --- a/internal/openai/eino_sse_sanitizer.go +++ b/internal/openai/eino_sse_sanitizer.go @@ -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 diff --git a/internal/openai/eino_usage_normalizer.go b/internal/openai/eino_usage_normalizer.go new file mode 100644 index 00000000..0b14bc3b --- /dev/null +++ b/internal/openai/eino_usage_normalizer.go @@ -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() } diff --git a/internal/openai/eino_usage_normalizer_test.go b/internal/openai/eino_usage_normalizer_test.go new file mode 100644 index 00000000..4bd319f9 --- /dev/null +++ b/internal/openai/eino_usage_normalizer_test.go @@ -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) } diff --git a/web/static/css/style.css b/web/static/css/style.css index 5e80d17f..100b2dd9 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47219,3 +47219,25 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } .update-adopt-confirm-btn { align-self: flex-start; } + +/* Provider-reported token analysis, scoped by the dashboard project selector. */ +.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } +.token-usage-panel > summary { cursor: pointer; } +.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } +.token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } +.token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } +.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +.token-usage-metrics > div { padding: 12px; border: 1px solid var(--border-color, #dce2ea); border-radius: 8px; min-width: 0; } +.token-usage-metrics span { display: block; font-size: 12px; opacity: .7; } +.token-usage-metrics strong { display: block; margin-top: 6px; font-size: 20px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +.token-usage-table-wrap { overflow-x: auto; } +.token-usage-table-wrap table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; } +.token-usage-table-wrap th, .token-usage-table-wrap td { padding: 10px; text-align: right; border-bottom: 1px solid var(--border-color, #dce2ea); white-space: nowrap; } +.token-usage-table-wrap th:first-child, .token-usage-table-wrap td:first-child { text-align: left; } +.token-usage-table-wrap td:first-child { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 640px) { .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .token-usage-panel { padding: 14px; } } +.token-usage-trend { display: flex; align-items: flex-end; gap: 8px; overflow-x: auto; padding: 12px 0; } +.token-usage-day { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; flex: 1 0 38px; height: 128px; gap: 6px; font-size: 11px; } +.token-usage-day-bar { width: 70%; max-width: 48px; background: var(--accent-color, #3b82f6); border-radius: 4px 4px 0 0; } + +.token-usage-panel h4 { margin: 20px 0 8px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 14e72eb0..25c5a3c2 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -287,7 +287,7 @@ "toolCalls": "Tool invocations", "successRate": "Tool success rate", "tokenUsage": "Token usage", - "tokenUsageSub": "Last 7 days {{calls}} calls · Today {{today}}", + "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", @@ -433,7 +433,33 @@ "mcpPartialDown_other": "{{count}} stopped", "mcpAllDown": "All stopped", "noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat", - "startScanBtn": "Go to chat to scan" + "startScanBtn": "Go to chat to scan", + "usageView": "View token usage", + "usagePeriod": "Period", + "usage7Days": "Last 7 days", + "usage30Days": "Last 30 days", + "usage90Days": "Last 90 days", + "usageTotal": "Total tokens", + "usageInput": "Input tokens", + "usageOutput": "Output tokens", + "usageCacheRead": "Cache read", + "usageCacheWrite": "Cache write", + "usageReasoning": "Reasoning tokens", + "usageCalls": "Model calls", + "usageCacheRate": "Cache read / input", + "usageByModel": "Usage by model (up to 20)", + "usageByDay": "Daily usage", + "usageRecent": "Recent runs (up to 20)", + "usageDate": "Date", + "usageTime": "Time", + "usageModel": "Model", + "usageStatus": "End reason", + "usageUnavailable": "Token usage is unavailable", + "usageScope": "Provider-reported usage from recorded Agent runs, including sub-agents. Cache reads and writes are part of input; reasoning is part of output. These details are not added to totals again. Unreported or uncollected usage is excluded. This is not a provider bill. Models are grouped by run configuration; runs with model failover may contain mixed usage.", + "usageShare": "Token share", + "usageTrend": "Daily token trend", + "usageFinal": "Completed", + "usagePartial": "Partial usage" }, "projects": { "title": "Projects", @@ -795,7 +821,7 @@ "turnDurationHours": "{{hours}} hr {{minutes}} min", "turnProcessAria": "{{state}}; expand or collapse execution details", "turnTokenUsageLabel": "{{tokens}} tokens", - "turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls)", + "turnTokenUsageTitle": "Token usage: {{total}} (input {{prompt}}, output {{completion}}, cached {{cached}}, reasoning {{reasoning}}, {{calls}} calls) · Cache write {{cacheWrite}}", "turnNumber": "Turn {{number}}", "turnPending": "Processing…", "expandDetailLazyHint": "Expand details (loads iteration details on click)", @@ -979,7 +1005,8 @@ "hitlTimeoutUnlimited": "No limit", "hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.", "hitlStatusOff": "Human-in-the-loop: Off", - "rolePanelTitle": "Select role" + "rolePanelTitle": "Select role", + "einoUsageCacheWriteTokens": "Cache write tokens" }, "toolGuard": { "invalidTestResponse": "The server returned an invalid test result", @@ -3722,7 +3749,6 @@ "hitlPolicy": "Tool approval", "hitlHint": "Applies to all tasks in this queue, including tasks added later. Uses the global whitelist, audit strategy and timeout.", "hitlSubtaskHint": "New tasks use the queue’s tool approval setting. Change it in queue details.", - "title": "New task", "queueTitle": "Queue title", "queueTitlePlaceholder": "Enter queue title (optional, for identification and filtering)", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 02370429..25c0928d 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -287,7 +287,7 @@ "toolCalls": "Вызовы инструментов", "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", - "tokenUsageSub": "За 7 дней {{calls}} вызовов · сегодня {{today}}", + "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", "clickToViewChat": "Открыть диалоги", @@ -445,7 +445,33 @@ "recoApproveHitl": "Одобрить запросы HITL: {{count}}", "recoRestartMcp": "Проверить не запущенные внешние MCP: {{count}}", "recoCheckMonitor": "Разобрать неудачные вызовы инструментов: {{count}}", - "mcpPartialDown": "Не запущено: {{count}}" + "mcpPartialDown": "Не запущено: {{count}}", + "usageView": "Расход токенов", + "usagePeriod": "Период", + "usage7Days": "За 7 дней", + "usage30Days": "За 30 дней", + "usage90Days": "За 90 дней", + "usageTotal": "Всего токенов", + "usageInput": "Входные токены", + "usageOutput": "Выходные токены", + "usageCacheRead": "Чтение кэша", + "usageCacheWrite": "Запись кэша", + "usageReasoning": "Токены рассуждения", + "usageCalls": "Вызовы модели", + "usageCacheRate": "Чтение кэша / ввод", + "usageByModel": "По моделям (до 20)", + "usageByDay": "По дням", + "usageRecent": "Последние запуски (до 20)", + "usageDate": "Дата", + "usageTime": "Время", + "usageModel": "Модель", + "usageStatus": "Причина завершения", + "usageUnavailable": "Статистика недоступна", + "usageShare": "Доля токенов", + "usageTrend": "Расход по дням", + "usageScope": "Данные провайдера из сохранённых запусков Agent, включая субагентов. Чтение и запись кэша входят во ввод, рассуждение — в вывод; они не прибавляются к сумме повторно. Неполученные данные исключены. Это не счёт провайдера. Группировка по модели в конфигурации запуска; при переключении модели расход может быть смешан.", + "usageFinal": "Завершено", + "usagePartial": "Частичный расход" }, "projects": { "title": "Проекты", @@ -807,7 +833,7 @@ "turnDurationHours": "{{hours}} ч {{minutes}} мин", "turnProcessAria": "{{state}}; развернуть или свернуть подробности выполнения", "turnTokenUsageLabel": "{{tokens}} токенов", - "turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}})", + "turnTokenUsageTitle": "Расход токенов: {{total}} (ввод {{prompt}}, вывод {{completion}}, кэш {{cached}}, рассуждение {{reasoning}}, вызовов {{calls}}) · Запись кэша {{cacheWrite}}", "turnNumber": "Ход {{number}}", "turnPending": "Обработка…", "expandDetailLazyHint": "Развернуть подробности (итерации загружаются по щелчку)", @@ -991,7 +1017,8 @@ "hitlTimeoutUnlimited": "Без ограничения", "hitlTimeoutHint": "Запросы без ответа автоматически отклоняются по истечении времени; на карточках одобрения виден отсчёт.", "hitlStatusOff": "Контроль человеком: выкл.", - "rolePanelTitle": "Выбор роли" + "rolePanelTitle": "Выбор роли", + "einoUsageCacheWriteTokens": "Токены записи кэша" }, "toolGuard": { "invalidTestResponse": "Сервер вернул некорректный результат проверки", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6cf0dd9a..00f8d90e 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -287,7 +287,7 @@ "toolCalls": "工具调用次数", "successRate": "工具执行成功率", "tokenUsage": "Token 用量", - "tokenUsageSub": "近 7 天 {{calls}} 次调用 · 今日 {{today}}", + "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", @@ -421,7 +421,33 @@ "mcpPartialDown": "{{count}} 个未运行", "mcpAllDown": "全部未运行", "noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里", - "startScanBtn": "前往对话发起扫描" + "startScanBtn": "前往对话发起扫描", + "usageView": "查看用量详情", + "usagePeriod": "统计周期", + "usage7Days": "近 7 天", + "usage30Days": "近 30 天", + "usage90Days": "近 90 天", + "usageTotal": "总 Token", + "usageInput": "输入 Token", + "usageOutput": "输出 Token", + "usageCacheRead": "缓存读取", + "usageCacheWrite": "缓存写入", + "usageReasoning": "推理 Token", + "usageCalls": "模型调用", + "usageCacheRate": "缓存读取占输入", + "usageByModel": "模型用量(最多 20 项)", + "usageByDay": "每日用量", + "usageRecent": "最近运行(最多 20 项)", + "usageDate": "日期", + "usageTime": "时间", + "usageModel": "模型", + "usageStatus": "结束原因", + "usageUnavailable": "用量统计暂不可用", + "usageScope": "统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。 模型按运行配置归类,切换模型的运行可能混合用量。", + "usageShare": "用量占比", + "usageTrend": "每日用量趋势", + "usageFinal": "已完成", + "usagePartial": "部分用量" }, "projects": { "title": "项目管理", @@ -783,7 +809,7 @@ "turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟", "turnProcessAria": "{{state}},展开或收起执行过程", "turnTokenUsageLabel": "{{tokens}} tokens", - "turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次)", + "turnTokenUsageTitle": "Token 用量:{{total}}(输入 {{prompt}},输出 {{completion}},缓存 {{cached}},推理 {{reasoning}},调用 {{calls}} 次) · 缓存写入 {{cacheWrite}}", "turnNumber": "第 {{number}} 轮", "turnPending": "正在处理…", "expandDetailLazyHint": "展开详情(点击后加载迭代详情)", @@ -967,7 +993,8 @@ "hitlTimeoutUnlimited": "不限制", "hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。", "hitlStatusOff": "人机协同:关闭", - "rolePanelTitle": "选择角色" + "rolePanelTitle": "选择角色", + "einoUsageCacheWriteTokens": "缓存写入 tokens" }, "toolGuard": { "invalidTestResponse": "服务端返回的测试结果格式不正确", @@ -3710,7 +3737,6 @@ "hitlPolicy": "工具审批", "hitlHint": "队列内所有任务(含后续添加的任务)使用此设置;白名单、审计策略和等待时限沿用全局配置。", "hitlSubtaskHint": "新增任务沿用队列的工具审批设置,可在队列详情中修改。", - "title": "新建任务", "queueTitle": "任务标题", "queueTitlePlaceholder": "请输入任务标题(可选,用于标识和筛选)", diff --git a/web/static/js/chat-scroll-refresh.test.cjs b/web/static/js/chat-scroll-refresh.test.cjs index 53cc6a75..24dfa94b 100644 --- a/web/static/js/chat-scroll-refresh.test.cjs +++ b/web/static/js/chat-scroll-refresh.test.cjs @@ -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); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 70637194..8d26bcdb 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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 || '' diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 23243bd5..e45a774a 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -17,6 +17,7 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive']; var dashboardState = { + tokenDays: 7, currentController: null, // 当前正在进行的 fetch 的 AbortController pollTimer: null, // 自动轮询的 setInterval id staleTimer: null, // 过期检查的 setInterval id @@ -162,7 +163,7 @@ async function refreshDashboard() { fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')), fetchJson('/api/projects/dashboard-summary?fact_limit=10'), selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null), - fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=5')) + fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20')) ]); // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 @@ -440,6 +441,7 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); + renderDashboardTokenAnalysis(null); setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); @@ -708,6 +710,7 @@ function setKpiRateBadge(id, rate, failedCount) { } function renderDashboardTokenUsage(res) { + renderDashboardTokenAnalysis(res); const summary = res && res.summary ? res.summary : null; if (!summary) { setEl('dashboard-kpi-token-usage', '-'); @@ -725,9 +728,10 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { + days: dashboardState.tokenDays, today: formatTokenUsageCompact(today), calls: Number.isFinite(calls) ? calls : 0 - }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + }, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); } function formatTokenUsageCompact(num) { @@ -2524,3 +2528,136 @@ document.addEventListener('click', function (ev) { var banner = document.getElementById('dashboard-alert-banner'); if (banner) banner.hidden = true; }); + +function openDashboardTokenUsage() { + const panel = document.getElementById('dashboard-token-details'); + if (!panel) return; + panel.open = true; + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function changeDashboardTokenPeriod(value) { + const days = Number(value); + if (![7, 30, 90].includes(days)) return; + dashboardState.tokenDays = days; + const host = document.getElementById('dashboard-token-analysis'); + if (host) host.textContent = '…'; + refreshDashboard(); +} + +function dashboardUsageCount(value) { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; +} + +function dashboardUsageMetrics(summary) { + const input = dashboardUsageCount(summary.promptTokens); + const cached = dashboardUsageCount(summary.cachedTokens); + return [ + ['usageTotal', summary.totalTokens], + ['usageInput', input], + ['usageOutput', summary.completionTokens], + ['usageCacheRead', cached], + ['usageCacheWrite', summary.cacheWriteTokens], + ['usageReasoning', summary.reasoningTokens], + ['usageCalls', summary.modelCalls], + ['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'] + ]; +} + +function renderDashboardTokenAnalysis(res) { + const host = document.getElementById('dashboard-token-analysis'); + if (!host) return; + host.replaceChildren(); + if (!res || !res.summary) { + host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用'); + return; + } + const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); + const label = key => dt('dashboard.' + key, null, key); + const metrics = document.createElement('div'); + metrics.className = 'token-usage-metrics'; + dashboardUsageMetrics(res.summary).forEach(([key, value]) => { + const card = document.createElement('div'); + const title = document.createElement('span'); + title.textContent = label(key); + const count = document.createElement('strong'); + count.textContent = typeof value === 'string' ? value : exact(value); + card.append(title, count); + metrics.append(card); + }); + host.append(metrics); + if (!dashboardUsageCount(res.summary.events)) { + const empty = document.createElement('p'); + empty.textContent = dt('dashboard.noTokenUsageYet', null, '暂无用量'); + host.append(empty); + return; + } + // Tables use text nodes: provider model names and conversation IDs are untrusted. + const table = (titleKey, headings, rows) => { + const section = document.createElement('section'); + const title = document.createElement('h4'); + title.textContent = label(titleKey); + const wrap = document.createElement('div'); + wrap.className = 'token-usage-table-wrap'; + const grid = document.createElement('table'); + const caption = document.createElement('caption'); + caption.className = 'sr-only'; + caption.textContent = title.textContent; + const head = document.createElement('thead'); + const hr = document.createElement('tr'); + headings.forEach(key => { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = label(key); + hr.append(th); + }); + head.append(hr); + const body = document.createElement('tbody'); + rows.forEach(values => { + const tr = document.createElement('tr'); + values.forEach(value => { + const td = document.createElement('td'); + td.textContent = String(value == null ? '' : value); + tr.append(td); + }); + body.append(tr); + }); + grid.append(caption, head, body); + wrap.append(grid); + section.append(title, wrap); + host.append(section); + }; + const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), + exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; + const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; + table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, + dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); + const trendTitle = document.createElement('h4'); + trendTitle.textContent = label('usageTrend'); + const trend = document.createElement('div'); + trend.className = 'token-usage-trend'; + const days = (res.byDay || []).slice().reverse(); + const peak = Math.max(1, ...days.map(row => dashboardUsageCount(row.totalTokens))); + days.forEach(row => { + const bar = document.createElement('div'); + bar.className = 'token-usage-day'; + const count = document.createElement('span'); + count.textContent = formatTokenUsageCompact(row.totalTokens); + const fill = document.createElement('div'); + fill.className = 'token-usage-day-bar'; + fill.style.height = Math.max(1, dashboardUsageCount(row.totalTokens) / peak * 90) + 'px'; + const date = document.createElement('span'); + date.textContent = String(row.key || '').slice(5); + bar.title = row.key + ': ' + exact(row.totalTokens); + bar.append(count, fill, date); + trend.append(bar); + }); + host.append(trendTitle, trend); + table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { + const time = new Date(row.createdAt); + return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', + row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + })); +} diff --git a/web/static/js/hitl-approval-ui.test.cjs b/web/static/js/hitl-approval-ui.test.cjs index 598bdf12..cf15377e 100644 --- a/web/static/js/hitl-approval-ui.test.cjs +++ b/web/static/js/hitl-approval-ui.test.cjs @@ -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._-]+/); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 8009d489..aafad05c 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -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); diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs new file mode 100644 index 00000000..a6c61896 --- /dev/null +++ b/web/static/js/token-usage-analysis.test.cjs @@ -0,0 +1,43 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8'); +const harness = () => { + const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} }; + vm.createContext(context); + vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), context); + return context; +}; + +test('cache and reasoning details do not inflate total or double-count input', () => { + const ctx = harness(); + const metrics = ctx.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); + const values = Object.fromEntries(metrics); + assert.equal(values.usageTotal, 120); + assert.equal(values.usageCacheWrite, 30); + assert.equal(values.usageCacheRate, '40.0%'); + assert.equal(Object.fromEntries(ctx.dashboardUsageMetrics({})).usageCacheRate, '—'); + assert.equal(ctx.dashboardUsageCount(Infinity), 0); + assert.equal(ctx.dashboardUsageCount(-12), 0); +}); + +test('period selection refreshes once and rejects arbitrary periods', () => { + const ctx = harness(); + let calls = 0; + ctx.refreshDashboard = () => { calls++; }; + ctx.document.getElementById = () => ({ textContent: '' }); + ctx.changeDashboardTokenPeriod('30'); + ctx.changeDashboardTokenPeriod('999'); + assert.equal(ctx.dashboardState.tokenDays, 30); + assert.equal(calls, 1); +}); + +test('all supported languages expose usage labels and interpolation placeholders', () => { + for (const locale of ['zh-CN', 'en-US', 'ru-RU']) { + const data = JSON.parse(fs.readFileSync(`web/static/i18n/${locale}.json`, 'utf8')); + for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend']) assert.ok(data.dashboard[key]); + assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); + assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); + } +}); diff --git a/web/static/js/turn-duration-refresh.test.cjs b/web/static/js/turn-duration-refresh.test.cjs index 2f0d94b6..030128e2 100644 --- a/web/static/js/turn-duration-refresh.test.cjs +++ b/web/static/js/turn-duration-refresh.test.cjs @@ -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/); }); diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 5999b317..b71e3e7c 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -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]); } diff --git a/web/templates/index.html b/web/templates/index.html index 85ed32fb..3ced8a4a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -556,7 +556,7 @@ 运行平稳 -
+
Token 用量
@@ -567,6 +567,19 @@
+
+ 查看用量详情 +
+ + +
+

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
+
@@ -6996,10 +7009,10 @@ - + - - + + @@ -7016,7 +7029,7 @@ - + From 4eda42dc107511e70c54e7f6aeefbfba71fe3c67 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:51:01 +0800 Subject: [PATCH 2/6] feat: move token details to settings and show dashboard cache hit rate --- docs/zh-CN/token-usage-monitoring.md | 2 +- web/static/css/style.css | 5 +- web/static/i18n/en-US.json | 2 + web/static/i18n/ru-RU.json | 2 + web/static/i18n/zh-CN.json | 2 + web/static/js/dashboard.js | 148 +-------------- web/static/js/router.js | 17 +- web/static/js/settings.js | 12 ++ web/static/js/token-usage-analysis.test.cjs | 62 ++++-- web/static/js/token-usage.js | 197 ++++++++++++++++++++ web/static/js/update-ui.test.cjs | 2 +- web/templates/index.html | 45 +++-- 12 files changed, 320 insertions(+), 176 deletions(-) create mode 100644 web/static/js/token-usage.js diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index f8c844b2..98247821 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片或「查看用量详情」展开面板。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。沿用首页自动刷新和请求取消机制。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天调用次数、今日用量与缓存命中率(缓存读取 Token / 输入 Token)。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 100b2dd9..7b1576ac 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47221,9 +47221,12 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } } /* Provider-reported token analysis, scoped by the dashboard project selector. */ +.dashboard-kpi-sub--tokens { row-gap: 2px; } +#dashboard-kpi-token-cache-text { flex-basis: 100%; } .token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } -.token-usage-panel > summary { cursor: pointer; } +.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } +.token-usage-toolbar label { white-space: nowrap; } .token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } .token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } .token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 25c5a3c2..9f3f913c 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -287,6 +287,7 @@ "toolCalls": "Tool invocations", "successRate": "Tool success rate", "tokenUsage": "Token usage", + "usageCacheHitRate": "Cache hit {{rate}}", "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", @@ -1969,6 +1970,7 @@ "settings": { "title": "System settings", "nav": { + "tokenUsage": "Token usage", "basic": "Basic", "hitl": "Human-in-the-loop", "knowledge": "Knowledge base", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 25c0928d..667c550e 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -287,6 +287,7 @@ "toolCalls": "Вызовы инструментов", "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", + "usageCacheHitRate": "Попадания в кэш {{rate}}", "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", @@ -1982,6 +1983,7 @@ "settings": { "title": "Системные настройки", "nav": { + "tokenUsage": "Расход токенов", "basic": "Основные", "hitl": "Человек в контуре", "knowledge": "База знаний", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 00f8d90e..a758dbd3 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -287,6 +287,7 @@ "toolCalls": "工具调用次数", "successRate": "工具执行成功率", "tokenUsage": "Token 用量", + "usageCacheHitRate": "缓存命中 {{rate}}", "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", @@ -1957,6 +1958,7 @@ "settings": { "title": "系统设置", "nav": { + "tokenUsage": "Token 用量", "basic": "基本设置", "hitl": "人机协同", "knowledge": "知识库", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index e45a774a..eec890d3 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -17,7 +17,6 @@ var DASHBOARD_SEVERITY_STATUS_FILTER_STORAGE_KEY = 'cyberstrike.dashboard.severi var DASHBOARD_SEVERITY_STATUS_FILTER_VALUES = ['', 'open', 'confirmed', 'fixed', 'ignored', 'false_positive']; var dashboardState = { - tokenDays: 7, currentController: null, // 当前正在进行的 fetch 的 AbortController pollTimer: null, // 自动轮询的 setInterval id staleTimer: null, // 过期检查的 setInterval id @@ -73,6 +72,7 @@ async function refreshDashboard() { setKpiSubText('dashboard-kpi-tools-sub-text', '…'); setKpiSubText('dashboard-kpi-rate-sub-text', '…'); setKpiSubText('dashboard-kpi-token-sub-text', '…'); + setKpiSubText('dashboard-kpi-token-cache-text', '…'); hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-alert-banner'); setRecentVulnsLoading(); @@ -163,7 +163,7 @@ async function refreshDashboard() { fetchJson(dashboardProjectScopedUrl('/api/c2/tasks?page=1&page_size=1')), fetchJson('/api/projects/dashboard-summary?fact_limit=10'), selectedSeverityStatus ? fetchJson('/api/vulnerabilities/stats?status=' + encodeURIComponent(selectedSeverityStatus)) : Promise.resolve(null), - fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=' + dashboardState.tokenDays + '&limit=20')) + fetchJson(dashboardProjectScopedUrl('/api/usage/tokens?days=7&limit=20')) ]); // 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果 @@ -441,8 +441,9 @@ async function refreshDashboard() { setDashboardOverviewPlaceholder('-'); setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-tools-calls', '-'); - renderDashboardTokenAnalysis(null); setEl('dashboard-kpi-token-usage', '-'); + setKpiSubText('dashboard-kpi-token-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-cache-text', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); @@ -710,13 +711,15 @@ function setKpiRateBadge(id, rate, failedCount) { } function renderDashboardTokenUsage(res) { - renderDashboardTokenAnalysis(res); const summary = res && res.summary ? res.summary : null; if (!summary) { setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); + setKpiSubText('dashboard-kpi-token-cache-text', '-'); return; } + const cacheRate = tokenUsageCacheRate(summary); + setKpiSubText('dashboard-kpi-token-cache-text', dt('dashboard.usageCacheHitRate', { rate: cacheRate }, '缓存命中 ' + cacheRate)); const total = Number(summary.totalTokens || 0); const calls = Number(summary.modelCalls || 0); const today = res && res.today ? Number(res.today.totalTokens || 0) : 0; @@ -728,10 +731,10 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { - days: dashboardState.tokenDays, + days: 7, today: formatTokenUsageCompact(today), calls: Number.isFinite(calls) ? calls : 0 - }, '近 ' + dashboardState.tokenDays + ' 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); } function formatTokenUsageCompact(num) { @@ -2528,136 +2531,3 @@ document.addEventListener('click', function (ev) { var banner = document.getElementById('dashboard-alert-banner'); if (banner) banner.hidden = true; }); - -function openDashboardTokenUsage() { - const panel = document.getElementById('dashboard-token-details'); - if (!panel) return; - panel.open = true; - panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); -} - -function changeDashboardTokenPeriod(value) { - const days = Number(value); - if (![7, 30, 90].includes(days)) return; - dashboardState.tokenDays = days; - const host = document.getElementById('dashboard-token-analysis'); - if (host) host.textContent = '…'; - refreshDashboard(); -} - -function dashboardUsageCount(value) { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0; -} - -function dashboardUsageMetrics(summary) { - const input = dashboardUsageCount(summary.promptTokens); - const cached = dashboardUsageCount(summary.cachedTokens); - return [ - ['usageTotal', summary.totalTokens], - ['usageInput', input], - ['usageOutput', summary.completionTokens], - ['usageCacheRead', cached], - ['usageCacheWrite', summary.cacheWriteTokens], - ['usageReasoning', summary.reasoningTokens], - ['usageCalls', summary.modelCalls], - ['usageCacheRate', input > 0 ? (Math.min(cached, input) / input * 100).toFixed(1) + '%' : '—'] - ]; -} - -function renderDashboardTokenAnalysis(res) { - const host = document.getElementById('dashboard-token-analysis'); - if (!host) return; - host.replaceChildren(); - if (!res || !res.summary) { - host.textContent = dt('dashboard.usageUnavailable', null, '用量统计暂不可用'); - return; - } - const exact = value => dashboardUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); - const label = key => dt('dashboard.' + key, null, key); - const metrics = document.createElement('div'); - metrics.className = 'token-usage-metrics'; - dashboardUsageMetrics(res.summary).forEach(([key, value]) => { - const card = document.createElement('div'); - const title = document.createElement('span'); - title.textContent = label(key); - const count = document.createElement('strong'); - count.textContent = typeof value === 'string' ? value : exact(value); - card.append(title, count); - metrics.append(card); - }); - host.append(metrics); - if (!dashboardUsageCount(res.summary.events)) { - const empty = document.createElement('p'); - empty.textContent = dt('dashboard.noTokenUsageYet', null, '暂无用量'); - host.append(empty); - return; - } - // Tables use text nodes: provider model names and conversation IDs are untrusted. - const table = (titleKey, headings, rows) => { - const section = document.createElement('section'); - const title = document.createElement('h4'); - title.textContent = label(titleKey); - const wrap = document.createElement('div'); - wrap.className = 'token-usage-table-wrap'; - const grid = document.createElement('table'); - const caption = document.createElement('caption'); - caption.className = 'sr-only'; - caption.textContent = title.textContent; - const head = document.createElement('thead'); - const hr = document.createElement('tr'); - headings.forEach(key => { - const th = document.createElement('th'); - th.scope = 'col'; - th.textContent = label(key); - hr.append(th); - }); - head.append(hr); - const body = document.createElement('tbody'); - rows.forEach(values => { - const tr = document.createElement('tr'); - values.forEach(value => { - const td = document.createElement('td'); - td.textContent = String(value == null ? '' : value); - tr.append(td); - }); - body.append(tr); - }); - grid.append(caption, head, body); - wrap.append(grid); - section.append(title, wrap); - host.append(section); - }; - const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), - exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; - const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; - table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, - dashboardUsageCount(res.summary.totalTokens) > 0 ? (dashboardUsageCount(row.totalTokens) / dashboardUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); - const trendTitle = document.createElement('h4'); - trendTitle.textContent = label('usageTrend'); - const trend = document.createElement('div'); - trend.className = 'token-usage-trend'; - const days = (res.byDay || []).slice().reverse(); - const peak = Math.max(1, ...days.map(row => dashboardUsageCount(row.totalTokens))); - days.forEach(row => { - const bar = document.createElement('div'); - bar.className = 'token-usage-day'; - const count = document.createElement('span'); - count.textContent = formatTokenUsageCompact(row.totalTokens); - const fill = document.createElement('div'); - fill.className = 'token-usage-day-bar'; - fill.style.height = Math.max(1, dashboardUsageCount(row.totalTokens) / peak * 90) + 'px'; - const date = document.createElement('span'); - date.textContent = String(row.key || '').slice(5); - bar.title = row.key + ': ' + exact(row.totalTokens); - bar.append(count, fill, date); - trend.append(bar); - }); - host.append(trendTitle, trend); - table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); - table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { - const time = new Date(row.createdAt); - return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', - row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; - })); -} diff --git a/web/static/js/router.js b/web/static/js/router.js index ddb3955d..b98c613c 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -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); diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 9e371efd..0eb2eab9 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -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(); diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index a6c61896..de24409d 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -2,41 +2,79 @@ const fs = require('node:fs'); const vm = require('node:vm'); const test = require('node:test'); const assert = require('node:assert/strict'); -const source = fs.readFileSync('web/static/js/dashboard.js', 'utf8'); +const source = fs.readFileSync('web/static/js/token-usage.js', 'utf8'); const harness = () => { - const context = { document: {}, dashboardState: { tokenDays: 7 }, refreshDashboard() {} }; + const context = { document: { addEventListener() {} }, window: {}, AbortController }; vm.createContext(context); - vm.runInContext(source.slice(source.indexOf('function openDashboardTokenUsage()')), 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.dashboardUsageMetrics({ totalTokens: 120, promptTokens: 100, completionTokens: 20, cachedTokens: 40, cacheWriteTokens: 30, reasoningTokens: 10, modelCalls: 1 }); + 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.dashboardUsageMetrics({})).usageCacheRate, '—'); - assert.equal(ctx.dashboardUsageCount(Infinity), 0); - assert.equal(ctx.dashboardUsageCount(-12), 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.refreshDashboard = () => { calls++; }; + ctx.refreshTokenUsage = () => { calls++; }; ctx.document.getElementById = () => ({ textContent: '' }); - ctx.changeDashboardTokenPeriod('30'); - ctx.changeDashboardTokenPeriod('999'); - assert.equal(ctx.dashboardState.tokenDays, 30); + 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('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']) assert.ok(data.dashboard[key]); + for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); + assert.ok(data.settings.nav.tokenUsage); + assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } diff --git a/web/static/js/token-usage.js b/web/static/js/token-usage.js new file mode 100644 index 00000000..34a2da73 --- /dev/null +++ b/web/static/js/token-usage.js @@ -0,0 +1,197 @@ +// 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 }; + +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() : ''; + 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 renderTokenUsageAnalysis(res) { + const host = document.getElementById('settings-token-analysis'); + if (!host) return; + host.replaceChildren(); + if (!res || !res.summary) { + host.textContent = tokenUsageText('dashboard.usageUnavailable', null, '用量统计暂不可用'); + return; + } + const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); + const label = key => tokenUsageText('dashboard.' + key, null, key); + const metrics = document.createElement('div'); + metrics.className = 'token-usage-metrics'; + tokenUsageMetrics(res.summary).forEach(([key, value]) => { + const card = document.createElement('div'); + const title = document.createElement('span'); + title.textContent = label(key); + const count = document.createElement('strong'); + count.textContent = typeof value === 'string' ? value : exact(value); + card.append(title, count); + metrics.append(card); + }); + host.append(metrics); + if (!tokenUsageCount(res.summary.events)) { + const empty = document.createElement('p'); + empty.textContent = tokenUsageText('dashboard.noTokenUsageYet', null, '暂无用量'); + host.append(empty); + return; + } + // Tables use text nodes: provider model names and conversation IDs are untrusted. + const table = (titleKey, headings, rows) => { + const section = document.createElement('section'); + const title = document.createElement('h4'); + title.textContent = label(titleKey); + const wrap = document.createElement('div'); + wrap.className = 'token-usage-table-wrap'; + const grid = document.createElement('table'); + const caption = document.createElement('caption'); + caption.className = 'sr-only'; + caption.textContent = title.textContent; + const head = document.createElement('thead'); + const hr = document.createElement('tr'); + headings.forEach(key => { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = label(key); + hr.append(th); + }); + head.append(hr); + const body = document.createElement('tbody'); + rows.forEach(values => { + const tr = document.createElement('tr'); + values.forEach(value => { + const td = document.createElement('td'); + td.textContent = String(value == null ? '' : value); + tr.append(td); + }); + body.append(tr); + }); + grid.append(caption, head, body); + wrap.append(grid); + section.append(title, wrap); + host.append(section); + }; + const counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), + exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; + const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; + table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, + tokenUsageCount(res.summary.totalTokens) > 0 ? (tokenUsageCount(row.totalTokens) / tokenUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); + const trendTitle = document.createElement('h4'); + trendTitle.textContent = label('usageTrend'); + const trend = document.createElement('div'); + trend.className = 'token-usage-trend'; + const days = (res.byDay || []).slice().reverse(); + const peak = Math.max(1, ...days.map(row => tokenUsageCount(row.totalTokens))); + days.forEach(row => { + const bar = document.createElement('div'); + bar.className = 'token-usage-day'; + const count = document.createElement('span'); + count.textContent = formatTokenUsageCompact(row.totalTokens); + const fill = document.createElement('div'); + fill.className = 'token-usage-day-bar'; + fill.style.height = Math.max(1, tokenUsageCount(row.totalTokens) / peak * 90) + 'px'; + const date = document.createElement('span'); + date.textContent = String(row.key || '').slice(5); + bar.title = row.key + ': ' + exact(row.totalTokens); + bar.append(count, fill, date); + trend.append(bar); + }); + host.append(trendTitle, trend); + table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('usageRecent', ['usageTime', 'usageModel', 'usageStatus', 'usageTotal', 'usageCalls'], (res.recent || []).map(row => { + const time = new Date(row.createdAt); + return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', + row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + })); +} diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index a3bf65b4..1e42d24b 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -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'); diff --git a/web/templates/index.html b/web/templates/index.html index 3ced8a4a..ad0c85d9 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,14 +39,14 @@ } })(); - + - +
-
-
+
- + -
-
- 查看用量详情 -
- - -
-

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

-
-
@@ -3615,6 +3603,9 @@
基本设置
+
+ Token 用量 +
人机协同
@@ -3650,6 +3641,23 @@
+
+
+

Token 用量

+ +
+
+ + +
+

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
+
+
@@ -7009,14 +7017,15 @@ - + - + + From 5d14daf486e277dfcb2075864cd56b73a1837f8e Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:58:41 +0800 Subject: [PATCH 3/6] style: compact dashboard token usage subtitle --- docs/zh-CN/token-usage-monitoring.md | 2 +- web/static/css/style.css | 2 -- web/static/i18n/en-US.json | 2 +- web/static/i18n/ru-RU.json | 2 +- web/static/i18n/zh-CN.json | 2 +- web/static/js/dashboard.js | 10 ++-------- web/static/js/token-usage-analysis.test.cjs | 3 ++- web/templates/index.html | 7 +++---- 8 files changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index 98247821..555bd7b8 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天调用次数、今日用量与缓存命中率(缓存读取 Token / 输入 Token)。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅用一行显示今日用量与缓存命中率(缓存读取 Token / 输入 Token),调用次数保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 7b1576ac..3258149f 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47221,8 +47221,6 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } } /* Provider-reported token analysis, scoped by the dashboard project selector. */ -.dashboard-kpi-sub--tokens { row-gap: 2px; } -#dashboard-kpi-token-cache-text { flex-basis: 100%; } .token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } .token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } .token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 9f3f913c..4a3ad926 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -288,7 +288,7 @@ "successRate": "Tool success rate", "tokenUsage": "Token usage", "usageCacheHitRate": "Cache hit {{rate}}", - "tokenUsageSub": "Last {{days}} days: {{calls}} calls · Today {{today}}", + "tokenUsageSub": "Today {{today}} · Cache hit {{rate}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 667c550e..d060b292 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -288,7 +288,7 @@ "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", "usageCacheHitRate": "Попадания в кэш {{rate}}", - "tokenUsageSub": "За {{days}} дней {{calls}} вызовов · сегодня {{today}}", + "tokenUsageSub": "Сегодня {{today}} · Кэш {{rate}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", "clickToViewChat": "Открыть диалоги", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index a758dbd3..77ed40c6 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -288,7 +288,7 @@ "successRate": "工具执行成功率", "tokenUsage": "Token 用量", "usageCacheHitRate": "缓存命中 {{rate}}", - "tokenUsageSub": "近 {{days}} 天 {{calls}} 次调用 · 今日 {{today}}", + "tokenUsageSub": "今日 {{today}} · 缓存命中 {{rate}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index eec890d3..5ad07c76 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -72,7 +72,6 @@ async function refreshDashboard() { setKpiSubText('dashboard-kpi-tools-sub-text', '…'); setKpiSubText('dashboard-kpi-rate-sub-text', '…'); setKpiSubText('dashboard-kpi-token-sub-text', '…'); - setKpiSubText('dashboard-kpi-token-cache-text', '…'); hideEl('dashboard-kpi-vuln-critical-badge'); hideEl('dashboard-alert-banner'); setRecentVulnsLoading(); @@ -443,7 +442,6 @@ async function refreshDashboard() { setEl('dashboard-kpi-tools-calls', '-'); setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); - setKpiSubText('dashboard-kpi-token-cache-text', '-'); setKpiSubText('dashboard-kpi-tasks-sub-text', '-'); setKpiSubText('dashboard-kpi-vuln-sub-text', '-'); setKpiSubText('dashboard-kpi-tools-sub-text', '-'); @@ -715,13 +713,10 @@ function renderDashboardTokenUsage(res) { if (!summary) { setEl('dashboard-kpi-token-usage', '-'); setKpiSubText('dashboard-kpi-token-sub-text', '-'); - setKpiSubText('dashboard-kpi-token-cache-text', '-'); return; } const cacheRate = tokenUsageCacheRate(summary); - setKpiSubText('dashboard-kpi-token-cache-text', dt('dashboard.usageCacheHitRate', { rate: cacheRate }, '缓存命中 ' + cacheRate)); 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'); @@ -731,10 +726,9 @@ function renderDashboardTokenUsage(res) { setEl('dashboard-kpi-token-usage', formatTokenUsageCompact(total)); setKpiSubText('dashboard-kpi-token-sub-text', dt('dashboard.tokenUsageSub', { - days: 7, today: formatTokenUsageCompact(today), - calls: Number.isFinite(calls) ? calls : 0 - }, '近 7 天 ' + (Number.isFinite(calls) ? calls : 0) + ' 次调用 · 今日 ' + formatTokenUsageCompact(today))); + rate: cacheRate + }, '今日 ' + formatTokenUsageCompact(today) + ' · 缓存命中 ' + cacheRate)); } function formatTokenUsageCompact(num) { diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index de24409d..9a39a482 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -75,7 +75,8 @@ test('all supported languages expose usage labels and interpolation placeholders for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); assert.ok(data.settings.nav.tokenUsage); assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); - assert.match(data.dashboard.tokenUsageSub, /\{\{days\}\}/); + assert.match(data.dashboard.tokenUsageSub, /\{\{today\}\}/); + assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } }); diff --git a/web/templates/index.html b/web/templates/index.html index ad0c85d9..949bc0f6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,7 +39,7 @@ } })(); - + @@ -562,9 +562,8 @@
-
-
+
- - -
@@ -7017,7 +7016,7 @@ - + From 4badef67c12cc91b3285a72401e54aadafd8eef8 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:00:26 +0800 Subject: [PATCH 4/6] style: show only cache hit rate below dashboard token total --- docs/zh-CN/token-usage-monitoring.md | 2 +- web/static/i18n/en-US.json | 2 +- web/static/i18n/ru-RU.json | 2 +- web/static/i18n/zh-CN.json | 2 +- web/static/js/dashboard.js | 4 +--- web/static/js/token-usage-analysis.test.cjs | 1 - web/templates/index.html | 2 +- 7 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index 555bd7b8..80458a69 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -29,7 +29,7 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 ## 功能入口与数据流 -点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅用一行显示今日用量与缓存命中率(缓存读取 Token / 输入 Token),调用次数保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 +点击首页「Token 用量」卡片进入「系统设置 → Token 用量」,也可直接从设置二级菜单打开。首页卡片显示近 7 天总用量,副标仅显示缓存命中率(缓存读取 Token / 输入 Token),调用次数和每日用量保留在详情页。支持当前项目范围、7/30/90 天切换、八项指标、最多 20 项运行模型排行、每日趋势与完整日明细、最多 20 项最近运行。详情页独立加载,支持手动刷新与请求取消,切换周期不会刷新整个仪表盘;刷新页面可保持该设置分区。 运行事件 → 流式/非流式用量累加 → `eino_usage_summary` → `process_details` 与 `model_token_usage` → 现有 `/api/usage/tokens`、对话用量接口 → 首页与聊天摘要。 diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 4a3ad926..932ccf16 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -288,7 +288,7 @@ "successRate": "Tool success rate", "tokenUsage": "Token usage", "usageCacheHitRate": "Cache hit {{rate}}", - "tokenUsageSub": "Today {{today}} · Cache hit {{rate}}", + "tokenUsageSub": "Cache hit {{rate}}", "noTokenUsageYet": "No usage yet", "clickToViewTasks": "Click to view tasks", "clickToViewChat": "Click to view conversations", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index d060b292..9e833896 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -288,7 +288,7 @@ "successRate": "Успешность инструментов", "tokenUsage": "Расход токенов", "usageCacheHitRate": "Попадания в кэш {{rate}}", - "tokenUsageSub": "Сегодня {{today}} · Кэш {{rate}}", + "tokenUsageSub": "Кэш {{rate}}", "noTokenUsageYet": "Расхода пока нет", "clickToViewTasks": "Открыть задачи", "clickToViewChat": "Открыть диалоги", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 77ed40c6..e53ee222 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -288,7 +288,7 @@ "successRate": "工具执行成功率", "tokenUsage": "Token 用量", "usageCacheHitRate": "缓存命中 {{rate}}", - "tokenUsageSub": "今日 {{today}} · 缓存命中 {{rate}}", + "tokenUsageSub": "缓存命中 {{rate}}", "noTokenUsageYet": "暂无用量", "clickToViewTasks": "点击查看任务管理", "clickToViewChat": "点击查看对话", diff --git a/web/static/js/dashboard.js b/web/static/js/dashboard.js index 5ad07c76..50f8d207 100644 --- a/web/static/js/dashboard.js +++ b/web/static/js/dashboard.js @@ -717,7 +717,6 @@ function renderDashboardTokenUsage(res) { } const cacheRate = tokenUsageCacheRate(summary); const total = Number(summary.totalTokens || 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, '暂无用量')); @@ -726,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), rate: cacheRate - }, '今日 ' + formatTokenUsageCompact(today) + ' · 缓存命中 ' + cacheRate)); + }, '缓存命中 ' + cacheRate)); } function formatTokenUsageCompact(num) { diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index 9a39a482..86b98ddb 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -75,7 +75,6 @@ test('all supported languages expose usage labels and interpolation placeholders for (const key of ['usageView', 'usageScope', 'usageCacheRead', 'usageCacheWrite', 'usageTrend', 'usageCacheHitRate']) assert.ok(data.dashboard[key]); assert.ok(data.settings.nav.tokenUsage); assert.match(data.dashboard.usageCacheHitRate, /\{\{rate\}\}/); - assert.match(data.dashboard.tokenUsageSub, /\{\{today\}\}/); assert.match(data.dashboard.tokenUsageSub, /\{\{rate\}\}/); assert.match(data.chat.turnTokenUsageTitle, /\{\{cacheWrite\}\}/); } diff --git a/web/templates/index.html b/web/templates/index.html index 949bc0f6..147d3cee 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -7016,7 +7016,7 @@ - + From 56d29b7054dc2f6ff36cedc91723ce3ce458a438 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:10:37 +0800 Subject: [PATCH 5/6] feat: refine token analytics with interactive trends and cache breakdown --- docs/zh-CN/token-usage-monitoring.md | 14 ++ web/static/css/style.css | 92 ++++++-- web/static/i18n/en-US.json | 13 ++ web/static/i18n/ru-RU.json | 13 ++ web/static/i18n/zh-CN.json | 13 ++ web/static/js/token-usage-analysis.test.cjs | 12 + web/static/js/token-usage.js | 238 ++++++++++++++------ web/templates/index.html | 11 +- 8 files changed, 310 insertions(+), 96 deletions(-) diff --git a/docs/zh-CN/token-usage-monitoring.md b/docs/zh-CN/token-usage-monitoring.md index 80458a69..9a60fd48 100644 --- a/docs/zh-CN/token-usage-monitoring.md +++ b/docs/zh-CN/token-usage-monitoring.md @@ -31,6 +31,20 @@ DeepSeek 的未命中输入不等于 Claude 的缓存写入。不能根据文本 点击首页「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 幂等写入、外键删除关联和历史回填继续生效。日分组按服务本地时区显示,与首页「今日」边界一致。 diff --git a/web/static/css/style.css b/web/static/css/style.css index 3258149f..a316b5d1 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -47220,25 +47220,79 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; } align-self: flex-start; } -/* Provider-reported token analysis, scoped by the dashboard project selector. */ -.token-usage-panel { margin-bottom: 20px; padding: 18px 22px; } -.token-usage-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.token-usage-toolbar { display: flex; align-items: center; gap: 12px; margin-top: 18px; } -.token-usage-toolbar label { white-space: nowrap; } -.token-usage-toolbar select { padding: 6px 10px; border: 1px solid var(--border-color, #dce2ea); border-radius: 6px; background: var(--bg-secondary, #fff); color: inherit; } -.token-usage-note { opacity: .72; font-size: 12px; line-height: 1.7; margin: 14px 0; } -.token-usage-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } -.token-usage-metrics > div { padding: 12px; border: 1px solid var(--border-color, #dce2ea); border-radius: 8px; min-width: 0; } -.token-usage-metrics span { display: block; font-size: 12px; opacity: .7; } -.token-usage-metrics strong { display: block; margin-top: 6px; font-size: 20px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } +/* 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: 10px; text-align: right; border-bottom: 1px solid var(--border-color, #dce2ea); white-space: nowrap; } +.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: 280px; overflow: hidden; text-overflow: ellipsis; } -@media (max-width: 640px) { .token-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .token-usage-panel { padding: 14px; } } -.token-usage-trend { display: flex; align-items: flex-end; gap: 8px; overflow-x: auto; padding: 12px 0; } -.token-usage-day { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; flex: 1 0 38px; height: 128px; gap: 6px; font-size: 11px; } -.token-usage-day-bar { width: 70%; max-width: 48px; background: var(--accent-color, #3b82f6); border-radius: 4px 4px 0 0; } - -.token-usage-panel h4 { margin: 20px 0 8px; } +.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; } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 932ccf16..e6817386 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 9e833896..23463753 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -277,6 +277,19 @@ "capabilities": "Возможности", "administration": "Администрирование" }, + "tokenUsageUI": { + "chartHint": "Наведите или выберите столбец для точных значений", + "scopeProject": "Текущий проект", + "scopeAccessible": "Доступные диалоги", + "subtitle": "Расход моделей, использование кэша и динамика", + "scopeNote": "Область и правила подсчёта", + "trendMetric": "Показатель графика", + "noDailyData": "Нет ежедневных данных", + "cacheComposition": "Кэш входных токенов", + "cacheHit": "Доля попаданий в кэш", + "uncachedInput": "Вход без чтения кэша", + "cacheNote": "Чтение кэша входит во входные токены и не прибавляется к итогу. Запись кэша указана отдельно." + }, "dashboard": { "title": "Обзор", "refresh": "Обновить", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index e53ee222..b1b6d7f4 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -277,6 +277,19 @@ "capabilities": "能力中心", "administration": "平台管理" }, + "tokenUsageUI": { + "chartHint": "悬停或聚焦柱形查看精确用量", + "scopeProject": "当前项目", + "scopeAccessible": "可访问会话", + "subtitle": "查看模型消耗、缓存利用与使用趋势", + "scopeNote": "统计口径与说明", + "trendMetric": "趋势指标", + "noDailyData": "暂无每日用量记录", + "cacheComposition": "输入缓存构成", + "cacheHit": "缓存命中率", + "uncachedInput": "未命中缓存的输入", + "cacheNote": "缓存读取是输入的一部分,不重复计入总量。缓存写入单独列示。" + }, "dashboard": { "title": "仪表盘", "refresh": "刷新", diff --git a/web/static/js/token-usage-analysis.test.cjs b/web/static/js/token-usage-analysis.test.cjs index 86b98ddb..9c7e5f8d 100644 --- a/web/static/js/token-usage-analysis.test.cjs +++ b/web/static/js/token-usage-analysis.test.cjs @@ -42,6 +42,17 @@ test('cache hit ratio uses input tokens, clamps invalid detail and handles missi 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: '' }; @@ -74,6 +85,7 @@ test('all supported languages expose usage labels and interpolation placeholders 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\}\}/); diff --git a/web/static/js/token-usage.js b/web/static/js/token-usage.js index 34a2da73..ae55fcd9 100644 --- a/web/static/js/token-usage.js +++ b/web/static/js/token-usage.js @@ -1,6 +1,6 @@ // 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 }; +var tokenUsageState = { days: 7, controller: null, requestSequence: 0, result: null, trendMetric: 'totalTokens' }; function tokenUsageText(key, opts, fallback) { if (typeof window.t === 'function') { @@ -38,6 +38,8 @@ async function refreshTokenUsage() { 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 }); @@ -99,60 +101,167 @@ document.addEventListener('languagechange', function () { } }); +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(); - if (!res || !res.summary) { - host.textContent = tokenUsageText('dashboard.usageUnavailable', null, '用量统计暂不可用'); - return; - } - const exact = value => tokenUsageCount(value).toLocaleString(window.__locale || 'zh-CN'); const label = key => tokenUsageText('dashboard.' + key, null, key); - const metrics = document.createElement('div'); - metrics.className = 'token-usage-metrics'; - tokenUsageMetrics(res.summary).forEach(([key, value]) => { - const card = document.createElement('div'); - const title = document.createElement('span'); - title.textContent = label(key); - const count = document.createElement('strong'); - count.textContent = typeof value === 'string' ? value : exact(value); - card.append(title, count); - metrics.append(card); - }); - host.append(metrics); - if (!tokenUsageCount(res.summary.events)) { - const empty = document.createElement('p'); - empty.textContent = tokenUsageText('dashboard.noTokenUsageYet', null, '暂无用量'); + 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; } - // Tables use text nodes: provider model names and conversation IDs are untrusted. - const table = (titleKey, headings, rows) => { - const section = document.createElement('section'); - const title = document.createElement('h4'); - title.textContent = label(titleKey); - const wrap = document.createElement('div'); - wrap.className = 'token-usage-table-wrap'; - const grid = document.createElement('table'); - const caption = document.createElement('caption'); - caption.className = 'sr-only'; - caption.textContent = title.textContent; - const head = document.createElement('thead'); - const hr = document.createElement('tr'); - headings.forEach(key => { - const th = document.createElement('th'); - th.scope = 'col'; - th.textContent = label(key); - hr.append(th); - }); + 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 = document.createElement('tbody'); + const body = tokenUsageNode('tbody'); rows.forEach(values => { - const tr = document.createElement('tr'); + const tr = tokenUsageNode('tr'); values.forEach(value => { - const td = document.createElement('td'); - td.textContent = String(value == null ? '' : 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); @@ -162,36 +271,21 @@ function renderTokenUsageAnalysis(res) { 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 counts = row => [exact(row.promptTokens), exact(row.completionTokens), exact(row.cachedTokens), exact(row.cacheWriteTokens), exact(row.reasoningTokens), exact(row.totalTokens), exact(row.modelCalls)]; const fields = ['usageInput', 'usageOutput', 'usageCacheRead', 'usageCacheWrite', 'usageReasoning', 'usageTotal', 'usageCalls']; - table('usageByModel', ['usageModel', 'usageShare', ...fields], (res.byModel || []).map(row => [row.label || row.key, - tokenUsageCount(res.summary.totalTokens) > 0 ? (tokenUsageCount(row.totalTokens) / tokenUsageCount(res.summary.totalTokens) * 100).toFixed(1) + '%' : '—', ...counts(row)])); - const trendTitle = document.createElement('h4'); - trendTitle.textContent = label('usageTrend'); - const trend = document.createElement('div'); - trend.className = 'token-usage-trend'; - const days = (res.byDay || []).slice().reverse(); - const peak = Math.max(1, ...days.map(row => tokenUsageCount(row.totalTokens))); - days.forEach(row => { - const bar = document.createElement('div'); - bar.className = 'token-usage-day'; - const count = document.createElement('span'); - count.textContent = formatTokenUsageCompact(row.totalTokens); - const fill = document.createElement('div'); - fill.className = 'token-usage-day-bar'; - fill.style.height = Math.max(1, tokenUsageCount(row.totalTokens) / peak * 90) + 'px'; - const date = document.createElement('span'); - date.textContent = String(row.key || '').slice(5); - bar.title = row.key + ': ' + exact(row.totalTokens); - bar.append(count, fill, date); - trend.append(bar); - }); - host.append(trendTitle, trend); - table('usageByDay', ['usageDate', ...fields], (res.byDay || []).map(row => [row.key, ...counts(row)])); + table('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); - return [Number.isFinite(time.getTime()) ? time.toLocaleString(window.__locale || 'zh-CN') : '—', - row.model || '—', row.reason === 'final' ? label('usageFinal') : row.reason === 'partial' ? label('usagePartial') : (row.reason || '—'), exact(row.totalTokens), exact(row.modelCalls)]; + 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)]; })); } diff --git a/web/templates/index.html b/web/templates/index.html index 147d3cee..8698ffa3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -39,7 +39,7 @@ } })(); - + @@ -3642,8 +3642,8 @@
-

Token 用量

- +

Token 用量

查看模型消耗、缓存利用与使用趋势

+
@@ -3652,8 +3652,9 @@ +
-

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

+
统计口径与说明

统计已记录的 Agent 运行中模型返回的用量,包括子 Agent。缓存读取和写入属于输入,推理属于输出,不重复加入总量。未返回或未采集的用量不在统计内;这不是供应商账单。模型按运行配置归类,切换模型的运行可能混合用量。

@@ -7023,7 +7024,7 @@ - + From 4f0a3049cb071fd2178049f509d13c8b661b03a9 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:23:43 +0800 Subject: [PATCH 6/6] fix: recover summary limits and sanitize agentic tool history --- ...ool_call_arguments_sanitizer_middleware.go | 75 ++++++ ...all_arguments_sanitizer_middleware_test.go | 214 ++++++++++++++++++ ...eino_agentic_chat_model_tail_middleware.go | 7 +- ...agentic_chat_model_tail_middleware_test.go | 6 +- .../multiagent/eino_summarize_model_guard.go | 65 +++++- .../eino_summarize_model_guard_test.go | 18 +- .../multiagent/eino_summarize_payload_test.go | 117 ++++++++++ 7 files changed, 488 insertions(+), 14 deletions(-) create mode 100644 internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware.go create mode 100644 internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware_test.go diff --git a/internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware.go b/internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware.go new file mode 100644 index 00000000..ad204bf3 --- /dev/null +++ b/internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware.go @@ -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 +} diff --git a/internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware_test.go b/internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware_test.go new file mode 100644 index 00000000..e1878e02 --- /dev/null +++ b/internal/multiagent/agentic_tool_call_arguments_sanitizer_middleware_test.go @@ -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") + } +} diff --git a/internal/multiagent/eino_agentic_chat_model_tail_middleware.go b/internal/multiagent/eino_agentic_chat_model_tail_middleware.go index 738a9b27..7ee7f3eb 100644 --- a/internal/multiagent/eino_agentic_chat_model_tail_middleware.go +++ b/internal/multiagent/eino_agentic_chat_model_tail_middleware.go @@ -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)) diff --git a/internal/multiagent/eino_agentic_chat_model_tail_middleware_test.go b/internal/multiagent/eino_agentic_chat_model_tail_middleware_test.go index 9b6d9d5c..22e135aa 100644 --- a/internal/multiagent/eino_agentic_chat_model_tail_middleware_test.go +++ b/internal/multiagent/eino_agentic_chat_model_tail_middleware_test.go @@ -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)) } } diff --git a/internal/multiagent/eino_summarize_model_guard.go b/internal/multiagent/eino_summarize_model_guard.go index c0b9757d..d735acf3 100644 --- a/internal/multiagent/eino_summarize_model_guard.go +++ b/internal/multiagent/eino_summarize_model_guard.go @@ -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 block and output only .... 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 { diff --git a/internal/multiagent/eino_summarize_model_guard_test.go b/internal/multiagent/eino_summarize_model_guard_test.go index 8fe2da2f..7cb562b3 100644 --- a/internal/multiagent/eino_summarize_model_guard_test.go +++ b/internal/multiagent/eino_summarize_model_guard_test.go @@ -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) } diff --git a/internal/multiagent/eino_summarize_payload_test.go b/internal/multiagent/eino_summarize_payload_test.go index 186b7793..21e7e17c 100644 --- a/internal/multiagent/eino_summarize_payload_test.go +++ b/internal/multiagent/eino_summarize_payload_test.go @@ -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") + } + } + } + }) + } + } +}