Skip to content

CanonicalTokenUsage conflates Codex and Anthropic token conventions; telemetry mislabels Codex input as uncached #4368

Description

@Trecek

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugExisting behavior is brokenrecipe:remediationRoute: investigate/decompose before implementation

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions