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

198 lines
7.8 KiB
Python

"""Tests for the Claude Code token meter (issue #1495)."""
from __future__ import annotations
import pathlib
import pytest
from tools.system.fleet_monitoring.meters.claude_code import ClaudeCodeMeter
_FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "claude_code_stream.ndjson"
@pytest.fixture
def meter() -> ClaudeCodeMeter:
return ClaudeCodeMeter()
def test_parses_full_fixture_stream(meter: ClaudeCodeMeter) -> None:
"""Sum input + output tokens across every ``assistant`` event in a
real stream.
Hand-counted from ``fixtures/claude_code_stream.ndjson``:
- ``system.init`` → no usage block, contributes 0.
- ``assistant`` msg_01: 120 in + 18 out = 138.
- ``assistant`` msg_02: 250 in + 42 out = 292.
- ``user`` (tool_result) → no usage block, contributes 0.
- ``assistant`` msg_03: 315 in + 11 out = 326.
- ``result`` → cumulative session totals (315 in + 71 out); the
meter ignores ``result`` events because counting them would
double-count the final turn's input and the entire session's
output (~50% inflation in any multi-turn session).
Total: 138 + 292 + 326 = **756**.
"""
chunk = _FIXTURE.read_text(encoding="utf-8")
assert meter.parse_chunk(chunk) == 756
def test_result_event_is_ignored(meter: ClaudeCodeMeter) -> None:
"""The ``result`` event carries cumulative session totals, not
per-turn deltas — counting it would overcount. Locking the
behavior in so a future "simplification" doesn't silently
re-introduce a ~50% inflation in every multi-turn session.
"""
result_event = (
'{"type":"result","subtype":"success","is_error":false,'
'"duration_ms":3420,"usage":{"input_tokens":315,"output_tokens":71},'
'"total_cost_usd":0.012}'
)
assert meter.parse_chunk(result_event) == 0
def test_returns_zero_for_irrelevant_chunk(meter: ClaudeCodeMeter) -> None:
"""Acceptance: irrelevant chunks return 0, not -1, not None, not a raise."""
assert meter.parse_chunk("hello world\n") == 0
assert meter.parse_chunk("") == 0
assert meter.parse_chunk('{"type":"system","subtype":"init"}') == 0
def test_returns_zero_for_assistant_text_containing_token_keys(meter: ClaudeCodeMeter) -> None:
"""An assistant response whose ``text`` content happens to embed
the literal JSON-key form (e.g. Claude generating documentation
about the Anthropic API) must not contribute. Structural
discrimination via ``message.usage`` rules out free-form text
matches that a flat regex would have captured.
"""
embedded_key = (
'{"type":"assistant","message":{"content":'
'[{"type":"text","text":"Anthropic responses look like '
r'\"input_tokens\": 5000."}]}}'
)
assert meter.parse_chunk(embedded_key) == 0
def test_returns_zero_for_token_word_outside_json_key_form(meter: ClaudeCodeMeter) -> None:
"""Free-form 'tokens' mentions in assistant content must not be counted."""
free_form = (
'{"type":"assistant","message":{"content":'
'[{"type":"text","text":"This used 50 tokens, roughly."}]}}'
)
assert meter.parse_chunk(free_form) == 0
def test_sums_correctly_across_split_chunks(meter: ClaudeCodeMeter) -> None:
"""A stream split into line-aligned chunks must total to the same
as the full stream. The wiring layer reads ``stdout`` line-by-line,
so this is the realistic splitting case.
"""
full = _FIXTURE.read_text(encoding="utf-8")
lines = full.splitlines(keepends=True)
midpoint = len(lines) // 2
chunk_a = "".join(lines[:midpoint])
chunk_b = "".join(lines[midpoint:])
assert meter.parse_chunk(chunk_a) + meter.parse_chunk(chunk_b) == 756
def test_handles_each_event_type_in_isolation(meter: ClaudeCodeMeter) -> None:
"""Each NDJSON event is independently parseable — useful for the
line-by-line streaming the dashboard wiring will do.
Per-line breakdown of the fixture (system, three assistant turns,
a tool_result user event, and a final result event):
"""
lines = _FIXTURE.read_text(encoding="utf-8").splitlines()
counts = [meter.parse_chunk(line) for line in lines]
# system, msg_01, msg_02, tool_result, msg_03, result
assert counts == [0, 138, 292, 0, 326, 0]
def test_cache_token_counters_are_not_summed(meter: ClaudeCodeMeter) -> None:
"""``cache_creation_input_tokens`` and ``cache_read_input_tokens``
are included in visible activity and broken out for exact pricing.
"""
chunk_with_cache = (
'{"type":"assistant","message":{"usage":{"input_tokens":100,'
'"cache_creation_input_tokens":500,"cache_read_input_tokens":2000,'
'"output_tokens":50}}}'
)
sample = meter.sample_chunk(chunk_with_cache)
assert sample.tokens == 2650
assert sample.usage.input_tokens == 100
assert sample.usage.cache_creation_input_tokens == 500
assert sample.usage.cache_read_input_tokens == 2000
assert sample.usage.output_tokens == 50
def test_malformed_json_lines_are_skipped(meter: ClaudeCodeMeter) -> None:
"""Truncated or otherwise unparseable JSON lines must not raise —
the wiring layer can deliver partial lines on subprocess
teardown, and a noisy session should not crash the dashboard.
"""
chunk = (
"not json at all\n"
'{"type":"assistant","message":{"usage":{"input_tokens":10,"output_tokens":5}}}\n'
'{"type":"assistant","message":{"usage":' # truncated
)
assert meter.parse_chunk(chunk) == 15
def test_sample_chunk_surfaces_latest_assistant_model(meter: ClaudeCodeMeter) -> None:
"""``sample_chunk`` (#2023) returns the model from the *latest*
assistant event so pricing follows the active model. A mid-stream
``/model`` switch should be reflected immediately rather than
sticking on the first model seen.
"""
chunk = "\n".join(
[
(
'{"type":"assistant","message":{"model":"claude-sonnet-4-5",'
'"usage":{"input_tokens":10,"output_tokens":5}}}'
),
(
'{"type":"assistant","message":{"model":"claude-opus-4-1",'
'"usage":{"input_tokens":20,"output_tokens":8}}}'
),
]
)
sample = meter.sample_chunk(chunk)
assert sample.tokens == 43
assert sample.model == "claude-opus-4-1"
def test_sample_chunk_returns_none_model_when_assistant_lacks_model_field(
meter: ClaudeCodeMeter,
) -> None:
"""Older fixtures and some Claude Code releases omit
``message.model`` on assistant events. ``sample_chunk`` must
accept that without raising and surface ``model=None`` — the
wiring layer then falls back to yaml override or env var.
"""
chunk = '{"type":"assistant","message":{"usage":{"input_tokens":10,"output_tokens":5}}}'
sample = meter.sample_chunk(chunk)
assert sample.tokens == 15
assert sample.model is None
def test_sample_chunk_ignores_model_on_non_assistant_events(meter: ClaudeCodeMeter) -> None:
"""The ``system.init`` event carries a top-level ``model`` field
but is not an assistant event. The meter must not surface it from
a chunk with no assistant turns, or the dashboard would price an
empty window against a model the user has not yet used.
"""
chunk = '{"type":"system","subtype":"init","session_id":"abc","model":"claude-opus-4-7"}'
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: ClaudeCodeMeter) -> None:
"""Locking the invariant that the integer-only API and the
structured API agree on the token count — any callers still using
``parse_chunk`` must continue to see the same numbers.
"""
chunk = _FIXTURE.read_text(encoding="utf-8")
assert meter.parse_chunk(chunk) == meter.sample_chunk(chunk).tokens