Summary
session_usage accumulates phantom output_tokens records with an empty model field. When a run falls back to the estimation path (provider did not return real usage) and the session has no compression snapshot, the estimator sums the token count of the entire session's assistant/tool history and writes that total as a single row's output_tokens. This produces records where a tiny input maps to a multi-million-token output, with model, provider, source, agent all empty and api_calls = 0.
This does not affect real billing or real cache behaviour, but it heavily pollutes the Analytics / Usage dashboard: total output tokens and the effective cache-hit ratio are both skewed, because these rows carry huge output_tokens with cache_read_tokens = 0.
Environment
- hermes-web-ui: v0.6.30 (also reproduced across local builds back to early July; the accounting function is byte-identical in every version)
- DB:
hermes-web-ui.db, table session_usage
Observed data
Aggregate over all rows where model = '' (and source/agent/provider empty, api_calls = 0):
| rows |
input_tokens |
output_tokens |
cache_read |
api_calls |
| 3626 |
11,258,704 |
318,712,269 |
0 |
0 |
Representative individual rows (session ids redacted):
| input_tokens |
output_tokens |
api_calls |
cache_read |
model |
| 14 |
2,755,307 |
0 |
0 |
(empty) |
| 44 |
1,307,782 |
0 |
0 |
(empty) |
| 34 |
920,135 |
0 |
0 |
(empty) |
Same session, consecutive rows monotonically increasing (each turn re-sums the whole history):
| input_tokens |
output_tokens |
| 430 |
855,126 |
| 445 |
855,513 |
| 465 |
866,477 |
| 475 |
866,799 |
| 491 |
867,227 |
| 509 |
877,080 |
A 14-token input producing 2.75M output with api_calls = 0 is physically impossible for a real call — it is the full session history summed into one row.
Root cause
packages/server/src/services/hermes/run-chat/usage.ts
estimateUsageTokensFromMessages() sums all assistant/tool messages in the session:
const outputTokens = messages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0)
calcAndUpdateUsage() only slices to new messages when a compression snapshot exists:
const estimateSnapshotUsage = (messages) => {
if (snapshot && messages.length && snapshot.lastMessageIndex >= 0 && snapshot.lastMessageIndex < messages.length) {
const newMessages = messages.slice(snapshot.lastMessageIndex + 1) // correct: only new messages
const newUsage = estimateUsageTokensFromMessages(newMessages)
return { inputTokens: countTokens(SUMMARY_PREFIX + snapshot.summary) + newUsage.inputTokens, outputTokens: newUsage.outputTokens }
}
return estimateUsageTokensFromMessages(messages) // BUG: no snapshot -> whole-history sum
}
So a session without a compression snapshot that hits the estimation fallback re-sums its entire output history on every turn.
Additionally, db/hermes/usage-store.ts updateUsage() does INSERT OR IGNORE with no upper-bound / sanity check on output_tokens, so the inflated value lands unfiltered.
Suggested fix
- In the no-snapshot branch, also scope the output estimate to messages produced by the current turn/run, rather than the full session history (mirror what the snapshot branch already does).
- Optionally, guard at write time in
updateUsage(): reject or flag estimation rows where output_tokens grossly exceeds input_tokens and provider/model are empty.
Relation to prior issues
Summary
session_usageaccumulates phantomoutput_tokensrecords with an emptymodelfield. When a run falls back to the estimation path (provider did not return real usage) and the session has no compression snapshot, the estimator sums the token count of the entire session's assistant/tool history and writes that total as a single row'soutput_tokens. This produces records where a tiny input maps to a multi-million-token output, withmodel,provider,source,agentall empty andapi_calls = 0.This does not affect real billing or real cache behaviour, but it heavily pollutes the Analytics / Usage dashboard: total output tokens and the effective cache-hit ratio are both skewed, because these rows carry huge
output_tokenswithcache_read_tokens = 0.Environment
hermes-web-ui.db, tablesession_usageObserved data
Aggregate over all rows where
model = ''(andsource/agent/providerempty,api_calls = 0):Representative individual rows (session ids redacted):
Same session, consecutive rows monotonically increasing (each turn re-sums the whole history):
A 14-token input producing 2.75M output with
api_calls = 0is physically impossible for a real call — it is the full session history summed into one row.Root cause
packages/server/src/services/hermes/run-chat/usage.tsestimateUsageTokensFromMessages()sums all assistant/tool messages in the session:calcAndUpdateUsage()only slices to new messages when a compression snapshot exists:So a session without a compression snapshot that hits the estimation fallback re-sums its entire output history on every turn.
Additionally,
db/hermes/usage-store.tsupdateUsage()doesINSERT OR IGNOREwith no upper-bound / sanity check onoutput_tokens, so the inflated value lands unfiltered.Suggested fix
updateUsage(): reject or flag estimation rows whereoutput_tokensgrossly exceedsinput_tokensandprovider/modelare empty.Relation to prior issues
session_usage表缺少created_at列导致旧版数据库迁移失败 #1087 also touchessession_usageschema but is unrelated (missingcreated_atcolumn).