Problem
CanonicalTokenUsage maps two mutually incompatible provider conventions onto identically-named fields, so any cross-backend token comparison built on it is wrong by roughly 2×.
src/autoskillit/core/types/_type_token.py:
@classmethod
def from_codex_dict(cls, d):
return cls(
input_tokens=d["input_tokens"], # already INCLUDES cached
output_tokens=d["output_tokens"],
cache_read_tokens=d.get("cached_input_tokens"), # subset of input_tokens
cache_write_tokens=None, # hardcoded
provider="codex", raw=dict(d),
)
Codex reports input_tokens inclusive of cached_input_tokens. Anthropic reports input_tokens disjoint from cache_read_input_tokens / cache_creation_input_tokens. Both land in the same field names with no provider-conditional correction anywhere in src/.
Verified against the rollout corpus: cached_input_tokens <= input_tokens in every usage record sampled (0 violations), median cached share 98.5% — i.e. the inclusion is real and large.
python3 - <<'EOF'
import json,glob,os
def w(o):
if isinstance(o,dict):
if 'input_tokens' in o and 'cached_input_tokens' in o: yield o
for v in o.values(): yield from w(v)
elif isinstance(o,list):
for v in o: yield from w(v)
n=v=0
for f in glob.glob(os.path.expanduser('~/.local/share/autoskillit/logs/codex-sessions/2026/07/**/rollout-*.jsonl'),recursive=True)[-40:]:
for l in open(f,errors='ignore'):
try: o=json.loads(l)
except: continue
for u in w(o):
i,c=u['input_tokens'],u['cached_input_tokens']
if isinstance(i,int) and isinstance(c,int) and i:
n+=1; v+= c>i
print('records',n,'violations',v)
EOF
Live manifestation
src/autoskillit/hooks/token_summary_hook.py renders a PR-body column labelled uncached from the raw input_tokens value with no provider branching. For a Codex session at 96% cache hit that column overstates uncached usage by more than an order of magnitude, and it ships in every PR body.
cache_write_tokens is separately hardcoded to None on the Codex path, so any "tokens written to cache" comparison is undefined rather than merely wrong.
Scope note
No code path in src/ currently sums input_tokens + cache_read_tokens + cache_write_tokens into a displayed total, so this is latent for aggregate reporting and live only in the mislabelled column above. It becomes actively wrong the moment anyone builds a cross-backend cost view on these fields.
Expected behaviour
input_tokens means the same thing on both providers — either subtract cached_input_tokens on the Codex path, or add an explicit total_prompt_tokens field and stop letting consumers infer totals by addition. cache_write_tokens gets a defined value or an explicit None-means-unsupported contract that consumers must handle.
Blast radius
Two readings, both worth knowing before scoping:
| Scope |
Source files |
Test files |
References the CanonicalTokenUsage type |
2 |
3 |
Reads the affected fields (cache_read_tokens / cached_input_tokens) |
15 |
37 |
The second row is the one that matters: changing what input_tokens means for the Codex path affects every consumer that reads these fields, not only those that import the type.
grep -rl "cache_read_tokens\|cached_input_tokens" --include=*.py src/ | wc -l # 15
grep -rl "cache_read_tokens\|cached_input_tokens" --include=*.py tests/ | wc -l # 37
Source consumers include pipeline/tokens.py, pipeline/telemetry_fmt.py, hooks/token_summary_hook.py, four hook formatters, execution/session/_session_model.py, execution/session_log.py, fleet/summary.py, cli/fleet/_fleet_display.py and server/tools/tools_status.py — i.e. every surface that displays or aggregates token counts.
tests/core/test_canonical_token_usage.py:48-52 pins the current arithmetic (input_tokens == 200 unmodified, cache_read_tokens == 30 copied verbatim) and must be rewritten in the same change.
(An earlier revision of this issue stated "8 source files and 25+ test files". That figure did not reproduce under either reading and has been replaced with the counts above and the command that produces them.)
Classification
bug, recipe:remediation
Problem
CanonicalTokenUsagemaps two mutually incompatible provider conventions onto identically-named fields, so any cross-backend token comparison built on it is wrong by roughly 2×.src/autoskillit/core/types/_type_token.py:Codex reports
input_tokensinclusive ofcached_input_tokens. Anthropic reportsinput_tokensdisjoint fromcache_read_input_tokens/cache_creation_input_tokens. Both land in the same field names with no provider-conditional correction anywhere insrc/.Verified against the rollout corpus:
cached_input_tokens <= input_tokensin every usage record sampled (0 violations), median cached share 98.5% — i.e. the inclusion is real and large.Live manifestation
src/autoskillit/hooks/token_summary_hook.pyrenders a PR-body column labelleduncachedfrom the rawinput_tokensvalue with no provider branching. For a Codex session at 96% cache hit that column overstates uncached usage by more than an order of magnitude, and it ships in every PR body.cache_write_tokensis separately hardcoded toNoneon the Codex path, so any "tokens written to cache" comparison is undefined rather than merely wrong.Scope note
No code path in
src/currently sumsinput_tokens + cache_read_tokens + cache_write_tokensinto a displayed total, so this is latent for aggregate reporting and live only in the mislabelled column above. It becomes actively wrong the moment anyone builds a cross-backend cost view on these fields.Expected behaviour
input_tokensmeans the same thing on both providers — either subtractcached_input_tokenson the Codex path, or add an explicittotal_prompt_tokensfield and stop letting consumers infer totals by addition.cache_write_tokensgets a defined value or an explicitNone-means-unsupported contract that consumers must handle.Blast radius
Two readings, both worth knowing before scoping:
CanonicalTokenUsagetypecache_read_tokens/cached_input_tokens)The second row is the one that matters: changing what
input_tokensmeans for the Codex path affects every consumer that reads these fields, not only those that import the type.Source consumers include
pipeline/tokens.py,pipeline/telemetry_fmt.py,hooks/token_summary_hook.py, four hook formatters,execution/session/_session_model.py,execution/session_log.py,fleet/summary.py,cli/fleet/_fleet_display.pyandserver/tools/tools_status.py— i.e. every surface that displays or aggregates token counts.tests/core/test_canonical_token_usage.py:48-52pins the current arithmetic (input_tokens == 200unmodified,cache_read_tokens == 30copied verbatim) and must be rewritten in the same change.(An earlier revision of this issue stated "8 source files and 25+ test files". That figure did not reproduce under either reading and has been replaced with the counts above and the command that produces them.)
Classification
bug,recipe:remediation