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

143 lines
5.1 KiB
Python

"""Token meter for OpenAI Codex CLI rollout NDJSON.
Verified empirically against codex-cli 0.130 rollouts. Event types
in ``$CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl``:
- ``session_meta`` — once per session; carries cwd and
``model_provider`` but not a specific model.
- ``turn_context`` — once per turn; carries ``payload.model``.
- ``response_item`` — agent messages and tool calls; no usage.
- ``event_msg`` with ``payload.type == "token_count"`` — emitted
after every turn; carries ``payload.info.last_token_usage`` with
per-turn ``input_tokens``, ``cached_input_tokens``,
``output_tokens``, ``reasoning_output_tokens``. The first such
event in a session carries ``info: null`` (rate-limit handshake).
This differs from ``codex exec --json`` stdout which uses
``turn.completed`` events. The on-disk rollout is the source of
truth because the wiring layer tails files, not stdout.
``cached_input_tokens`` is captured separately so pricing can apply
the discounted cache-read rate. ``total_token_usage`` is cumulative;
it is only used as a per-PID fallback by diffing against the previous
total observed for that PID.
"""
from __future__ import annotations
import json
import threading
from typing import Any
from tools.system.fleet_monitoring.meters import TokenSample, TokenUsage, safe_int
class CodexMeter:
"""Extracts per-turn usage from ``event_msg.token_count`` records."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._total_usage_by_pid: dict[int, TokenUsage] = {}
def parse_chunk(self, chunk: str) -> int:
return self.sample_chunk(chunk).tokens
def sample_chunk(self, chunk: str, *, pid: int | None = None) -> TokenSample:
usage = TokenUsage()
latest_model: str | None = None
for line in chunk.splitlines():
stripped = line.strip()
if not stripped:
continue
try:
event: Any = json.loads(stripped)
except (json.JSONDecodeError, ValueError):
continue
usage += self._usage_from_event(event, pid)
event_model = _model_from_event(event)
if event_model is not None:
latest_model = event_model
return TokenSample(usage=usage, model=latest_model)
def forget(self, pid: int) -> None:
with self._lock:
self._total_usage_by_pid.pop(pid, None)
def known_pids(self) -> list[int]:
with self._lock:
return list(self._total_usage_by_pid.keys())
def _usage_from_event(self, event: object, pid: int | None) -> TokenUsage:
info = _token_count_info(event)
if info is None:
return TokenUsage()
last = info.get("last_token_usage")
if isinstance(last, dict):
if pid is not None:
total = info.get("total_token_usage")
if isinstance(total, dict):
with self._lock:
self._total_usage_by_pid[pid] = _usage_from_usage_dict(total)
return _usage_from_usage_dict(last)
total = info.get("total_token_usage")
if pid is None or not isinstance(total, dict):
return TokenUsage()
current = _usage_from_usage_dict(total)
with self._lock:
previous = self._total_usage_by_pid.get(pid)
self._total_usage_by_pid[pid] = current
if previous is None or not _is_monotonic(previous, current):
return TokenUsage()
return _usage_delta(previous, current)
def _token_count_info(event: object) -> dict[str, Any] | None:
if not isinstance(event, dict) or event.get("type") != "event_msg":
return None
payload = event.get("payload")
if not isinstance(payload, dict) or payload.get("type") != "token_count":
return None
info = payload.get("info")
if not isinstance(info, dict):
return None
return info
def _usage_from_usage_dict(raw: dict[str, Any]) -> TokenUsage:
return TokenUsage(
input_tokens=safe_int(raw.get("input_tokens")),
output_tokens=safe_int(raw.get("output_tokens")),
cached_input_tokens=safe_int(raw.get("cached_input_tokens")),
)
def _is_monotonic(previous: TokenUsage, current: TokenUsage) -> bool:
return (
current.input_tokens >= previous.input_tokens
and current.output_tokens >= previous.output_tokens
and current.cached_input_tokens >= previous.cached_input_tokens
)
def _usage_delta(previous: TokenUsage, current: TokenUsage) -> TokenUsage:
return TokenUsage(
input_tokens=current.input_tokens - previous.input_tokens,
output_tokens=current.output_tokens - previous.output_tokens,
cached_input_tokens=current.cached_input_tokens - previous.cached_input_tokens,
)
def _model_from_event(event: object) -> str | None:
if not isinstance(event, dict) or event.get("type") != "turn_context":
return None
payload = event.get("payload")
if not isinstance(payload, dict):
return None
model = payload.get("model")
if isinstance(model, str) and model:
return model
return None