Skip to content

Commit 4b235d4

Browse files
committed
fixed cost tracking of agent frameworks
1 parent 15687d9 commit 4b235d4

15 files changed

Lines changed: 606 additions & 1065 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Usage and cost tracking as a first-class collection axis alongside tracing and configuration. `Usage` and `TokenUsage` data classes record billable resource consumption (tokens, API calls, custom units). `UsageTrackableMixin` enables automatic collection via `gather_usage()`. `ModelAdapter` tracks token usage automatically after each `chat()` call with no changes required from benchmark implementers. (PR: #45)
1515
- Pluggable cost calculation via `CostCalculator` protocol. `StaticPricingCalculator` computes cost from user-supplied per-token rates (supports USD, EUR, credits, or any unit). Pass a `cost_calculator` to any `ModelAdapter` to fill in `Usage.cost` when the provider doesn't report it. Provider-reported cost always takes precedence. (PR: #45)
1616
- `LiteLLMCostCalculator` in `maseval.interface.usage` for automatic pricing via LiteLLM's bundled model database. Supports `custom_pricing` overrides and `model_id_map` for remapping adapter model IDs to LiteLLM's naming convention. Requires `litellm`. (PR: #45)
17+
- Cost calculation for agent adapters. `AgentAdapter` now accepts `cost_calculator` and `model_id` parameters. For smolagents, CAMEL, and LlamaIndex, both the model ID and cost calculator are auto-detected (model ID from the framework's agent object, calculator via `LiteLLMCostCalculator` if litellm is installed). For LangGraph, `model_id` must be passed explicitly since graphs can contain multiple models. Explicit `cost_calculator` and `model_id` always override auto-detection. (PR: #45)
1718
- `UsageReporter` post-hoc analysis utility for slicing usage data from benchmark reports by task, component, or model. Create via `UsageReporter.from_reports(benchmark.reports)`. (PR: #45)
1819
- Live usage totals accessible during benchmark execution via `benchmark.usage` (grand total) and `benchmark.usage_by_component` (per-component breakdowns). Totals persist across task repetitions. (PR: #45)
1920
- `ComponentRegistry` gains usage collection: `collect_usage()`, `total_usage`, and `usage_by_component` properties, parallel to existing trace and config collection. (PR: #45)

docs/guides/usage-tracking.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ MASEval tracks how much each benchmark run consumes (tokens, API calls, dollars)
1616

1717
**Model adapters** track every `chat()` call: input tokens, output tokens, cached tokens, reasoning tokens. No setup needed.
1818

19-
**Agent adapters** aggregate token usage from the underlying framework's execution. Each framework adapter (`SmolAgentAdapter`, `CamelAgentAdapter`, `LangGraphAgentAdapter`, `LlamaIndexAgentAdapter`) extracts usage from its framework's native data structures (memory steps, response metadata, message annotations, or execution logs respectively).
19+
**Agent adapters** aggregate token usage from the underlying framework's execution. Each framework adapter (`SmolAgentAdapter`, `CamelAgentAdapter`, `LangGraphAgentAdapter`, `LlamaIndexAgentAdapter`) extracts usage from its framework's native data structures (memory steps, response metadata, message annotations, or execution logs respectively). Cost is computed automatically when litellm is installed (see [Agent Cost Tracking](#agent-cost-tracking) below).
2020

2121
**Benchmarks** collect usage from all registered components after each task and include it in reports.
2222

@@ -55,6 +55,51 @@ print(f"{usage.input_tokens} in, {usage.output_tokens} out")
5555

5656
This works across all supported frameworks (smolagents, CAMEL, LangGraph, and LlamaIndex). The adapter handles the framework-specific extraction; you always call `gather_usage()`.
5757

58+
### Agent Cost Tracking
59+
60+
Agent adapters auto-detect cost when possible. For smolagents, CAMEL, and LlamaIndex, the adapter reads the model ID from the framework's agent object and uses `LiteLLMCostCalculator` if litellm is installed. No configuration needed:
61+
62+
```python
63+
# Cost tracking works automatically if litellm is installed
64+
adapter = SmolAgentAdapter(agent, name="researcher")
65+
adapter.run("What's the capital of France?")
66+
print(f"Cost: ${adapter.gather_usage().cost:.4f}")
67+
```
68+
69+
For **LangGraph**, the model ID cannot be auto-detected because a graph can contain multiple models across its nodes. Pass `model_id` explicitly:
70+
71+
```python
72+
adapter = LangGraphAgentAdapter(
73+
compiled_graph, "agent",
74+
model_id="gpt-4o-mini", # Required for cost tracking
75+
)
76+
```
77+
78+
To override auto-detection or use custom pricing, pass `cost_calculator` and/or `model_id`:
79+
80+
```python
81+
from maseval import StaticPricingCalculator
82+
83+
calculator = StaticPricingCalculator({
84+
"my-model": {"input": 0.001, "output": 0.002},
85+
})
86+
87+
adapter = SmolAgentAdapter(
88+
agent, name="researcher",
89+
cost_calculator=calculator,
90+
model_id="my-model",
91+
)
92+
```
93+
94+
| Framework | Model ID | Cost Calculator |
95+
|-----------|----------|-----------------|
96+
| smolagents | Auto (`agent.model.model_id`) | Auto (`LiteLLMCostCalculator`) |
97+
| CAMEL | Auto (`agent.model_backend.model_type`) | Auto (`LiteLLMCostCalculator`) |
98+
| LlamaIndex | Auto (`agent.llm.metadata.model_name`) | Auto (`LiteLLMCostCalculator`) |
99+
| LangGraph | **Manual** (`model_id=...`) | Auto (`LiteLLMCostCalculator`) |
100+
101+
If litellm is not installed, auto-creation of the calculator is skipped and cost stays at `0.0`. Tokens are always tracked regardless.
102+
58103
### In Benchmarks
59104

60105
Usage is collected automatically alongside traces and configs after each task. Each report includes a `"usage"` key:

examples/five_a_day_benchmark/five_a_day_benchmark.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@ def load_benchmark_data(
967967
def _fmt_usage(usage):
968968
parts = [f"cost=${usage.cost:.6f}"]
969969
if isinstance(usage, TokenUsage):
970-
parts.append(f"in={usage.input_tokens} out={usage.output_tokens}")
970+
parts.append(f"in={usage.input_tokens:>10,} out={usage.output_tokens:>10,}")
971971
if usage.units:
972972
parts.append(f"units={dict(usage.units)}")
973973
return " ".join(parts)

maseval/core/agent.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,56 @@
55
from .history import MessageHistory
66
from .tracing import TraceableMixin
77
from .config import ConfigurableMixin
8-
from .usage import UsageTrackableMixin
8+
from .usage import Usage, TokenUsage, UsageTrackableMixin, CostCalculator
99

1010

1111
class AgentAdapter(ABC, TraceableMixin, ConfigurableMixin, UsageTrackableMixin):
1212
"""Wraps an agent from any framework to provide a standard interface.
1313
1414
This Adapter provides:
15+
1516
- Unified execution interface via `run()`
1617
- Callback hooks for monitoring
1718
- Message history management via getter/setter
1819
- Framework-agnostic tracing
20+
- Automatic cost calculation from token usage (when a cost calculator is available)
21+
22+
Cost Tracking:
23+
Agent adapters track token usage from the underlying framework. To also
24+
compute cost, you can pass a ``cost_calculator`` and optionally a ``model_id``.
25+
26+
Most framework adapters auto-detect both the model ID (from the framework's
27+
agent object) and the cost calculator (using ``LiteLLMCostCalculator`` if
28+
litellm is installed). This means cost tracking often works with zero
29+
configuration.
30+
31+
To override or disable auto-detection, pass explicit values::
32+
33+
adapter = SmolAgentAdapter(
34+
agent, name="researcher",
35+
cost_calculator=StaticPricingCalculator({...}),
36+
model_id="my-custom-model",
37+
)
38+
39+
Pass ``cost_calculator=None`` explicitly to disable cost calculation
40+
even when auto-detection would otherwise enable it.
1941
"""
2042

21-
def __init__(self, agent_instance: Any, name: str, callbacks: Optional[List[AgentCallback]] = None):
43+
def __init__(
44+
self,
45+
agent_instance: Any,
46+
name: str,
47+
callbacks: Optional[List[AgentCallback]] = None,
48+
cost_calculator: Optional[CostCalculator] = None,
49+
model_id: Optional[str] = None,
50+
):
2251
self.agent = agent_instance
2352
self.name = name
2453
self.callbacks = callbacks or []
2554
self.messages: Optional[MessageHistory] = None
2655
self.logs: List[Dict[str, Any]] = []
56+
self._cost_calculator = cost_calculator
57+
self._model_id = model_id
2758

2859
def run(self, query: str) -> Any:
2960
"""Executes the agent and returns the result."""
@@ -105,6 +136,70 @@ def get_messages(self) -> MessageHistory:
105136
"""
106137
return self.messages if self.messages is not None else MessageHistory()
107138

139+
def gather_usage(self) -> Usage:
140+
"""Gather usage with automatic cost calculation.
141+
142+
Calls ``_gather_usage()`` for raw token counts, then applies
143+
the cost calculator if one is available and cost is still ``0.0``.
144+
145+
The ``model_id`` used for cost calculation is resolved in order:
146+
147+
1. Explicit ``model_id`` passed to ``__init__``
148+
2. Auto-detected from the framework agent via ``_resolve_model_id()``
149+
150+
Subclasses should override ``_gather_usage()`` (not this method)
151+
to provide framework-specific token extraction.
152+
153+
Returns:
154+
Usage (or TokenUsage) with cost filled in when possible.
155+
"""
156+
usage = self._gather_usage()
157+
if isinstance(usage, TokenUsage) and usage.cost == 0.0:
158+
calculator = self._resolve_cost_calculator()
159+
if calculator is not None:
160+
mid = self._model_id or self._resolve_model_id()
161+
if mid:
162+
cost = calculator.calculate_cost(usage, mid)
163+
if cost is not None:
164+
usage.cost = cost
165+
return usage
166+
167+
def _gather_usage(self) -> Usage:
168+
"""Gather raw token usage from the framework.
169+
170+
Override this in subclasses to extract token counts from the
171+
framework's native data structures.
172+
173+
Returns:
174+
Usage or TokenUsage with token counts (cost may be 0.0).
175+
"""
176+
return Usage()
177+
178+
def _resolve_model_id(self) -> Optional[str]:
179+
"""Auto-detect the model ID from the framework agent.
180+
181+
Override in subclasses to extract the model identifier from
182+
the framework's agent object (e.g., ``self.agent.model.model_id``
183+
for smolagents).
184+
185+
Returns:
186+
Model ID string, or ``None`` if not detectable.
187+
"""
188+
return None
189+
190+
def _resolve_cost_calculator(self) -> Optional[CostCalculator]:
191+
"""Resolve the cost calculator to use.
192+
193+
Returns the explicit calculator if one was provided, otherwise
194+
returns ``None``. Framework-specific subclasses can override this
195+
to auto-create a calculator (e.g., ``LiteLLMCostCalculator``)
196+
when the required dependencies are available.
197+
198+
Returns:
199+
A CostCalculator, or ``None`` if cost calculation is not available.
200+
"""
201+
return self._cost_calculator
202+
108203
def gather_traces(self) -> Dict[str, Any]:
109204
"""Gather execution traces from this agent.
110205

maseval/interface/agents/camel.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ class CamelAgentAdapter(AgentAdapter):
175175
camel-ai to be installed: `pip install maseval[camel]`
176176
"""
177177

178-
def __init__(self, agent_instance: Any, name: str, callbacks: Optional[List[Any]] = None):
178+
def __init__(
179+
self, agent_instance: Any, name: str, callbacks: Optional[List[Any]] = None, cost_calculator: Any = None, model_id: Optional[str] = None
180+
):
179181
"""Initialize the CAMEL adapter.
180182
181183
Note: We don't call super().__init__() to avoid initializing self.logs as a list,
@@ -185,11 +187,19 @@ def __init__(self, agent_instance: Any, name: str, callbacks: Optional[List[Any]
185187
agent_instance: CAMEL ChatAgent instance
186188
name: Agent name for identification
187189
callbacks: Optional list of AgentCallback instances
190+
cost_calculator: Optional cost calculator. If not provided, a
191+
``LiteLLMCostCalculator`` is created automatically when litellm
192+
is available.
193+
model_id: Optional model ID for cost calculation. If not provided,
194+
auto-detected from ``agent.model_backend.model_type``.
188195
"""
189196
self.agent = agent_instance
190197
self.name = name
191198
self.callbacks = callbacks or []
192199
self.messages = None
200+
self._cost_calculator = cost_calculator
201+
self._model_id = model_id
202+
self._auto_calculator = None # Lazy-initialized
193203
# Store responses from each step() call
194204
self._responses: List[Any] = []
195205
# Store errors that occur during execution (for comprehensive logging)
@@ -419,7 +429,36 @@ def gather_traces(self) -> Dict[str, Any]:
419429

420430
return base_traces
421431

422-
def gather_usage(self) -> Usage:
432+
def _resolve_model_id(self):
433+
"""Auto-detect model ID from CAMEL agent.
434+
435+
CAMEL's ChatAgent stores the model backend in ``model_backend``
436+
(or ``model`` for older versions). The backend has a ``model_type``
437+
enum whose ``.value`` is the model ID string.
438+
"""
439+
try:
440+
backend = getattr(self.agent, "model_backend", None) or getattr(self.agent, "model", None)
441+
if backend is not None and hasattr(backend, "model_type"):
442+
model_type = backend.model_type
443+
return model_type.value if hasattr(model_type, "value") else str(model_type)
444+
except Exception:
445+
pass
446+
return None
447+
448+
def _resolve_cost_calculator(self):
449+
"""Return the cost calculator, auto-creating one if litellm is available."""
450+
if self._cost_calculator is not None:
451+
return self._cost_calculator
452+
if self._auto_calculator is None:
453+
try:
454+
from maseval.interface.usage import LiteLLMCostCalculator
455+
456+
self._auto_calculator = LiteLLMCostCalculator()
457+
except (ImportError, Exception):
458+
self._auto_calculator = False
459+
return self._auto_calculator if self._auto_calculator is not False else None
460+
461+
def _gather_usage(self) -> Usage:
423462
"""Gather aggregated token usage across all CAMEL agent responses.
424463
425464
Walks stored ``ChatAgentResponse`` objects and sums their

maseval/interface/agents/langgraph.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,39 @@ def chatbot(state: MessagesState):
117117
langgraph to be installed: `pip install maseval[langgraph]`
118118
"""
119119

120-
def __init__(self, agent_instance: Any, name: str, callbacks: Optional[List[Any]] = None, config: Optional[Dict[str, Any]] = None):
120+
def __init__(
121+
self,
122+
agent_instance: Any,
123+
name: str,
124+
callbacks: Optional[List[Any]] = None,
125+
config: Optional[Dict[str, Any]] = None,
126+
cost_calculator: Any = None,
127+
model_id: Optional[str] = None,
128+
):
121129
"""Initialize the LangGraph adapter.
122130
123131
Args:
124132
agent_instance: Compiled LangGraph graph
125133
name: Agent name
126134
callbacks: Optional list of callbacks
127-
config: Optional LangGraph config dict (for stateful graphs with checkpointer)
128-
Should include 'configurable': {'thread_id': '...'} for persistent state
135+
config: Optional LangGraph config dict (for stateful graphs with checkpointer).
136+
Should include ``'configurable': {'thread_id': '...'}`` for persistent state.
137+
cost_calculator: Optional cost calculator. If not provided, a
138+
``LiteLLMCostCalculator`` is created automatically when litellm
139+
is available.
140+
model_id: Model ID for cost calculation. LangGraph graphs can contain
141+
multiple models across nodes, so the model ID cannot be auto-detected.
142+
Pass the primary model's ID here to enable cost tracking::
143+
144+
LangGraphAgentAdapter(
145+
graph, "agent",
146+
model_id="gpt-4o-mini",
147+
)
129148
"""
130-
super().__init__(agent_instance, name, callbacks)
149+
super().__init__(agent_instance, name, callbacks, cost_calculator=cost_calculator, model_id=model_id)
131150
self._langgraph_config = config
132151
self._last_result = None
152+
self._auto_calculator = None # Lazy-initialized
133153

134154
def get_messages(self) -> MessageHistory:
135155
"""Get message history from LangGraph.
@@ -214,7 +234,20 @@ def gather_config(self) -> dict[str, Any]:
214234

215235
return base_config
216236

217-
def gather_usage(self) -> Usage:
237+
def _resolve_cost_calculator(self):
238+
"""Return the cost calculator, auto-creating one if litellm is available."""
239+
if self._cost_calculator is not None:
240+
return self._cost_calculator
241+
if self._auto_calculator is None:
242+
try:
243+
from maseval.interface.usage import LiteLLMCostCalculator
244+
245+
self._auto_calculator = LiteLLMCostCalculator()
246+
except (ImportError, Exception):
247+
self._auto_calculator = False
248+
return self._auto_calculator if self._auto_calculator is not False else None
249+
250+
def _gather_usage(self) -> Usage:
218251
"""Gather aggregated token usage from LangGraph message metadata.
219252
220253
Walks messages from the last graph execution (or persistent state)

0 commit comments

Comments
 (0)