Skip to content

fix(usage): handle interleaved Codex token counters - #5854

Merged
farion1231 merged 3 commits into
farion1231:mainfrom
MJYKIM99:fix/codex-session-usage-counter-lanes
Aug 4, 2026
Merged

farion1231 merged 3 commits into
farion1231:mainfrom
MJYKIM99:fix/codex-session-usage-counter-lanes

Conversation

@MJYKIM99

Copy link
Copy Markdown
Contributor

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 codex and codex_bengalfox; the current single prev_total baseline subtracts across those lanes and creates artificial multi-million-token deltas.

This change:

  • prefers exact per-request last_token_usage when available;
  • scopes cumulative fallback high-water marks by rate_limits.limit_id, with model fallback for older logs;
  • deduplicates adjacent repeated total snapshots within each lane;
  • preserves legitimate counter resets when exact last usage is present;
  • adds regression coverage for interleaved lanes, repeated snapshots, resets, and fallback regressions.

Root Cause and Evidence / 根因与证据

Observed in a root rollout with no forked_from_id or parent thread:

  • codex cumulative input: 76,780,408
  • next codex_bengalfox cumulative input: 87,709,262
  • current importer-derived delta: 10,928,854
  • exact last_token_usage.input_tokens: 151,258

Replaying that file's 1,909 token events through the current v3.18 algorithm yields 1,711,084,739 input tokens. Exact last usage with per-lane adjacent-snapshot deduplication yields 273,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 current main and 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 passed
  • cargo test --manifest-path src-tauri/Cargo.toml --lib: 2,254 passed, 2 ignored
  • cargo clippy --manifest-path src-tauri/Cargo.toml --lib
  • cargo fmt --manifest-path src-tauri/Cargo.toml --check
  • pnpm typecheck
  • pnpm format:check
  • pnpm test:unit: 538 passed
  • git diff --check

Checklist / 检查清单

  • pnpm typecheck passes / 通过 TypeScript 类型检查
  • pnpm format:check passes / 通过代码格式检查
  • cargo clippy passes / 通过 Clippy 检查
  • No user-facing text changed; i18n updates are not required / 未修改用户可见文本,无需更新国际化文件

@MJYKIM99
MJYKIM99 requested a review from farion1231 as a code owner July 29, 2026 00:34
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@github-actions github-actions Bot added the backend Backend (Rust/Tauri) label Jul 29, 2026

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MJYKIM99
MJYKIM99 force-pushed the fix/codex-session-usage-counter-lanes branch from 8bc3c9b to 77561f4 Compare July 29, 2026 02:30
@MJYKIM99

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed corpus replay and the concrete repro cases. I addressed all three points in 77561f43:

  • deduplicate unchanged full snapshots (total + last) across limit_id sources;
  • treat an empty last_token_usage: {} as missing while preserving explicit zero values;
  • use a single session-wide cumulative baseline for the total-only fallback, including across model switches.

I also added regression tests for each case and revalidated the revised parser against an immutable snapshot of my local Codex corpus. Both origin/main and this PR were run against the exact same snapshot with fresh in-memory databases:

Metric origin/main PR (77561f43) Difference
Rollout files 1,190 1,190 —
Sessions 876 876 —
Requests 103,505 103,615 +110
New input tokens 667,192,581 641,596,415 -3.84%
Cached input tokens 14,206,022,278 12,796,810,240 -9.92%
Output tokens 53,604,669 50,866,210 -5.11%
Total tokens 14,926,819,528 13,489,272,865 -1,437,546,663 (-9.63%)
Estimated cost $10,255.98 $9,338.30 -$917.68

The additional 110 non-zero requests are expected: preferring exact last_token_usage recovers reset-crossing requests that cumulative saturating_sub previously swallowed.

For the original affected root rollout specifically (2,059 token events, 502 limit_id switches), an independent replay produced:

  • main: 1,733,408,659 total tokens
  • revised PR: 293,000,771 total tokens

This matches the Rust importer output exactly.

Validation on 77561f43:

  • 34/34 targeted Codex usage tests passed;
  • 2,375 Rust tests passed, 0 failed, 2 ignored;
  • cargo clippy --all-targets -- -D warnings passed;
  • cargo fmt --check and git diff --check passed.

When convenient, could you please re-run your 1,867-file corpus against 77561f43?

