Skip to content

Commit abc4136

Browse files
authored
fix(dashboard): 修正 Sessions 页 total_tokens 漏算缓存 token 的聚合口径; (#240)
- src/coding/proxy/logging/db.py: query_recent_sessions / query_session_profile 两条按 session_key 分组的聚合 SQL 中, 将 total_tokens 由 SUM(input + output) 修正为 SUM(input + output + cache_creation + cache_read), 与同文件 query_total_tokens_by_vendor / query_usage 已使用的全口径对齐, 消除 Sessions 页与 Overview 页之间的统计口径分裂; - tests/test_session_aware.py: test_query_recent_sessions_basic / test_query_session_profile_found 中追加 cache_creation_tokens 与 cache_read_tokens 入参及完整口径断言, 锁定回归; - docs/issue.md: 追加 Issue 档案, 记录 SQL 聚合层 SSOT 缺失导致的口径漂移, 沉淀向 usage_log 增列后必须审计所有 SUM 表达式的防范经验. 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist)
1 parent 2574d39 commit abc4136

3 files changed

Lines changed: 62 additions & 5 deletions

File tree

docs/issue.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,47 @@ litellm 按 Google AI Studio 格式构造请求:
186186

187187
- litellm 在 Gemini 其他端点(`generateContent` / `countTokens`)同样存在 `_check_custom_proxy` 丢失 `v1beta/` 前缀的 bug;本次仅放宽了 `operation.py` 中的路径正则(让分类器能识别此类异常路径),未对这些端点做格式转换,因为非 embedding 端点的 Google AI Studio / Vertex AI 请求体差异较小,多数上游兼容。如未来出现类似失配再做针对性适配。
188188
- 若上游网关同时支持 OpenAI `/v1/embeddings` 与 Vertex AI 路径,建议优先在客户端配置 OpenAI 兼容路径,减少协议转换链路。
189+
190+
---
191+
192+
## Dashboard Sessions 页 `Tokens` 列漏算缓存 Token
193+
194+
**问题描述**
195+
196+
Dashboard 的 **Sessions** 标签页中,每条会话的 `Tokens` 列与展开详情卡的 `Tokens` 值,仅统计 `input + output`,遗漏了 `cache_creation`(写缓存)与 `cache_read`(读缓存)。在长链路 Anthropic Prompt Cache 场景下,读取命中常常是 input/output 的数倍,导致 Sessions 页总量被显著低估,与 Overview 标签页(卡片、Token 时序图)跨页口径分裂。
197+
198+
**表因**
199+
200+
前端 `dashboard.py:1597 / 1614` 直接渲染 `s.total_tokens`,该值由 `/api/dashboard/sessions` 透传自 `token_logger.query_recent_sessions()` 的聚合结果。
201+
202+
**根因**
203+
204+
`src/coding/proxy/logging/db.py` 中两条按 `session_key` 分组的聚合 SQL 使用了不完整的求和口径:
205+
206+
```sql
207+
SUM(input_tokens + output_tokens) AS total_tokens -- 第 607 行(query_recent_sessions)
208+
SUM(input_tokens + output_tokens) AS total_tokens -- 第 634 行(query_session_profile)
209+
```
210+
211+
而同文件内 `query_usage()`(第 465–466 行分别 `SUM(...)` 四列)与 `query_total_tokens_by_vendor()`(第 584 行 `SUM(input + output + cache_creation + cache_read)`)已采用完整四项口径,构成了同文件内的口径双标。
212+
213+
**处理方式**
214+
215+
复用 `query_total_tokens_by_vendor` 的四项求和表达式,将两处 `total_tokens` 改写为:
216+
217+
```sql
218+
SUM(input_tokens + output_tokens
219+
+ cache_creation_tokens + cache_read_tokens) AS total_tokens
220+
```
221+
222+
不改动 API 返回结构、不新增字段、不改前端 detail-card——前端 `fmtTokens(s.total_tokens)` 调用无须变更。同时在 `tests/test_session_aware.py``test_query_recent_sessions_basic` / `test_query_session_profile_found` 中追加 `cache_creation_tokens` / `cache_read_tokens` 入参与完整口径断言,覆盖回归。
223+
224+
**后续防范**
225+
226+
- SQL 聚合层涉及"总 Tokens"概念时,必须保持**单一权威定义**(Single Source of Truth):要么所有视图共用同一求和表达式,要么抽取为常量片段集中引用,杜绝多处独立维护造成的语义漂移。
227+
- 未来若引入新的 token 维度(如 reasoning_tokens、tool_tokens 等),需要全文检索 `SUM(input_tokens + output_tokens` 这一历史模式并同步补齐,避免出现新的口径分裂点。
228+
229+
**同类问题影响与处理注意事项**
230+
231+
- 历次 PR 中 cache token 字段的引入是渐进式的(schema 已有四列、`log()` 入参齐全、Overview 已全口径消费),但部分聚合视图的口径升级被遗漏;任何向 `usage_log` 增列后,**必须**审计所有 `SUM(input_tokens` / `SUM(output_tokens` 出现处的聚合表达式是否需要同步更新。
232+
- 跨标签页同一指标(如"总 Tokens")的口径一致性,建议在添加新视图时主动与 Overview 现有口径做交叉核对,必要时在 SQL 注释中标注口径来源,便于后续 review。

