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
257 lines
9.8 KiB
Python
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
|