Skip to content

Commit 2b99143

Browse files
authored
Merge pull request #136 from FSoft-AI4Code/feat/caw-cost-tracking
Track token usage and cost of every LLM call
2 parents 34fc7c3 + ba1b6fc commit 2b99143

12 files changed

Lines changed: 510 additions & 39 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,4 @@ Thumbs.db
9090
!tests/test_updater_change_report.py
9191
!tests/test_updater_write_guard.py
9292
!tests/test_updater_orchestrator.py
93+
!tests/test_usage_ledger.py

‎codewiki/cli/adapters/doc_generator.py‎

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def __init__(
6868
self.commit_id = commit_id
6969
self.progress_tracker = ProgressTracker(total_stages=5, verbose=verbose)
7070
self.job = DocumentationJob()
71+
self._usage_ledger = None
7172

7273
# Setup job metadata
7374
self.job.repository_path = str(repo_path)
@@ -168,6 +169,7 @@ def generate(self) -> DocumentationJob:
168169

169170
# Run backend documentation generation
170171
asyncio.run(self._run_backend_generation(backend_config))
172+
self._record_usage_statistics()
171173

172174
# Stage 4: HTML Generation (optional)
173175
if self.generate_html:
@@ -198,6 +200,7 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
198200

199201
# Create documentation generator
200202
doc_generator = DocumentationGenerator(backend_config, commit_id=self.commit_id)
203+
self._usage_ledger = doc_generator.usage_ledger
201204

202205
# Incremental update (component-level): keep the previous graph before
203206
# the builder overwrites it, so the updater can diff old against new.
@@ -309,18 +312,21 @@ async def _run_backend_generation(self, backend_config: BackendConfig):
309312
"Clustering modules with LLM...",
310313
)
311314
cluster_model = backend_config.cluster_model or None
312-
module_tree = cluster_modules(
313-
leaf_nodes,
314-
components,
315-
backend_config,
316-
completer=lambda p: doc_generator.backend.complete(p, model=cluster_model),
317-
)
318-
if module_tree:
319-
module_tree = super_group_modules(
320-
module_tree,
315+
with doc_generator.usage_ledger.in_phase("clustering"):
316+
module_tree = cluster_modules(
317+
leaf_nodes,
318+
components,
321319
backend_config,
322320
completer=lambda p: doc_generator.backend.complete(p, model=cluster_model),
323321
)
322+
if module_tree:
323+
module_tree = super_group_modules(
324+
module_tree,
325+
backend_config,
326+
completer=lambda p: doc_generator.backend.complete(
327+
p, model=cluster_model
328+
),
329+
)
324330
# Artifact nodes the clustering LLM dropped get a fixed module
325331
# so build/CI/config coverage does not depend on the LLM.
326332
if getattr(backend_config, "artifacts_enabled", True):
@@ -440,7 +446,8 @@ async def _run_incremental_update(
440446
updater = IncrementalUpdater(
441447
backend_config, doc_generator.backend, doc_generator, update_opts
442448
)
443-
record = await updater.run(prev_graph_path, components, leaf_nodes, revision)
449+
with doc_generator.usage_ledger.in_phase("update"):
450+
record = await updater.run(prev_graph_path, components, leaf_nodes, revision)
444451
self._last_update_record = record
445452
summary = record.summary()
446453
if self.verbose:
@@ -547,6 +554,29 @@ def _run_html_generation(self):
547554

548555
self.progress_tracker.complete_stage()
549556

557+
def _record_usage_statistics(self):
558+
"""Copy the run's token and cost totals from the usage ledger into the job."""
559+
if self._usage_ledger is None:
560+
return
561+
summary = self._usage_ledger.summary()
562+
totals = summary["totals"]
563+
stats = self.job.statistics
564+
stats.usage_totals = totals
565+
stats.total_tokens_used = int(
566+
sum(
567+
totals.get(k, 0)
568+
for k in (
569+
"input_tokens",
570+
"output_tokens",
571+
"cache_read_tokens",
572+
"cache_write_tokens",
573+
)
574+
)
575+
or totals.get("total_tokens", 0)
576+
)
577+
stats.total_cost_usd = totals.get("cost_usd")
578+
stats.unpriced_models = summary["unpriced_models"]
579+
550580
def _finalize_job(self):
551581
"""Finalize the job (metadata already created by backend)."""
552582
# Just verify metadata exists

‎codewiki/cli/commands/generate.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,6 +851,10 @@ def generate_command(
851851
"total_files_analyzed": job.statistics.total_files_analyzed,
852852
"generation_time": generation_time,
853853
"total_tokens_used": job.statistics.total_tokens_used,
854+
"usage_totals": job.statistics.usage_totals,
855+
"total_cost_usd": job.statistics.total_cost_usd,
856+
"unpriced_models": job.statistics.unpriced_models,
857+
"provider": getattr(config, "provider", "openai-compatible"),
854858
},
855859
)
856860

‎codewiki/cli/models/job.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ class JobStatistics:
3737
leaf_nodes: int = 0
3838
max_depth: int = 0
3939
total_tokens_used: int = 0
40+
# Summed provider usage of this run (input/output/cache tokens, cost_usd)
41+
usage_totals: Dict[str, float] = field(default_factory=dict)
42+
total_cost_usd: Optional[float] = None
43+
# Models whose calls reported tokens but no price
44+
unpriced_models: List[str] = field(default_factory=list)
4045

4146

4247
@dataclass

‎codewiki/cli/utils/instructions.py‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,52 @@ def get_pr_creation_url(repo_url: str, branch_name: str) -> str:
4545
return f"{base_url}/compare/{branch_name}"
4646

4747

48+
def _compact(n: float) -> str:
49+
"""1234567 -> '1.2M', 8900 -> '8.9k'."""
50+
for div, suffix in ((1_000_000, "M"), (1_000, "k")):
51+
if n >= div:
52+
return f"{n / div:.1f}{suffix}"
53+
return f"{int(n)}"
54+
55+
56+
def _echo_usage(statistics: dict) -> None:
57+
"""Print the run's token usage and estimated cost, if any was recorded."""
58+
tokens = statistics.get("total_tokens_used") or 0
59+
if not tokens:
60+
return
61+
totals = statistics.get("usage_totals") or {}
62+
parts = [
63+
f"{label} {_compact(totals[key])}"
64+
for label, key in (
65+
("in", "input_tokens"),
66+
("out", "output_tokens"),
67+
("cache-read", "cache_read_tokens"),
68+
("cache-write", "cache_write_tokens"),
69+
)
70+
if totals.get(key)
71+
]
72+
detail = f" ({' / '.join(parts)})" if parts else ""
73+
click.echo(f" Tokens used: {tokens:,}{detail}")
74+
75+
cost = statistics.get("total_cost_usd")
76+
provider = statistics.get("provider") or ""
77+
if cost:
78+
note = (
79+
" (API-equivalent; billed to your subscription)"
80+
if provider in ("claude-code", "codex")
81+
else ""
82+
)
83+
click.echo(f" Est. cost: ${cost:,.2f}{note}")
84+
unpriced = statistics.get("unpriced_models") or []
85+
if unpriced:
86+
click.secho(f" Cost unknown for: {', '.join(unpriced)}", fg="yellow")
87+
if provider == "codex":
88+
# Claude Code reports its own cost; codex cost comes from caw's table
89+
click.echo(
90+
" set a price: caw pricing set codex <model> --input <usd/1M> --output <usd/1M>"
91+
)
92+
93+
4894
def display_post_generation_instructions(
4995
output_dir: Path,
5096
repo_name: str,
@@ -95,9 +141,7 @@ def display_post_generation_instructions(
95141
minutes = int(statistics["generation_time"] // 60)
96142
seconds = int(statistics["generation_time"] % 60)
97143
click.echo(f" Generation time: {minutes} minutes {seconds} seconds")
98-
# if 'total_tokens_used' in statistics:
99-
# tokens = statistics['total_tokens_used']
100-
# click.echo(f" Tokens used: ~{tokens:,}")
144+
_echo_usage(statistics)
101145
click.echo()
102146

103147
# Next steps

‎codewiki/src/be/backend.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
from __future__ import annotations
1919

2020
import abc
21+
import contextlib
22+
import threading
23+
from collections.abc import Iterator
2124
from dataclasses import dataclass, field
2225
from typing import TYPE_CHECKING, Any, Dict, List
2326

@@ -65,6 +68,87 @@ def usage_to_dict(usage: Any) -> dict[str, Any] | None:
6568
return out or None
6669

6770

71+
_TOKEN_KEYS = ("input_tokens", "output_tokens", "prompt_tokens", "completion_tokens")
72+
73+
74+
@dataclass
75+
class UsageLedger:
76+
"""Every LLM call of one CodeWiki run, with its tokens and cost.
77+
78+
Calls are recorded once per CLI process / agent run, so nested caw
79+
sub-module agents (separate ``claude`` / ``codex`` processes) add up
80+
without double counting. ``add`` can be called from caw's MCP worker
81+
threads, hence the lock. ``phase`` labels the stage the calls belong to
82+
(clustering, modules, overview, ...) and ``target`` the page a
83+
``complete()`` call is for; stages run sequentially.
84+
"""
85+
86+
phase: str = "generate"
87+
target: str = ""
88+
entries: list[dict[str, Any]] = field(default_factory=list)
89+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
90+
91+
@contextlib.contextmanager
92+
def in_phase(self, name: str, target: str = "") -> Iterator[None]:
93+
previous = (self.phase, self.target)
94+
self.phase, self.target = name, target
95+
try:
96+
yield
97+
finally:
98+
self.phase, self.target = previous
99+
100+
def add(
101+
self,
102+
kind: str,
103+
target: str,
104+
usage: dict[str, Any] | None,
105+
*,
106+
seconds: float = 0.0,
107+
model: str | None = None,
108+
) -> None:
109+
entry: dict[str, Any] = {
110+
"phase": self.phase,
111+
"kind": kind,
112+
"target": target or self.target,
113+
"model": model or "",
114+
"seconds": round(seconds, 2),
115+
**(usage or {}),
116+
}
117+
with self._lock:
118+
self.entries.append(entry)
119+
120+
def summary(self) -> dict[str, Any]:
121+
"""Totals overall and per phase, plus the per-call list.
122+
123+
A call with tokens but no ``cost_usd`` is *unpriced*: its model is
124+
listed under ``unpriced_models`` so a $0 total is never mistaken for
125+
a free run.
126+
"""
127+
with self._lock:
128+
entries = [dict(e) for e in self.entries]
129+
totals: dict[str, float] = {}
130+
by_phase: dict[str, dict[str, float]] = {}
131+
unpriced: set[str] = set()
132+
for e in entries:
133+
phase_totals = by_phase.setdefault(e["phase"], {"calls": 0})
134+
phase_totals["calls"] += 1
135+
for k, v in e.items():
136+
if k == "seconds" or not isinstance(v, (int, float)) or isinstance(v, bool):
137+
continue
138+
totals[k] = totals.get(k, 0) + v
139+
phase_totals[k] = phase_totals.get(k, 0) + v
140+
if not e.get("cost_usd") and any(e.get(k) for k in _TOKEN_KEYS):
141+
unpriced.add(e["model"] or "<default>")
142+
return {
143+
"scope": "this run only (modules resumed from disk cost nothing)",
144+
"n_calls": len(entries),
145+
"totals": totals,
146+
"by_phase": by_phase,
147+
"unpriced_models": sorted(unpriced),
148+
"calls": entries,
149+
}
150+
151+
68152
CAW_PROVIDERS = frozenset({"claude-code", "codex"})
69153

70154

@@ -79,9 +163,32 @@ class LLMBackend(abc.ABC):
79163
``last_usage`` holds the token usage of the most recent ``complete`` /
80164
``run_module_agent`` / ``run_update_agent`` call when the provider exposes
81165
it (``None`` otherwise). The incremental updater reads it for its record.
166+
``usage_ledger`` accumulates every call of the run for the cost report.
82167
"""
83168

84169
last_usage: dict[str, Any] | None = None
170+
_usage_ledger: UsageLedger | None = None
171+
172+
@property
173+
def usage_ledger(self) -> UsageLedger:
174+
if self._usage_ledger is None:
175+
self._usage_ledger = UsageLedger()
176+
return self._usage_ledger
177+
178+
def _record_usage(
179+
self,
180+
kind: str,
181+
target: str,
182+
usage: Any,
183+
*,
184+
seconds: float = 0.0,
185+
model: str | None = None,
186+
) -> dict[str, Any] | None:
187+
"""Set ``last_usage`` from a provider usage object and log it in the ledger."""
188+
usage_dict = usage_to_dict(usage)
189+
self.last_usage = usage_dict
190+
self.usage_ledger.add(kind, target, usage_dict, seconds=seconds, model=model)
191+
return usage_dict
85192

86193
@abc.abstractmethod
87194
def complete(

‎codewiki/src/be/caw_backend.py‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from caw import ToolGroup
3131

3232
from codewiki.src.be.agent_tools.deps import CodeWikiDeps
33-
from codewiki.src.be.backend import AgentReply, LLMBackend, usage_to_dict
33+
from codewiki.src.be.backend import AgentReply, LLMBackend
3434
from codewiki.src.be.cluster_modules import format_potential_core_components
3535
from codewiki.src.be.dependency_analyzer.models.core import Node
3636
from codewiki.src.be.prompt_template import (
@@ -260,8 +260,15 @@ def complete(
260260
tools=ToolGroup.READER,
261261
system_prompt=system_prompt,
262262
)
263+
started = time.time()
263264
traj = agent.completion(prompt)
264-
self.last_usage = usage_to_dict(getattr(traj, "total_usage", None))
265+
self._record_usage(
266+
"complete",
267+
"",
268+
getattr(traj, "total_usage", None),
269+
seconds=time.time() - started,
270+
model=getattr(traj, "model", None) or effective_model,
271+
)
265272
return traj.result
266273

267274
# ------------------------------------------------------------------
@@ -305,12 +312,18 @@ def _run_update_agent_sync(
305312
except Exception as e:
306313
logger.error("Update agent for %s failed via caw: %s", deps.current_module_name, e)
307314
raise
308-
usage = usage_to_dict(getattr(traj, "total_usage", None))
309-
self.last_usage = usage
315+
seconds = time.time() - started
316+
usage = self._record_usage(
317+
"update_agent",
318+
deps.current_module_name,
319+
getattr(traj, "total_usage", None),
320+
seconds=seconds,
321+
model=getattr(traj, "model", None) or self._model,
322+
)
310323
return AgentReply(
311324
text=traj.result or "",
312325
usage=usage,
313-
seconds=time.time() - started,
326+
seconds=seconds,
314327
meta={"turns": traj.num_turns, "tool_calls": traj.total_tool_calls},
315328
)
316329

@@ -485,19 +498,29 @@ def _run_module_agent_sync(
485498
run_cwd = working_dir
486499
else:
487500
run_cwd = self._repo_root
501+
started = time.time()
488502
try:
489503
os.chdir(run_cwd)
490504
try:
491505
traj = agent.completion(user_prompt)
492506
finally:
493507
os.chdir(original_cwd)
508+
# Nested sub-module agents are separate CLI processes that record
509+
# their own usage, so this covers only this module's own run.
510+
usage = self._record_usage(
511+
"module_agent",
512+
module_name,
513+
getattr(traj, "total_usage", None),
514+
seconds=time.time() - started,
515+
model=getattr(traj, "model", None) or self._model,
516+
)
494517
logger.info(
495-
"Module %s completed via caw (turns=%d, tool_calls=%d)",
518+
"Module %s completed via caw (turns=%d, tool_calls=%d, cost=$%.4f)",
496519
module_name,
497520
traj.num_turns,
498521
traj.total_tool_calls,
522+
(usage or {}).get("cost_usd", 0.0),
499523
)
500-
self.last_usage = usage_to_dict(getattr(traj, "total_usage", None))
501524
file_manager.save_json(deps.module_tree, module_tree_path)
502525
return deps.module_tree
503526
except Exception as e:

0 commit comments

Comments
 (0)