src/coding/proxy/logging/db.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,8 @@ async def query_recent_sessions(
604604
MIN(ts) AS first_seen_ts,
605605
MAX(ts) AS last_active_ts,
606606
COUNT(*) AS total_requests,
607-
SUM(input_tokens + output_tokens) AS total_tokens,
607+
SUM(input_tokens + output_tokens
608+
+ cache_creation_tokens + cache_read_tokens) AS total_tokens,
608609
SUM(input_tokens) AS total_input,
609610
SUM(output_tokens) AS total_output,
610611
GROUP_CONCAT(DISTINCT model_served) AS models,
@@ -631,7 +632,8 @@ async def query_session_profile(self, session_key: str) -> dict | None:
631632
MIN(ts) AS first_seen_ts,
632633
MAX(ts) AS last_active_ts,
633634
COUNT(*) AS total_requests,
634-
SUM(input_tokens + output_tokens) AS total_tokens,
635+
SUM(input_tokens + output_tokens
636+
+ cache_creation_tokens + cache_read_tokens) AS total_tokens,
635637
SUM(input_tokens) AS total_input,
636638
SUM(output_tokens) AS total_output,
637639
GROUP_CONCAT(DISTINCT model_served) AS models,

tests/test_session_aware.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ async def test_query_recent_sessions_basic(logger):
160160
model_served="claude-sonnet",
161161
input_tokens=100 * (i + 1),
162162
output_tokens=50 * (i + 1),
163+
cache_creation_tokens=10 * (i + 1),
164+
cache_read_tokens=1000 * (i + 1),
163165
session_key="session-alpha",
164166
duration_ms=100 + i * 50,
165167
)
@@ -186,9 +188,15 @@ async def test_query_recent_sessions_basic(logger):
186188

187189
alpha = next(s for s in sessions if s["session_key"] == "session-alpha")
188190
assert alpha["total_requests"] == 3
189-
assert alpha["total_tokens"] == (100 + 200 + 300) + (50 + 100 + 150)
190-
assert alpha["total_input"] == 100 + 200 + 300
191-
assert alpha["total_output"] == 50 + 100 + 150
191+
expected_input = 100 + 200 + 300
192+
expected_output = 50 + 100 + 150
193+
expected_cache_creation = 10 + 20 + 30
194+
expected_cache_read = 1000 + 2000 + 3000
195+
assert alpha["total_tokens"] == (
196+
expected_input + expected_output + expected_cache_creation + expected_cache_read
197+
)
198+
assert alpha["total_input"] == expected_input
199+
assert alpha["total_output"] == expected_output
192200
assert "claude-sonnet" in alpha["models"]
193201
assert "anthropic" in alpha["vendors"]
194202
assert alpha["success_rate"] == 100.0
@@ -269,12 +277,15 @@ async def test_query_session_profile_found(logger):
269277
model_served="m",
270278
input_tokens=100,
271279
output_tokens=50,
280+
cache_creation_tokens=20,
281+
cache_read_tokens=400,
272282
session_key="profile-test",
273283
)
274284
profile = await logger.query_session_profile("profile-test")
275285
assert profile is not None
276286
assert profile["session_key"] == "profile-test"
277287
assert profile["total_requests"] == 1
288+
assert profile["total_tokens"] == 100 + 50 + 20 + 400
278289

279290

280291
@pytest.mark.asyncio

0 commit comments

Comments
 (0)