Repository navigation
fix(usage): handle interleaved Codex token counters - #5854
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
farion1231
left a comment
There was a problem hiding this comment.
Thanks for the thorough investigation and for rebasing the #3015 direction onto the current parser — the evidence in the PR description is solid, and preferring exact last_token_usage over cumulative-snapshot subtraction is the right call. Your 6.26× overcount replay for genuinely divergent interleaved counters is reproducible in structure, and this PR fixes that class of file.
However, I replayed the new algorithm against a corpus of 1,867 real rollout files (2025-08 → 2026-07, multiple Codex versions) and found one blocking issue plus two secondary ones. The PR's 31 tests all pass, but the three regression cases below fail stably on 210f17fd.
1. [Blocking] Per-lane dedup double-counts snapshots re-emitted under a different limit_id
The dedup at session_usage_codex.rs:723-734 is scoped to a single lane. But upstream, limit_id identifies the rate-limit bucket, not a token-counter boundary: whenever rate limits refresh, codex re-broadcasts the unchanged cached token info together with the new snapshot — see update_rate_limits → send_token_count_event, which clones state.token_info_and_rate_limits() without touching usage.
In real logs this produces a single monotonic counter whose snapshots are replayed 1-2× with alternating limit_ids. Excerpt from an affected rollout (all 15 multi-limit_id files in my corpus follow this shape):
seq | limit_id | total_input | last_input
9 | codex | 571,291 | 91,858
10 | codex | 571,291 | 91,858 <- same-lane repeat: deduped OK
11 | codex_bengalfox | 571,291 | 91,858 <- lane flipped: NOT deduped, last billed again
12 | codex_bengalfox | 663,799 | 92,508
13 | codex | 663,799 | 92,508 <- billed again
14 | codex_bengalfox | 663,799 | 92,508 <- billed again
The old algorithm absorbs these replays naturally (total unchanged → delta 0). The new per-lane dedup misses every lane-flip replay and bills last each time. Corpus impact: 44 files affected, ~156M input tokens double-counted; the worst file inflates 24.7M → 47.1M (+90%) — the same order of regression this PR sets out to fix, just in the opposite direction.
Minimal repro (fails with [100, 100], expected [100]):
Failing test
#[test]
fn cross_lane_snapshot_replay_is_not_double_counted() -> Result<(), AppError> {
let dir = tempdir().unwrap();
let file = rollout_path(dir.path(), PARENT_ID);
// Upstream re-emits the unchanged token info when rate limits refresh,
// possibly under a different limit_id.
write_jsonl(
&file,
&[
session_meta(PARENT_ID),
turn_context(),
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex_bengalfox", "2026-07-10T03:00:03Z"),
],
);
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
let deltas = parsed
.token_events
.iter()
.filter(|e| !e.delta.is_zero())
.map(|e| e.delta.input)
.collect::<Vec<_>>();
assert_eq!(deltas, vec![100]);
Ok(())
}Suggested fix: dedup on the full signature (total + last) across all lanes — i.e. treat an event as a duplicate when its full signature matches the last-seen full signature of any lane (this subsumes the current per-lane check). A genuine counter reset still gets counted because its last differs, so test_lane_snapshot_dedupe_allows_counter_reset keeps passing. I verified on the corpus that full-signature repeats are always within one lane-flip burst (zero non-burst repeats), so this closes the hole completely.
2. last_token_usage: {} silently swallows a valid total_token_usage
parse_cumulative_tokens maps {} to all-zeros, so at L736-L748 an empty-object last wins the priority chain with a zero delta and the valid total is never consulted — main would have counted it. Minimal repro: total = {input: 100, ...} + last = {} yields [], expected [100]. No such event exists in my corpus (current codex serializes every field), so this is hardening — but cheap: return None from the parser when the object contains no known token field, while keeping explicit zeros valid.
3. Model-keyed fallback lanes re-count session-cumulative totals
For events without limit_id, token_counter_lane splits the high-water baseline by model. But total_token_usage is session-cumulative and does not reset on model switch (protocol.rs). A total-only log switching model A→B at totals 100→150 yields [100, 150] instead of [100, 50]: the first event of each new model lane re-counts the whole session history. This path needs both last missing and limit_id missing to fire — zero such events in my corpus (total/last have appeared strictly in pairs since 2025-09) — but since limit_id is not a counter boundary anyway (see item 1), the lane concept can be dropped from the fallback entirely: keep a single global baseline for the total-only path, which is exactly main's proven semantics.
Validation of the combined fix
Replaying the corpus with (full-signature cross-lane dedup) + (prefer last) + (single-baseline fallback):
| algorithm | corpus input tokens | vs. fixed |
|---|---|---|
main |
6.927B | -0.02% |
| this PR as-is | 7.009B | +1.16% (concentrated 2026-02→05, worst file +90%) |
| PR + fixes above | 6.929B | baseline |
The residual main gap (-0.02%) is the legitimate improvement: last recovers the reset-crossing requests that saturating_sub swallows, and the genuinely-divergent interleaved pattern from your machine is still fixed by last-preference — none of that depends on per-lane bookkeeping.
One maintainer-side note, not on you to solve: any change to delta classification renumbers event_index for rollouts that are still being appended across the upgrade, so the release note should keep the usual "Rebuild Codex Usage corrects history" pointer.
Happy to re-run the corpus replay on a revised version.
8bc3c9b to
77561f4
Compare
|
Thanks for the detailed corpus replay and the concrete repro cases. I addressed all three points in
I also added regression tests for each case and revalidated the revised parser against an immutable snapshot of my local Codex corpus. Both
The additional 110 non-zero requests are expected: preferring exact For the original affected root rollout specifically (2,059 token events, 502
This matches the Rust importer output exactly. Validation on
When convenient, could you please re-run your 1,867-file corpus against |
farion1231
left a comment
There was a problem hiding this comment.
Re-ran the full validation against 77561f43 as promised — this time by compiling the actual parser from both trees and replaying the corpus through the real parse_codex_file (no re-implementation on my side), plus an independent ground-truth pass over the raw JSONL (per-file reset-segmented high-water sums, derived without either algorithm's logic).
Corpus: 1,869 rollout files (2025-08 → 2026-07), 1,535 with token events, immutable snapshot.
| algorithm | input tokens | vs. independent ideal |
|---|---|---|
origin/main |
6,935,406,684 | -0.017% |
PR 77561f43 |
6,936,529,046 | -0.001% |
| independent ideal (segmented high-water) | 6,936,585,330 | — |
All three findings are resolved:
- Cross-
limit_idreplay double-count — gone. All 15 multi-limit_idfiles now produce exactly the same totals asmainand as the independent ideal, byte-for-byte per file. The worst offender under210f17fd(24,725,834 → 47,092,556, +90%) is back to 24,725,834 exactly. The.values().any()full-signature check against each source's latest snapshot is precisely the shape I had in mind, and keeping one entry per source (rather than only the single most recent signature) handles replay bursts that interleave more than two sources. - Empty
last_token_usage: {}now falls back tototal, with explicit zeros preserved — covered bytest_parse_cumulative_tokens_rejects_empty_object_but_accepts_explicit_zero. - Single session-wide baseline restores
main's proven total-only semantics; the per-lane high-water concept is fully gone.
Only 26 of 1,869 files differ from main at all, and every one is accounted for:
- 24 counter-reset files: PR counts slightly more (+9k to +138k each, +1.12M total) because
lastrecovers the reset-crossing requests that cumulativesaturating_subswallows. This is the legitimate improvement and matches your +110-requests observation on your corpus. - 1 legacy 2025-09 file (-100,015): an old-codex quirk where
totaljumps +52k/+62k butlastrecords only ~7k for those requests. Preferringlasttrusts the exact per-request figures; bounded to one file at the pairing-transition boundary. - 1 file with a mid-session counter dip (6,271 → dip → 3,232): PR yields 9,503 = 6,271 + 3,232 exactly — i.e. it correctly bills both counter generations where my threshold-based segmentation (and
main) merged them. The PR is more correct here.
One last refinement before I approve: stale cross-source signatures can swallow a legitimate counter reset
Entries in last_signature_by_source never expire, and .values().any() compares against every source's latest snapshot forever. If signature X was once replayed from codex to codex_bengalfox, and codex then advances to Y, a later genuine reset that legitimately reproduces X is judged a duplicate by the stale codex_bengalfox → X entry. Failing test on 77561f43 (yields [100, 100], expected [100, 100, 100]):
Failing test
#[test]
fn stale_cross_source_signature_must_not_swallow_reset() -> Result<(), AppError> {
let dir = tempdir().unwrap();
let file = rollout_path(dir.path(), PARENT_ID);
write_jsonl(
&file,
&[
session_meta(PARENT_ID),
turn_context(),
// ev1: "codex" emits snapshot X
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:02Z"),
// ev2: X replayed under "codex_bengalfox"
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex_bengalfox", "2026-07-10T03:00:03Z"),
// ev3: "codex" advances to Y
token_count_with_last_at(2_000, 0, 20, 100, 0, 10, "codex", "2026-07-10T03:00:04Z"),
// ev4: counter resets; a genuine request reproduces X exactly
token_count_with_last_at(1_000, 0, 10, 100, 0, 10, "codex", "2026-07-10T03:00:05Z"),
],
);
let parsed = parse_codex_file(&file, Some(PARENT_ID.to_string()))?;
let deltas = parsed
.token_events
.iter()
.filter(|event| !event.delta.is_zero())
.map(|event| event.delta.input)
.collect::<Vec<_>>();
assert_eq!(deltas, vec![100, 100, 100]);
Ok(())
}Suggested rule: treat an event as a duplicate only when its full signature matches (a) the current source's own last signature — this preserves your interleaved-counters test — or (b) the signature of the immediately preceding token event, regardless of source. (b) covers the replay bursts: by the upstream mechanism, a rate-limit refresh re-broadcasts the current cached token info, never a stale one, so a replay always appears adjacent to the snapshot it duplicates. The union is strictly narrower than scanning all sources, removes the unbounded staleness, and leaves only the irreducible core (a reset immediately reproducing the very latest signature is indistinguishable from a replay under any scheme).
To be clear about severity: zero occurrences in my corpus (multi-source ∩ reset = 0 of 1,869 files), and the failure cost is dropping a single request — this is hardening. But since the replacement is simpler than the current check and mechanism-aligned, it's worth folding in. On my corpus it produces bit-identical results (every observed full-signature repeat is adjacent or same-source), so I expect the validation table above to carry over unchanged — I'll re-run the replay to confirm and then approve.
Everything else is merge-ready. Thanks for the fast and precise turnaround, and for validating against your own corpus with fresh databases on both sides.
|
Addressed the final stale-signature case in The deduplication rule now follows the suggested narrower condition: a full snapshot is treated as a replay only when it matches either:
It no longer scans stale signatures retained by unrelated sources. I added regression coverage for:
Validation:
I also replayed the revised parser against the same immutable 1,190-file local snapshot. The result is bit-identical to
The updated commit is ready for your corpus re-check whenever convenient. Thanks again for the careful review. |
9df73bd to
d8e653e
Compare
|
Rebased this PR onto the current The only textual conflict was between adjacent test helpers:
Both helpers are retained. The parent-rollout timeline cache from #5626 and the parser/deduplication behavior in this PR now coexist without a production-code conflict. Post-rebase validation:
I also replayed the rebased parser against the same immutable 1,190-file local snapshot. The result remains bit-identical to the pre-rebase head:
The branch is now up to date with |
|
I independently replayed the actual parser from the current PR head ( Using the same fixed rolling-window cutoff for the existing v3.19.0 database and the PR parser:
The first file visibly alternates divergent cumulative snapshots from concurrent Codex turns; the current single I also ran the targeted test group at this head: 44 passed, 0 failed, 1 local probe ignored. No paths, session IDs, project names, or message content were included in this validation. One operational confirmation: an already-inflated v3.19.0 database remains inflated until Codex usage is rebuilt after installing a release containing this fix, so the existing release-note/manual rebuild guidance is important. |
|
Hi @farion1231, the requested changes have been addressed in d8e653e, and all checks are green. An independent replay also confirmed the fix on real rollout data. When convenient, could you please take another look? |
farion1231
left a comment
There was a problem hiding this comment.
Thank you for your contribution!
Integrates official v3.19.2 (upstream/main @ 413c09e) on top of local customizations. All 4 open PRs (farion1231#5763, farion1231#5764, farion1231#5765, farion1231#5772) remain unmerged upstream; their changes are preserved on this branch. Upstream v3.19.2 highlights: - Codex interleaved-counter usage fix (farion1231#5854) - Six security hardening caps (farion1231#5919) - MCP/Skills panel search & bulk toggles (farion1231#5967) - Per-account Codex OAuth usage in Auth Center (farion1231#4887) - OMO unified config + runtime models (farion1231#6011, farion1231#5522) - Backup batch dump/restore (farion1231#6122), Codex reimport ~3x faster - Copilot compat with new Claude Code (farion1231#5832) - Hermes SOUL.md filename fix (farion1231#5779) - Skill source-dir anchor (farion1231#4153), skills.sh README fix (farion1231#6119) - Qwen3.8 Max built-in pricing (farion1231#6053) No file overlap between upstream changes and local customizations; merge is conflict-free.
Summary / 概述
Fix Codex session-usage inflation that remains after v3.18's fork-history filtering. A root rollout can interleave independent cumulative counters such as
codexandcodex_bengalfox; the current singleprev_totalbaseline subtracts across those lanes and creates artificial multi-million-token deltas.This change:
last_token_usagewhen available;rate_limits.limit_id, with model fallback for older logs;Root Cause and Evidence / 根因与证据
Observed in a root rollout with no
forked_from_idor parent thread:codexcumulative input:76,780,408codex_bengalfoxcumulative input:87,709,26210,928,854last_token_usage.input_tokens:151,258Replaying that file's 1,909 token events through the current v3.18 algorithm yields
1,711,084,739input tokens. Exact last usage with per-lane adjacent-snapshot deduplication yields273,343,633, so the current algorithm overcounts the file by 6.26×.Related Issue / 关联 Issue
Fixes #3011.
This builds on the direction established by @KeShih in #3015. That PR predates the recent Codex session-parser refactor and is now conflicting with
main; this implementation rebases the approach onto currentmainand adds lane isolation, snapshot deduplication, and reset/fallback coverage.#3189 is related but distinct: it aggregates multiple token events per turn, while this PR corrects the per-event delta before any aggregation.
Screenshots / 截图
Not applicable — backend session-log importer only.
Test Plan / 测试
cargo test --manifest-path src-tauri/Cargo.toml --lib services::session_usage_codex::tests: 31 passedcargo test --manifest-path src-tauri/Cargo.toml --lib: 2,254 passed, 2 ignoredcargo clippy --manifest-path src-tauri/Cargo.toml --libcargo fmt --manifest-path src-tauri/Cargo.toml --checkpnpm typecheckpnpm format:checkpnpm test:unit: 538 passedgit diff --checkChecklist / 检查清单
pnpm typecheckpasses / 通过 TypeScript 类型检查pnpm format:checkpasses / 通过代码格式检查cargo clippypasses / 通过 Clippy 检查