mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
The token-usage pipeline was fully built — per-session SDK counters,
/usage/status, the orchestrator finalize-fetch that writes token columns and
estimated cost to the spawn-session row, the daily rollup, and the dashboard
— but nothing ever populated the counters. /usage/report had zero callers, so
every session reported zero tokens and the cost dashboard rendered all-zeros.
A redeploy could not fix code that was never written.
Close the loop with the producer that was missing. Claude Code does not pass
token counts to hooks, but it does pass the session transcript path, and each
assistant entry records its API call's usage. Add:
- POST /usage/sync, which parses the transcript and *sets* the cumulative
totals absolutely (idempotent — re-syncing the same or a grown transcript
overwrites, never double-counts), with a (size, mtime) short-circuit so an
unchanged transcript skips the re-parse.
- usage-report-hook.sh, which hands the SDK the transcript path. Registered on
PostToolUse (keeps mid-run snapshots and reaped-agent sessions accurate) and
Stop (guarantees a final sync at turn end before finalize reads the totals).
Field mapping verified against a real Claude Code transcript:
message.usage.{input_tokens, output_tokens, cache_read_input_tokens,
cache_creation_input_tokens}. Unit tests cover summation, idempotency, growth,
a missing transcript, and malformed lines.
171 lines
5.8 KiB
Python
171 lines
5.8 KiB
Python
"""Token-usage capture — /usage/sync parses the transcript and sets totals.
|
|
|
|
The agent SDK exposes /usage/report (additive) and /usage/status (read),
|
|
but nothing ever fed token counts in, so every session reported zero and the
|
|
cost dashboard rendered all-zeros. The fix: the usage-report hook hands the
|
|
SDK the Claude Code transcript path; /usage/sync parses the per-message
|
|
``usage`` blocks and *sets* the cumulative totals absolutely. These tests pin
|
|
that contract — correct summation, idempotency (no double-count on re-sync),
|
|
graceful handling of a missing/partial transcript, and growth on re-sync.
|
|
|
|
Expected totals are derived from the input rows (no magic literals), so the
|
|
assertions track whatever the fixtures declare.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
import roboco.agent_sdk.server as srv
|
|
from fastapi.testclient import TestClient
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator, Sequence
|
|
from pathlib import Path
|
|
|
|
_OK = 200
|
|
|
|
# Each row is (input, output, cache_read, cache_write).
|
|
_UsageRow = tuple[int, int, int, int]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_state() -> Iterator[None]:
|
|
srv._state.reset()
|
|
yield
|
|
srv._state.reset()
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(srv.app)
|
|
|
|
|
|
def _assistant_line(row: _UsageRow) -> str:
|
|
inp, out, cread, cwrite = row
|
|
return json.dumps(
|
|
{
|
|
"type": "assistant",
|
|
"message": {
|
|
"role": "assistant",
|
|
"usage": {
|
|
"input_tokens": inp,
|
|
"output_tokens": out,
|
|
"cache_read_input_tokens": cread,
|
|
"cache_creation_input_tokens": cwrite,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
def _write(path: Path, *lines: str) -> None:
|
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def _expected(rows: Sequence[_UsageRow]) -> dict[str, int]:
|
|
return {
|
|
"tokens_input": sum(r[0] for r in rows),
|
|
"tokens_output": sum(r[1] for r in rows),
|
|
"tokens_cache_read": sum(r[2] for r in rows),
|
|
"tokens_cache_write": sum(r[3] for r in rows),
|
|
}
|
|
|
|
|
|
def test_sums_usage_across_assistant_messages(
|
|
client: TestClient, tmp_path: Path
|
|
) -> None:
|
|
rows: list[_UsageRow] = [(100, 20, 5, 3), (50, 10, 2, 1)]
|
|
transcript = tmp_path / "session.jsonl"
|
|
_write(transcript, *(_assistant_line(r) for r in rows))
|
|
resp = client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
|
assert resp.status_code == _OK
|
|
body = resp.json()
|
|
for key, value in _expected(rows).items():
|
|
assert body[key] == value
|
|
|
|
|
|
def test_status_reflects_synced_totals(client: TestClient, tmp_path: Path) -> None:
|
|
rows: list[_UsageRow] = [(200, 40, 0, 0)]
|
|
transcript = tmp_path / "session.jsonl"
|
|
_write(transcript, *(_assistant_line(r) for r in rows))
|
|
client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
|
status = client.get("/usage/status").json()
|
|
for key, value in _expected(rows).items():
|
|
assert status[key] == value
|
|
|
|
|
|
def test_resync_is_idempotent_not_additive(client: TestClient, tmp_path: Path) -> None:
|
|
"""The set is absolute — syncing the same transcript twice must not double."""
|
|
rows: list[_UsageRow] = [(100, 20, 0, 0)]
|
|
transcript = tmp_path / "session.jsonl"
|
|
_write(transcript, *(_assistant_line(r) for r in rows))
|
|
client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
|
client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
|
status = client.get("/usage/status").json()
|
|
for key, value in _expected(rows).items():
|
|
assert status[key] == value
|
|
|
|
|
|
def test_resync_after_growth_overwrites_with_new_total(
|
|
client: TestClient, tmp_path: Path
|
|
) -> None:
|
|
first: list[_UsageRow] = [(100, 20, 0, 0)]
|
|
grown: list[_UsageRow] = [(100, 20, 0, 0), (80, 15, 0, 0)]
|
|
transcript = tmp_path / "session.jsonl"
|
|
_write(transcript, *(_assistant_line(r) for r in first))
|
|
client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
|
# The transcript grows as the turn continues.
|
|
_write(transcript, *(_assistant_line(r) for r in grown))
|
|
client.post("/usage/sync", json={"transcript_path": str(transcript)})
|
|
status = client.get("/usage/status").json()
|
|
for key, value in _expected(grown).items():
|
|
assert status[key] == value
|
|
|
|
|
|
def test_missing_transcript_returns_zero_without_error(
|
|
client: TestClient, tmp_path: Path
|
|
) -> None:
|
|
resp = client.post(
|
|
"/usage/sync", json={"transcript_path": str(tmp_path / "nope.jsonl")}
|
|
)
|
|
assert resp.status_code == _OK
|
|
assert resp.json() == _expected([])
|
|
|
|
|
|
def test_malformed_lines_are_skipped(client: TestClient, tmp_path: Path) -> None:
|
|
rows: list[_UsageRow] = [(100, 20, 0, 0), (50, 10, 0, 0)]
|
|
transcript = tmp_path / "session.jsonl"
|
|
_write(
|
|
transcript,
|
|
"not json at all",
|
|
_assistant_line(rows[0]),
|
|
json.dumps({"type": "user", "message": {"role": "user"}}), # no usage
|
|
"{ broken",
|
|
_assistant_line(rows[1]),
|
|
)
|
|
body = client.post("/usage/sync", json={"transcript_path": str(transcript)}).json()
|
|
exp = _expected(rows)
|
|
assert body["tokens_input"] == exp["tokens_input"]
|
|
assert body["tokens_output"] == exp["tokens_output"]
|
|
|
|
|
|
def test_parser_handles_entries_without_message(tmp_path: Path) -> None:
|
|
rows: list[_UsageRow] = [(10, 5, 0, 0)]
|
|
transcript = tmp_path / "session.jsonl"
|
|
_write(
|
|
transcript,
|
|
json.dumps({"type": "system", "subtype": "init"}),
|
|
_assistant_line(rows[0]),
|
|
)
|
|
tin, tout, cread, cwrite = srv._sum_transcript_usage(transcript)
|
|
exp = _expected(rows)
|
|
assert (tin, tout, cread, cwrite) == (
|
|
exp["tokens_input"],
|
|
exp["tokens_output"],
|
|
exp["tokens_cache_read"],
|
|
exp["tokens_cache_write"],
|
|
)
|