Token Accounting for Claude / Codex / ZCode / Qwen
This document explains how Open ACE collects token usage for the four local tools claude, codex, zcode, and qwen, how it computes daily and message-level metrics, how those metrics are stored, and which downstream services/pages consume which table.
Target readers:
- users who want to understand why Open ACE numbers can differ from provider dashboards
- maintainers who need to debug or change fetchers
- contributors adding a new local tool or adjusting usage semantics
1. End-to-end flow
local JSONL / SQLite
-> scripts/fetch_*.py
-> scripts/shared/db.py
-> daily_usage
-> daily_messages
-> agent_sessions
-> session_messages
-> daily_stats / hourly_stats
-> user_daily_stats
-> app/repositories/* / app/services/*
-> Work / Manage pages, quota, reporting, analytics
At a high level:
daily_usageis the day/tool/host aggregate fact tabledaily_messagesis the message-level analytics fact tableagent_sessions/session_messagespower workspace/session viewsdaily_stats,hourly_stats, anduser_daily_statsare derived aggregates
2. Shared semantics
2.1 Core fields
| Field | Meaning | Main tables |
|---|---|---|
tokens_used | Open ACE's total-token value for that record | daily_usage, daily_messages, agent_sessions, session_messages |
input_tokens | non-cached input tokens for that record | daily_usage, daily_messages, agent_sessions, session_messages |
output_tokens | output tokens | daily_usage, daily_messages, agent_sessions, session_messages |
cache_tokens | total cache tokens | only stored separately in daily_usage |
request_count | Open ACE request-count semantic | daily_usage, agent_sessions, user_daily_stats |
Important caveats:
daily_messagesdoes not have its owncache_tokenscolumn.- Cache is preserved separately only in
daily_usage.cache_tokens. - If a consumer already reads
tokens_used, it usually must not addcache_tokensagain.
2.2 Intended invariant
For these four tools, the current intent is to keep the stored totals as close as possible to:
tokens_used == non-cached input_tokens + output_tokens + cache_tokens
But provider-native semantics differ:
- Claude exposes cache separately, so Open ACE explicitly adds cache into
tokens_used - Codex provider
total_tokensalready includes cached input - Qwen provider
promptTokenCountincludes cache, whiletotalTokenCountis preserved as the provider total - ZCode uses
turn_usageas the source of truth
3. Tool-by-tool collection and computation
3.1 Claude
Source
- path:
~/.claude/projects/**.jsonl - script:
scripts/fetch_claude.py
How usage is extracted
- Claude local logs are JSONL
- usage is read from
entry["usage"]orentry["message"]["usage"] - key functions:
extract_tokens_from_entry()process_jsonl_file()_merge_messages_by_id()
Field mapping
input_tokenscomes frominput_tokensoutput_tokenscomes fromoutput_tokenscache_read_tokenscomes fromcache_read_input_tokenscache_creation_tokenscomes fromcache_creation_input_tokenstokens_usedis computed asinput + output + cache_read + cache_creation
Why message-id merge exists
- one logical Claude message can span multiple JSONL lines
- line-by-line inserts would duplicate token accounting and lose structured content
- Open ACE merges the logical message first, then writes
daily_messages/session_messages
request_count
- counted per logical assistant message
- deduplicated by stable message id when available
- a zero-token assistant message can still count as a request
3.2 Codex
Source
- path:
~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl - script:
scripts/fetch_codex.py
Key event types
task_startedturn_contextresponse_itemtoken_counttask_complete
How usage is reconstructed
- Codex does not store billing usage as one field per final message
- Open ACE rebuilds turns from the event stream
task_startedopens a turnresponse_itemcollects user / assistant messagestoken_countaccumulates turn usagetask_completecloses the turn
Why attribution is per turn, not per session
- a Codex session can span many hours or multiple days
- putting all tokens on the first assistant row distorts daily and hourly stats
- current logic reconstructs turns and attributes each turn to the initiating user message
Field mapping
tokens_usedcomes from accumulatedlast_token_usage.total_tokenscache_tokenscomes fromcached_input_tokensinput_tokensis computed asinput_tokens - cached_input_tokensoutput_tokenscomes fromoutput_tokensthoughts_tokensonly participates in daily aggregation and is not stored separately indaily_messages
Important semantic note
- for current local Codex logs,
token_count.last_token_usagebehaves as event-level increments and should be summed cached_input_tokensis already part of provider total, not an extra add-on
request_count
- counted per
task_started - not per assistant message row
Why re-import deletes old rows first
- parser fixes can move token attribution from assistant rows to user rows
delete_messages_for_agent_sessions()clears old rows bytool_name + host_name + agent_session_id- then the authoritative snapshot is re-written
3.3 ZCode
Source
- path:
~/.zcode/cli/db/db.sqlite - script:
scripts/fetch_zcode.py - source tables:
session,message,part,turn_usage
Why it differs from the other three
- ZCode stores source data relationally in SQLite, not JSONL
message/partare useful for transcript reconstructionturn_usageis the authoritative source for token accounting
How usage is computed
ZcodeSessionfromremote-agent/session_sync.pyis reused for transcript/project parsing- token attribution is then done by querying
turn_usage - key functions:
_get_turn_usage_rows()_get_turn_usage_by_date()process_zcode_session()
Field mapping
tokens_usedcomes fromcomputed_total_tokensinput_tokenscomes fromturn_usage.input_tokensoutput_tokenscomes fromturn_usage.output_tokenscache_tokensis computed ascache_creation_input_tokens + cache_read_input_tokens
Why date grouping uses turn_usage.started_at
- one session may span multiple local calendar days
- the authoritative billing timestamp for ZCode is the turn start
- so
daily_usageis split by turn date, not by session creation time or dominant message day
Message-level attribution
- each turn is attached to the user message referenced by
turn_usage.user_message_id - this keeps
daily_messages/hourly_statsaligned with real turn timing
What happens on mismatch
- partial match: warning is printed
daily_usageremains authoritativedaily_messages/agent_sessionsmay be incomplete for unmatched turns
- full mismatch: fallback warning is printed and the whole session total is injected into the first assistant message
3.4 Qwen
Source
- path:
~/.qwen/projects/**/chats/*.jsonl - some older layouts also use direct
*.jsonl - script:
scripts/fetch_qwen.py
How usage is extracted
- usage comes from
usageMetadata - key functions:
extract_tokens_from_entry()process_jsonl_file()
Field mapping
prompt_tokenscomes frompromptTokenCountcandidates_tokenscomes fromcandidatesTokenCountthoughts_tokenscomes fromthoughtsTokenCountcached_tokenscomes fromcachedContentTokenCounttokens_usedcomes fromtotalTokenCount
Important semantic note
promptTokenCountincludes cached context- Open ACE computes
actual_input_tokens = promptTokenCount - cachedContentTokenCount daily_messages.input_tokensstoresactual_input_tokenstokens_usedstill preserves providertotalTokenCount
Why thoughts are not added again
thoughtsTokenCountis treated as an extra observation dimension, not something that should automatically be added on top of provider total- otherwise Open ACE would inflate totals beyond provider semantics
request_count
- counted per assistant message
- deduplicated by message id when available
4. How data is stored
4.1 daily_usage
Role
- day/tool/host aggregate fact table
- suitable for trend charts, totals, quota, ROI, and cost estimation
Write path
save_usage()inscripts/shared/db.py
Main columns
datetool_namehost_nametokens_usedinput_tokensoutput_tokenscache_tokensrequest_countmodels_used
4.2 daily_messages
Role
- message-level analytics fact table
- suitable for hourly analysis, timelines, sender attribution, and conversation/project dimensions
Write path
save_messages_batch()inscripts/shared/db.py
Important constraint
- there is no separate
cache_tokenscolumn here - this is an analytics fact table, not the workspace runtime transcript authority
- direct
SUM(tokens_used)requires understanding the tool-specific attribution model
4.3 agent_sessions
Role
- session summary table
- used for workspace session lists and session detail headers
Update path
- each fetcher has an
update_agent_sessions_stats()implementation
Typical fields
message_counttotal_tokensrequest_countmodelproject_pathupdated_at
4.4 session_messages
Role
- transcript mirror for session detail pages
- allows fetchers to populate session replay data directly
Update path
- usually inserted from the fetcher's
update_agent_sessions_stats()
4.5 daily_stats / hourly_stats
Role
- derived aggregate tables built from
daily_messages
Refresh behavior
- after
save_messages_batch()succeeds,_refresh_daily_stats_for_messages(messages)rebuilds:daily_statshourly_stats
4.6 user_daily_stats
Role
- per-user daily aggregate table
- used for quota checks, trends, and fast user-level reads
Refresh behavior
- after
save_messages_batch()finishes,scripts/shared/user_stats_helper.py - calls
app/services/user_stats_aggregator.py - which aggregates
daily_messagesandagent_sessionsintouser_daily_stats
5. How downstream code consumes these tables
| Module | Primary source | Why |
|---|---|---|
app/repositories/usage_repo.py | daily_usage first | totals, per-tool summaries, CSV, request counts; avoids daily_messages JOIN multiplication |
app/repositories/message_repo.py | daily_messages | hourly usage, message timeline, sender/project/conversation analysis |
app/services/analysis_service.py | message_repo + hourly_stats | trend and hourly views combine raw message aggregations with derived tables |
app/services/user_stats_aggregator.py | daily_messages + agent_sessions | builds per-user daily aggregates |
| Work session detail | agent_sessions + session_messages | does not rely on daily_messages as the normal runtime source |
| Manage usage / analysis | daily_usage, daily_messages, daily_stats, hourly_stats | depends on whether the page needs totals, trends, or detail |
Practical rules:
- For "how many tokens were used that day", prefer
daily_usage - For "what happened in this hour", use
daily_messagesorhourly_stats - For "show me this session transcript", use
session_messages - For "how much has this user used today", prefer
user_daily_stats
6. Common pitfalls
6.1 Why can Open ACE differ from provider dashboards?
Common reasons:
- provider dashboards and local logs refresh at different times
- provider-side buckets may exist even when local logs do not expose them
- attribution may be session-based, turn-based, or message-based
- provider-specific total/cache/thought semantics differ
Differences do not automatically mean a bug. First identify which layer differs:
- missing source logs
- fetcher attribution
- comparing
daily_usagetodaily_messageseven though they serve different purposes
6.2 Is cache included in total?
For these four tools, Open ACE currently treats cache as part of the total. The difference is only whether:
- the provider already includes cache in its native total, or
- Open ACE has to add cache into
tokens_useditself
6.3 Why can daily_usage differ from SUM(daily_messages.tokens_used)?
Because:
daily_usageis the tool-level aggregate authoritydaily_messagesis the message-level attribution layer- some tools attribute turns to user rows, not assistant rows
- fallback or partial-match cases can keep
daily_usagecomplete while message/session attribution is only approximate
6.4 Why can a re-fetch change historical numbers?
Re-fetch does more than re-insert rows. It may include:
- message-id merge fixes
- turn-attribution fixes
- stale assistant-token cleanup
- deleting and rebuilding old session rows
If parser logic changes, historical attribution can be corrected too.
6.5 Why should some pages avoid summing daily_messages.tokens_used directly?
Because daily_messages is an analytics fact table, not the universal billing fact.
It is ideal for:
- timelines
- hourly patterns
- sender / project / conversation analysis
But for:
- daily totals
- tool-to-tool comparisons
- quota deduction
consumers should prefer daily_usage or user_daily_stats.
7. Maintenance guidance
When adding a new tool or changing a fetcher, check these questions in order:
- What is the provider/source semantic for usage fields?
- Does
tokens_usedalready include cache or thoughts? - Should attribution be per session, per turn, or per message?
- Are
daily_usageanddaily_messagesbeing kept in their intended roles? - Does a parser change require stale session-row cleanup?
- Are there fetcher unit tests or targeted regression tests covering the semantics?
Useful code entry points:
scripts/fetch_claude.pyscripts/fetch_codex.pyscripts/fetch_zcode.pyscripts/fetch_qwen.pyscripts/shared/db.pyscripts/shared/user_stats_helper.pyapp/repositories/usage_repo.pyapp/repositories/message_repo.pyapp/services/analysis_service.pyapp/services/user_stats_aggregator.py