mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JThjboXTeZ3yJPBzxMx5wS
208 lines
8.0 KiB
Python
208 lines
8.0 KiB
Python
"""Collect the detail rows (calls, marks, turn facts) during a file walk.
|
|
|
|
The walker feeds every record, tool call, tool result and priced turn here;
|
|
:meth:`Detail.finish` settles creation and carry cost on each row. Marks come
|
|
from what Claude Code writes next to the conversation (see :mod:`marks`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from python_pkg.token_audit import parse, records
|
|
from python_pkg.token_audit.pricing import WRITE_1H, WRITE_5M
|
|
from python_pkg.token_dashboard import blocks, carry, classify, hook_join, marks
|
|
from python_pkg.token_dashboard.detail_rows import (
|
|
CallRow,
|
|
DetailRows,
|
|
MarkRow,
|
|
TurnDetail,
|
|
Where,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from python_pkg.token_audit.model import Turn
|
|
from python_pkg.token_audit.pricing import PriceBook
|
|
|
|
CHARS = parse.CHARS_PER_TOKEN
|
|
|
|
|
|
def write_multiplier(usage: dict[str, int]) -> float:
|
|
"""Average cache-write price of one call, as a multiple of input."""
|
|
total = usage.get("cache_creation_input_tokens", 0)
|
|
if not total:
|
|
return WRITE_5M
|
|
one_hour = min(usage.get(records.CACHE_1H, 0), total)
|
|
return (one_hour * WRITE_1H + (total - one_hour) * WRITE_5M) / total
|
|
|
|
|
|
def _message(rec: dict[str, Any]) -> dict[str, Any]:
|
|
"""A record's ``message`` dict, empty when absent."""
|
|
message = rec.get("message")
|
|
return message if isinstance(message, dict) else {}
|
|
|
|
|
|
class _Output:
|
|
"""Visible output of one API message, accumulated over its records."""
|
|
|
|
def __init__(self, latency: float) -> None:
|
|
"""Start empty; ``latency`` is the gap before the first record."""
|
|
self.latency = latency
|
|
self.text = 0
|
|
self.tool = 0
|
|
self.thinking = False
|
|
|
|
def add(self, block: dict[str, Any]) -> None:
|
|
"""Count one content block."""
|
|
kind = block.get("type")
|
|
if kind == "text":
|
|
self.text += len(str(block.get("text") or ""))
|
|
elif kind == "tool_use":
|
|
self.tool += len(json.dumps(block.get("input") or {}))
|
|
elif kind in {"thinking", "redacted_thinking"}:
|
|
self.thinking = True
|
|
|
|
def split(self, output: int) -> tuple[int, int, int]:
|
|
"""``(text, tool, thinking)`` tokens summing to ``output``.
|
|
|
|
Visible blocks are estimated by length; billed output left over is
|
|
thinking (whose text Claude Code does not keep). With no thinking
|
|
block, or more visible text than output, the visible estimates are
|
|
scaled to the billed total instead.
|
|
"""
|
|
text, tool = self.text // CHARS, self.tool // CHARS
|
|
visible = text + tool
|
|
if not visible:
|
|
return (0, 0, output) if self.thinking else (output, 0, 0)
|
|
if visible > output or not self.thinking:
|
|
text = round(output * text / visible)
|
|
return text, output - text, 0
|
|
return text, tool, output - visible
|
|
|
|
|
|
class Detail:
|
|
"""Detail state for one file."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Nothing seen yet."""
|
|
self.rows = DetailRows()
|
|
self.tracker = carry.Tracker()
|
|
self.calls: dict[str, CallRow] = {}
|
|
self.outputs: dict[str, _Output] = {}
|
|
self.last_ts = 0.0
|
|
self.model = ""
|
|
self.hooks = hook_join.Hooks()
|
|
|
|
def mark(self, found: marks.Found, ts: float, prompt: int) -> MarkRow:
|
|
"""Turn a found mark into a row; carry it if it sits in context."""
|
|
row = MarkRow(
|
|
Where(prompt, ts, self.model),
|
|
found.kind,
|
|
found.name,
|
|
detail=found.detail,
|
|
count=found.count,
|
|
tokens=found.tokens,
|
|
extra=found.extra,
|
|
duration_ms=found.duration_ms,
|
|
)
|
|
self.rows.marks.append(row)
|
|
if row.tokens and found.kind not in marks.NOT_IN_CONTEXT:
|
|
self.tracker.result(row.cost, row.tokens)
|
|
self.hooks.track(row, found)
|
|
return row
|
|
|
|
def record(self, rec: dict[str, Any], ts: float, prompt: int) -> None:
|
|
"""Account one record (after the walker opened any new prompt)."""
|
|
kind = rec.get("type")
|
|
if kind == "assistant":
|
|
self.assistant(rec, ts, prompt)
|
|
elif kind == "user" and blocks.prompt_text(rec) is not None:
|
|
self.hooks.prompt_opened()
|
|
self.last_ts = ts
|
|
for found in marks.of_record(rec):
|
|
self.mark(found, ts, prompt)
|
|
|
|
def assistant(self, rec: dict[str, Any], ts: float, prompt: int) -> None:
|
|
"""Error placeholders become marks; real messages add visible output."""
|
|
message = _message(rec)
|
|
if rec.get("isApiErrorMessage"):
|
|
text = blocks.text_of(message.get("content"))
|
|
detail = str(rec.get("apiError") or text)[: classify.TARGET_CHARS]
|
|
name = str(rec.get("error") or "unknown")
|
|
self.mark(marks.Found("api_error", name, detail=detail), ts, prompt)
|
|
return
|
|
mid = str(message.get("id") or "")
|
|
if mid not in self.outputs:
|
|
self.outputs[mid] = _Output(max(0.0, ts - (self.last_ts or ts)))
|
|
for block in parse.content_blocks(rec):
|
|
self.outputs[mid].add(block)
|
|
|
|
def use(
|
|
self, rec: dict[str, Any], block: dict[str, Any], ts: float, prompt: int
|
|
) -> None:
|
|
"""Open a call row for a ``tool_use`` block."""
|
|
message = _message(rec)
|
|
raw = block.get("input")
|
|
tool_input: dict[str, object] = raw if isinstance(raw, dict) else {}
|
|
name = str(block.get("name") or "unknown")
|
|
row = CallRow(
|
|
Where(prompt, ts, str(message.get("model") or self.model)),
|
|
name=name,
|
|
family=classify.family(name, tool_input),
|
|
target=classify.target(name, tool_input),
|
|
sig=classify.signature(name, tool_input),
|
|
message_id=str(message.get("id") or ""),
|
|
)
|
|
self.calls[str(block.get("id") or "")] = row
|
|
self.rows.calls.append(row)
|
|
|
|
def result(self, block: dict[str, Any], ts: float) -> None:
|
|
"""Close the call a ``tool_result`` answers."""
|
|
row = self.calls.pop(str(block.get("tool_use_id") or ""), None)
|
|
if row is None:
|
|
return
|
|
content = block.get("content")
|
|
images, text = marks.split_images(content)
|
|
row.end_ts = ts
|
|
row.images = images
|
|
row.result_tokens = images * parse.IMAGE_TOKENS + len(text) // CHARS
|
|
if block.get("is_error"):
|
|
row.error_kind = classify.error_kind(blocks.text_of(content))
|
|
self.tracker.result(row.cost, row.result_tokens)
|
|
|
|
def turn(self, turn: Turn, cost: float, prompt: int) -> None:
|
|
"""Note a priced API call."""
|
|
usage = turn.usage
|
|
self.model = turn.model
|
|
self.tracker.turn(turn.model, turn.context, write_multiplier(usage))
|
|
self.hooks.turn(cost, turn.timestamp)
|
|
output = self.outputs.pop(turn.message_id, None) or _Output(0.0)
|
|
text, tool, think = output.split(usage.get("output_tokens", 0))
|
|
one_hour = usage.get(records.CACHE_1H, 0)
|
|
self.rows.turns.append(
|
|
TurnDetail(
|
|
Where(prompt, turn.timestamp, turn.model),
|
|
latency_s=output.latency,
|
|
cw_1h=one_hour,
|
|
cw_5m=usage.get("cache_creation_input_tokens", 0) - one_hour,
|
|
out_text=text,
|
|
out_tool=tool,
|
|
out_think=think,
|
|
tool_calls=turn.tool_calls,
|
|
)
|
|
)
|
|
|
|
def finish(self, book: PriceBook) -> DetailRows:
|
|
"""Settle costs, fill batch sizes and models, merge repeated marks."""
|
|
self.tracker.settle(book)
|
|
per_message: dict[str, int] = {}
|
|
for call in self.rows.calls:
|
|
per_message[call.message_id] = per_message.get(call.message_id, 0) + 1
|
|
for call in self.rows.calls:
|
|
call.batch = per_message[call.message_id]
|
|
first_model = self.rows.turns[0].where.model if self.rows.turns else ""
|
|
self.hooks.resolve()
|
|
self.rows.marks = hook_join.merge(self.rows.marks, first_model)
|
|
return self.rows
|