@MJYKIM99
MJYKIM99 requested a review from farion1231 July 29, 2026 08:09

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Cross-limit_id replay double-count — gone. All 15 multi-limit_id files now produce exactly the same totals as main and as the independent ideal, byte-for-byte per file. The worst offender under 210f17fd (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.
  2. Empty last_token_usage: {} now falls back to total, with explicit zeros preserved — covered by test_parse_cumulative_tokens_rejects_empty_object_but_accepts_explicit_zero.
  3. 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 last recovers the reset-crossing requests that cumulative saturating_sub swallows. 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 total jumps +52k/+62k but last records only ~7k for those requests. Preferring last trusts 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.

@MJYKIM99

Copy link
Copy Markdown
Contributor Author

Addressed the final stale-signature case in 9df73bda.

The deduplication rule now follows the suggested narrower condition: a full snapshot is treated as a replay only when it matches either:

  • the current source's own last full signature; or
  • the immediately preceding token-event signature, regardless of source.

It no longer scans stale signatures retained by unrelated sources.

I added regression coverage for:

  • the exact X → X (other source) → Y → X (reset) case, which now yields [100, 100, 100];
  • adjacent replay bursts across three sources;
  • cross-source replay adjacency across intervening non-token events;
  • same-source repeats after another source advances; and
  • empty total_token_usage: {} not enabling snapshot deduplication by itself.

Validation:

  • 39/39 targeted Codex usage tests passed;
  • 2,380 Rust tests passed, 0 failed, 2 ignored;
  • cargo clippy --all-targets -- -D warnings passed;
  • cargo fmt --check and git diff --check passed.

I also replayed the revised parser against the same immutable 1,190-file local snapshot. The result is bit-identical to 77561f43:

Metric 77561f43 9df73bda
Requests 103,615 103,615
Total tokens 13,489,272,865 13,489,272,865
Estimated cost $9,338.297667 $9,338.297667

The updated commit is ready for your corpus re-check whenever convenient. Thanks again for the careful review.

@MJYKIM99
MJYKIM99 force-pushed the fix/codex-session-usage-counter-lanes branch from 9df73bd to d8e653e Compare July 30, 2026 10:22
@MJYKIM99

Copy link
Copy Markdown
Contributor Author

Rebased this PR onto the current main (c0ff89b9) in d8e653eb and resolved the overlap introduced by #5626.

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:

  • 44/44 targeted Codex usage tests passed;
  • 2,393 Rust tests passed, 0 failed, 2 ignored;
  • cargo clippy --all-targets -- -D warnings passed;
  • cargo fmt --check and git diff --check passed.

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:

Metric 9df73bda d8e653eb
Requests 103,615 103,615
Total tokens 13,489,272,865 13,489,272,865
Estimated cost $9,338.297667 $9,338.297667

The branch is now up to date with main and ready for review whenever convenient. Thanks again.

@ayanamislover

Copy link
Copy Markdown
Contributor

I independently replayed the actual parser from the current PR head (d8e653eb) against two anonymized Windows rollout files that exhibit the interleaved-counter pattern. This was a direct call to this branch's parse_codex_file, not a reimplementation.

Using the same fixed rolling-window cutoff for the existing v3.19.0 database and the PR parser:

rollout token events v3.19.0/main input PR input reduction
A 9,578 8,713,212,675 934,050,933 -7,779,161,742 (-89.28%, 9.33x)
B 4,787 634,738,149 201,733,432 -433,004,717 (-68.22%, 3.15x)
combined 14,365 9,347,950,824 1,135,784,365 -8,212,166,459 (-87.85%, 8.23x)

The first file visibly alternates divergent cumulative snapshots from concurrent Codex turns; the current single prev_total baseline bills each return to the higher stream as a fresh delta. The PR's exact last_token_usage path removes those false deltas while retaining real requests.

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.

@MJYKIM99

MJYKIM99 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

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 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution!

@farion1231
farion1231 merged commit 59a2bd1 into farion1231:main Aug 4, 2026
6 checks passed
@SaladDay SaladDay mentioned this pull request Aug 10, 2026
2 tasks done
Qfbxz pushed a commit to Qfbxz/cc-switch that referenced this pull request Aug 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Backend (Rust/Tauri)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex 单个 session 用量被严重高估:total_token_usage 非单调/重放时被当作新增 delta 统计

3 participants