Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

257 lines
9.8 KiB
Python

"""Tests for the Codex token meter (#2023).
Fixtures and assertions are derived from a real codex-cli 0.130.0
rollout captured during the issue-#2023 demo. The on-disk format
uses ``event_msg`` with ``payload.type == "token_count"`` and a
nested ``payload.info.last_token_usage`` block — distinct from the
``codex exec --json`` stdout format which uses ``turn.completed``.
"""
from __future__ import annotations
import pathlib
import pytest
from tools.system.fleet_monitoring.meters.codex import CodexMeter
_FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "codex_rollout.ndjson"
@pytest.fixture
def meter() -> CodexMeter:
return CodexMeter()
def test_parses_full_fixture_rollout(meter: CodexMeter) -> None:
"""Sum ``input_tokens + output_tokens`` across every per-turn
``token_count`` event in a realistic Codex rollout fixture.
Hand-counted from ``fixtures/codex_rollout.ndjson``:
- ``session_meta`` → no usage, contributes 0.
- ``turn_context`` (t_001) → no usage.
- ``event_msg`` token_count #1 → ``info: null`` (session-start
handshake), contributes 0.
- ``response_item`` → no usage.
- ``event_msg`` token_count for t_001: 120 in + 18 out = 138.
- ``turn_context`` (t_002) → no usage.
- ``event_msg`` token_count for t_002: 250 in + 42 out = 292.
``cached_input_tokens: 100`` is NOT summed.
- ``turn_context`` (t_003) → no usage.
- ``event_msg`` token_count for t_003: 315 in + 11 out = 326.
``reasoning_output_tokens: 50`` is NOT summed.
Total: 138 + 292 + 326 = **756**. Identical to the Claude Code
fixture total — intentional, makes cross-meter comparison easy.
"""
chunk = _FIXTURE.read_text(encoding="utf-8")
assert meter.parse_chunk(chunk) == 756
def test_info_null_token_count_event_returns_zero(meter: CodexMeter) -> None:
"""The session-start ``token_count`` event carries ``info: null``
while the rate-limit handshake completes. Counting it would
raise ``AttributeError`` on the ``.get`` call; treating it as 0
is both correct and crash-safe.
"""
chunk = (
'{"type":"event_msg","payload":'
'{"type":"token_count","info":null,"rate_limits":{"plan_type":"plus"}}}'
)
assert meter.parse_chunk(chunk) == 0
def test_cached_input_tokens_are_not_summed(meter: CodexMeter) -> None:
"""``cached_input_tokens`` is a discounted subset of input,
so it is exposed for pricing but not added to visible tokens.
"""
chunk = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":'
'{"input_tokens":100,"cached_input_tokens":500,'
'"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":150}}}}'
)
# 100 + 50 = 150, NOT 100 + 500 + 50 = 650.
sample = meter.sample_chunk(chunk)
assert sample.tokens == 150
assert sample.usage.input_tokens == 100
assert sample.usage.cached_input_tokens == 500
assert sample.usage.output_tokens == 50
def test_reasoning_output_tokens_are_not_summed(meter: CodexMeter) -> None:
"""``reasoning_output_tokens`` (o-series internal CoT) bill at
the output rate but only some models emit them. Excluding them
keeps non-reasoning models from being penalized by a
presence-vs-absence inconsistency.
"""
chunk = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":'
'{"input_tokens":100,"cached_input_tokens":0,"output_tokens":50,'
'"reasoning_output_tokens":300,"total_tokens":150}}}}'
)
# 100 + 50 = 150, NOT 100 + 50 + 300 = 450.
assert meter.parse_chunk(chunk) == 150
def test_total_token_usage_is_not_summed(meter: CodexMeter) -> None:
"""``total_token_usage`` is cumulative across the session.
Summing it would double-count every turn from the second tick on.
Lock the meter's choice of ``last_token_usage`` in.
"""
chunk = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":'
'{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,'
'"reasoning_output_tokens":0,"total_tokens":15},'
'"total_token_usage":'
'{"input_tokens":9999,"output_tokens":9999,"total_tokens":19998}}}}'
)
# Only the per-turn delta contributes.
assert meter.parse_chunk(chunk) == 15
def test_total_token_usage_fallback_initializes_baseline(meter: CodexMeter) -> None:
"""When Codex omits ``last_token_usage``, the first cumulative
total for a PID becomes the baseline and must not retro-price the
session history.
"""
chunk = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"total_token_usage":'
'{"input_tokens":1000,"cached_input_tokens":400,"output_tokens":200}}}}'
)
sample = meter.sample_chunk(chunk, pid=4242)
assert sample.tokens == 0
assert sample.usage.input_tokens == 0
def test_total_token_usage_fallback_counts_delta(meter: CodexMeter) -> None:
first = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"total_token_usage":'
'{"input_tokens":1000,"cached_input_tokens":400,"output_tokens":200}}}}'
)
second = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"total_token_usage":'
'{"input_tokens":1125,"cached_input_tokens":450,"output_tokens":240}}}}'
)
assert meter.sample_chunk(first, pid=4242).tokens == 0
sample = meter.sample_chunk(second, pid=4242)
assert sample.tokens == 165
assert sample.usage.input_tokens == 125
assert sample.usage.cached_input_tokens == 50
assert sample.usage.output_tokens == 40
def test_total_token_usage_reset_rebaselines_without_delta(meter: CodexMeter) -> None:
first = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"total_token_usage":'
'{"input_tokens":1000,"cached_input_tokens":400,"output_tokens":200}}}}'
)
reset = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"total_token_usage":'
'{"input_tokens":25,"cached_input_tokens":10,"output_tokens":5}}}}'
)
assert meter.sample_chunk(first, pid=4242).tokens == 0
assert meter.sample_chunk(reset, pid=4242).tokens == 0
assert meter.known_pids() == [4242]
meter.forget(4242)
assert meter.known_pids() == []
def test_non_token_count_events_return_zero(meter: CodexMeter) -> None:
"""``session_meta``, ``turn_context``, and ``response_item``
events do not carry per-turn usage. Skipping them is what keeps
the meter monotonic.
"""
irrelevant = "\n".join(
[
'{"type":"session_meta","payload":{"id":"th_1","model_provider":"openai"}}',
'{"type":"turn_context","payload":{"turn_id":"t_1","model":"gpt-5"}}',
'{"type":"response_item","payload":{"id":"i_1","type":"agent_message","text":"hi"}}',
]
)
assert meter.parse_chunk(irrelevant) == 0
def test_returns_zero_for_irrelevant_chunk(meter: CodexMeter) -> None:
assert meter.parse_chunk("") == 0
assert meter.parse_chunk("hello world\n") == 0
assert meter.parse_chunk("not even close to json\n") == 0
def test_malformed_json_lines_are_skipped(meter: CodexMeter) -> None:
"""A noisy session must not crash — the wiring layer can deliver
partial / truncated lines on subprocess teardown.
"""
chunk = (
"not json at all\n"
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":{"input_tokens":10,"output_tokens":5}}}}\n'
'{"type":"event_msg","payload":{"type":"token_count","info":' # truncated mid-object
)
assert meter.parse_chunk(chunk) == 15
def test_sample_chunk_surfaces_latest_turn_snapshot_model(meter: CodexMeter) -> None:
"""``sample_chunk`` returns ``model`` from the latest
``turn_context`` event so pricing follows the active model when
a session migrates mid-stream (e.g. fallback from a paid model
to a cheaper one).
"""
chunk = "\n".join(
[
'{"type":"turn_context","payload":{"turn_id":"t_1","model":"gpt-5"}}',
(
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":{"input_tokens":10,"output_tokens":5}}}}'
),
'{"type":"turn_context","payload":{"turn_id":"t_2","model":"gpt-5-codex"}}',
(
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":{"input_tokens":20,"output_tokens":10}}}}'
),
]
)
sample = meter.sample_chunk(chunk)
assert sample.tokens == 45
assert sample.model == "gpt-5-codex"
def test_sample_chunk_returns_none_model_when_no_turn_snapshot(
meter: CodexMeter,
) -> None:
"""``session_meta`` carries ``model_provider`` (e.g. ``"openai"``)
but not the specific model id. The meter must not surface a
placeholder; let pricing fall back to yaml / env var sources.
"""
chunk = '{"type":"session_meta","payload":{"model_provider":"openai"}}'
sample = meter.sample_chunk(chunk)
assert sample.tokens == 0
assert sample.model is None
def test_sample_chunk_tokens_match_parse_chunk_for_back_compat(meter: CodexMeter) -> None:
"""The ``int`` returned by ``parse_chunk`` must equal
``sample_chunk(...).tokens`` for every input.
"""
chunk = _FIXTURE.read_text(encoding="utf-8")
assert meter.parse_chunk(chunk) == meter.sample_chunk(chunk).tokens
def test_bool_input_tokens_does_not_add_one(meter: CodexMeter) -> None:
"""``bool`` is a subclass of ``int``; a stray ``true`` in
malformed output must not silently add 1.
"""
chunk = (
'{"type":"event_msg","payload":{"type":"token_count","info":'
'{"last_token_usage":{"input_tokens":true,"output_tokens":false}}}}'
)
assert meter.parse_chunk(chunk) == 0