Skip to content

Commit f65a79c

Browse files
feat: add pricing to html reports
1 parent a15e48c commit f65a79c

12 files changed

Lines changed: 366 additions & 3 deletions

File tree

docs/05_bring_your_own_model_provider.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,49 @@ class MyImageQAProvider(ImageQAProvider):
135135
```
136136

137137

138+
### Execution Cost Tracking
139+
140+
If you want execution cost tracking in your reports, override the `pricing` property on your custom `VlmProvider`:
141+
142+
```python
143+
from typing import Any
144+
from typing_extensions import override
145+
from askui.model_providers import VlmProvider, ModelPricing
146+
from askui.models.shared.agent_message_param import MessageParam, ThinkingConfigParam, ToolChoiceParam
147+
from askui.models.shared.prompts import SystemPrompt
148+
from askui.models.shared.tools import ToolCollection
149+
150+
151+
class MyVlmProvider(VlmProvider):
152+
@property
153+
def model_id(self) -> str:
154+
return "my-model-v1"
155+
156+
@property
157+
@override
158+
def pricing(self) -> ModelPricing | None:
159+
return ModelPricing(
160+
input_cost_per_million_tokens=1.0,
161+
output_cost_per_million_tokens=5.0,
162+
)
163+
164+
@override
165+
def create_message(
166+
self,
167+
messages: list[MessageParam],
168+
tools: ToolCollection | None = None,
169+
max_tokens: int | None = None,
170+
system: SystemPrompt | None = None,
171+
thinking: ThinkingConfigParam | None = None,
172+
tool_choice: ToolChoiceParam | None = None,
173+
temperature: float | None = None,
174+
provider_options: dict[str, Any] | None = None,
175+
) -> MessageParam:
176+
... # call your API here
177+
```
178+
179+
---
180+
138181
## Advanced: Injecting a Custom Client
139182

140183
For full control over HTTP settings (timeouts, proxies, retries), you can inject a pre-configured client:

docs/08_reporting.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,35 @@ This generates an HTML file (typically in the current directory) showing:
3232
SimpleHtmlReporter(output_dir="./execution_reports", filename="agent_run.html")
3333
```
3434

35+
### Execution Cost Tracking
36+
37+
The HTML report automatically shows the estimated API cost when using a `VlmProvider` with pricing information. The built-in Anthropic and AskUI providers include default pricing for supported Claude models.
38+
39+
To override pricing (for example, if you have a custom pricing agreement):
40+
41+
```python
42+
from askui import AgentSettings, ComputerAgent
43+
from askui.model_providers import AnthropicVlmProvider
44+
from askui.reporting import SimpleHtmlReporter
45+
46+
with ComputerAgent(
47+
reporters=[SimpleHtmlReporter()],
48+
settings=AgentSettings(
49+
vlm_provider=AnthropicVlmProvider(
50+
model_id="claude-sonnet-4-6",
51+
input_cost_per_million_tokens=2.5,
52+
output_cost_per_million_tokens=12.0,
53+
),
54+
),
55+
) as agent:
56+
agent.act("Open settings")
57+
```
58+
59+
The report will display:
60+
- Total estimated cost
61+
- Per-token rates used for the calculation
62+
- Input and output token breakdowns (as before)
63+
3564
### Custom Reporters
3665

3766
Create custom reporters by implementing the `Reporter` interface:

src/askui/agent_base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,12 @@ def __init__(
7575
# Create conversation with speakers and model providers
7676
speakers = Speakers()
7777
_callbacks = list(callbacks or [])
78-
_callbacks.append(UsageTrackingCallback(reporter=self._reporter))
78+
_callbacks.append(
79+
UsageTrackingCallback(
80+
reporter=self._reporter,
81+
pricing=self._vlm_provider.pricing,
82+
)
83+
)
7984
self._conversation = Conversation(
8085
speakers=speakers,
8186
vlm_provider=self._vlm_provider,

src/askui/model_providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from askui.model_providers.google_image_qa_provider import GoogleImageQAProvider
2424
from askui.model_providers.image_qa_provider import ImageQAProvider
2525
from askui.model_providers.vlm_provider import VlmProvider
26+
from askui.utils.model_pricing import ModelPricing
2627

2728
__all__ = [
2829
"AnthropicImageQAProvider",
@@ -33,5 +34,6 @@
3334
"DetectionProvider",
3435
"GoogleImageQAProvider",
3536
"ImageQAProvider",
37+
"ModelPricing",
3638
"VlmProvider",
3739
]

src/askui/model_providers/anthropic_vlm_provider.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from askui.models.shared.prompts import SystemPrompt
1818
from askui.models.shared.tools import ToolCollection
19+
from askui.utils.model_pricing import ModelPricing, resolve_default_pricing
1920

2021
_DEFAULT_MODEL_ID = "claude-sonnet-4-6"
2122

@@ -38,6 +39,11 @@ class AnthropicVlmProvider(VlmProvider):
3839
`\"claude-sonnet-4-6\"`.
3940
client (Anthropic | None, optional): Pre-configured Anthropic client.
4041
If provided, other connection parameters are ignored.
42+
input_cost_per_million_tokens (float | None, optional): Override
43+
cost in USD per 1M input tokens. Both cost params must be set
44+
to override the built-in defaults.
45+
output_cost_per_million_tokens (float | None, optional): Override
46+
cost in USD per 1M output tokens.
4147
4248
Example:
4349
```python
@@ -60,6 +66,8 @@ def __init__(
6066
auth_token: str | None = None,
6167
model_id: str | None = None,
6268
client: Anthropic | None = None,
69+
input_cost_per_million_tokens: float | None = None,
70+
output_cost_per_million_tokens: float | None = None,
6371
) -> None:
6472
self._model_id_value = (
6573
model_id or os.environ.get("VLM_PROVIDER_MODEL_ID") or _DEFAULT_MODEL_ID
@@ -72,12 +80,28 @@ def __init__(
7280
base_url=base_url,
7381
auth_token=auth_token,
7482
)
83+
self._pricing: ModelPricing | None
84+
if (
85+
input_cost_per_million_tokens is not None
86+
and output_cost_per_million_tokens is not None
87+
):
88+
self._pricing = ModelPricing(
89+
input_cost_per_million_tokens=input_cost_per_million_tokens,
90+
output_cost_per_million_tokens=output_cost_per_million_tokens,
91+
)
92+
else:
93+
self._pricing = resolve_default_pricing(self._model_id_value)
7594

7695
@property
7796
@override
7897
def model_id(self) -> str:
7998
return self._model_id_value
8099

100+
@property
101+
@override
102+
def pricing(self) -> ModelPricing | None:
103+
return self._pricing
104+
81105
@cached_property
82106
def _messages_api(self) -> AnthropicMessagesApi:
83107
"""Lazily initialise the AnthropicMessagesApi on first use."""

src/askui/model_providers/askui_vlm_provider.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from askui.models.shared.prompts import SystemPrompt
1919
from askui.models.shared.tools import ToolCollection
20+
from askui.utils.model_pricing import ModelPricing, resolve_default_pricing
2021

2122
_DEFAULT_MODEL_ID = "claude-sonnet-4-6"
2223

@@ -37,6 +38,11 @@ class AskUIVlmProvider(VlmProvider):
3738
`"claude-sonnet-4-6"`.
3839
client (Anthropic | None, optional): Pre-configured Anthropic client.
3940
If provided, `workspace_id` and `token` are ignored.
41+
input_cost_per_million_tokens (float | None, optional): Override
42+
cost in USD per 1M input tokens. Both cost params must be set
43+
to override the built-in defaults.
44+
output_cost_per_million_tokens (float | None, optional): Override
45+
cost in USD per 1M output tokens.
4046
4147
Example:
4248
```python
@@ -58,18 +64,36 @@ def __init__(
5864
askui_settings: AskUiInferenceApiSettings | None = None,
5965
model_id: str | None = None,
6066
client: Anthropic | None = None,
67+
input_cost_per_million_tokens: float | None = None,
68+
output_cost_per_million_tokens: float | None = None,
6169
) -> None:
6270
self._askui_settings = askui_settings or AskUiInferenceApiSettings()
6371
self._model_id_value = (
6472
model_id or os.environ.get("VLM_PROVIDER_MODEL_ID") or _DEFAULT_MODEL_ID
6573
)
6674
self._injected_client = client
75+
self._pricing: ModelPricing | None
76+
if (
77+
input_cost_per_million_tokens is not None
78+
and output_cost_per_million_tokens is not None
79+
):
80+
self._pricing = ModelPricing(
81+
input_cost_per_million_tokens=input_cost_per_million_tokens,
82+
output_cost_per_million_tokens=output_cost_per_million_tokens,
83+
)
84+
else:
85+
self._pricing = resolve_default_pricing(self._model_id_value)
6786

6887
@property
6988
@override
7089
def model_id(self) -> str:
7190
return self._model_id_value
7291

92+
@property
93+
@override
94+
def pricing(self) -> ModelPricing | None:
95+
return self._pricing
96+
7397
@cached_property
7498
def _messages_api(self) -> AnthropicMessagesApi:
7599
"""Lazily initialise the AnthropicMessagesApi on first use."""

src/askui/model_providers/vlm_provider.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
)
1111
from askui.models.shared.prompts import SystemPrompt
1212
from askui.models.shared.tools import ToolCollection
13+
from askui.utils.model_pricing import ModelPricing
1314

1415

1516
class VlmProvider(ABC):
@@ -43,6 +44,15 @@ class VlmProvider(ABC):
4344
def model_id(self) -> str:
4445
"""The model identifier used by this provider."""
4546

47+
@property
48+
def pricing(self) -> ModelPricing | None:
49+
"""Pricing information for this provider's model.
50+
51+
Returns ``None`` if no pricing information is available.
52+
Override in subclasses to provide model-specific pricing.
53+
"""
54+
return None
55+
4656
@abstractmethod
4757
def create_message(
4858
self,

src/askui/models/shared/usage_tracking_callback.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,25 @@
1414
if TYPE_CHECKING:
1515
from askui.models.shared.conversation import Conversation
1616
from askui.speaker.speaker import SpeakerResult
17+
from askui.utils.model_pricing import ModelPricing
1718

1819

1920
class UsageTrackingCallback(ConversationCallback):
2021
"""Tracks token usage per step and reports a summary at conversation end.
2122
2223
Args:
2324
reporter: Reporter to write the final usage summary to.
25+
pricing: Pricing information for cost calculation. If ``None``,
26+
no cost data is included in the usage summary.
2427
"""
2528

26-
def __init__(self, reporter: Reporter = NULL_REPORTER) -> None:
29+
def __init__(
30+
self,
31+
reporter: Reporter = NULL_REPORTER,
32+
pricing: ModelPricing | None = None,
33+
) -> None:
2734
self._reporter = reporter
35+
self._pricing = pricing
2836
self._accumulated_usage = UsageParam()
2937

3038
@override
@@ -43,7 +51,27 @@ def on_step_end(
4351

4452
@override
4553
def on_conversation_end(self, conversation: Conversation) -> None:
46-
self._reporter.add_usage_summary(self._accumulated_usage.model_dump())
54+
usage_dict = self._accumulated_usage.model_dump()
55+
if self._pricing is not None:
56+
input_tokens = self._accumulated_usage.input_tokens or 0
57+
output_tokens = self._accumulated_usage.output_tokens or 0
58+
input_cost = (
59+
input_tokens * self._pricing.input_cost_per_million_tokens / 1_000_000
60+
)
61+
output_cost = (
62+
output_tokens * self._pricing.output_cost_per_million_tokens / 1_000_000
63+
)
64+
usage_dict["input_cost"] = input_cost
65+
usage_dict["output_cost"] = output_cost
66+
usage_dict["total_cost"] = input_cost + output_cost
67+
usage_dict["currency"] = self._pricing.currency
68+
usage_dict["input_cost_per_million_tokens"] = (
69+
self._pricing.input_cost_per_million_tokens
70+
)
71+
usage_dict["output_cost_per_million_tokens"] = (
72+
self._pricing.output_cost_per_million_tokens
73+
)
74+
self._reporter.add_usage_summary(usage_dict)
4775

4876
@property
4977
def accumulated_usage(self) -> UsageParam:

src/askui/reporting.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,18 @@ def generate(self) -> None:
824824
</td>
825825
</tr>
826826
{% endif %}
827+
{% if usage_summary.get('total_cost') is not none %}
828+
<tr>
829+
<th>Estimated Cost</th>
830+
<td>
831+
{{ "%.2f"|format(usage_summary.get('total_cost')) }} {{ usage_summary.get('currency', 'USD') }}
832+
<span style="color: var(--text-muted); margin-left: 8px; font-size: 0.85em;">
833+
(Input: ${{ "%.2f"|format(usage_summary.get('input_cost_per_million_tokens', 0)) }}/1M tokens,
834+
Output: ${{ "%.2f"|format(usage_summary.get('output_cost_per_million_tokens', 0)) }}/1M tokens)
835+
</span>
836+
</td>
837+
</tr>
838+
{% endif %}
827839
{% endif %}
828840
{% if cache_original_usage is not none %}
829841
{% if cache_original_usage.get('input_tokens') is not none %}

src/askui/utils/model_pricing.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Pricing information for model API calls."""
2+
3+
from pydantic import BaseModel
4+
5+
6+
class ModelPricing(BaseModel):
7+
"""Cost per 1 million tokens for a model.
8+
9+
Args:
10+
input_cost_per_million_tokens (float): Cost in USD per 1M input tokens.
11+
output_cost_per_million_tokens (float): Cost in USD per 1M output tokens.
12+
currency (str): ISO 4217 currency code. Defaults to ``"USD"``.
13+
"""
14+
15+
input_cost_per_million_tokens: float
16+
output_cost_per_million_tokens: float
17+
currency: str = "USD"
18+
19+
20+
_DEFAULT_PRICING: dict[str, ModelPricing] = {
21+
"claude-haiku-4-5-20251001": ModelPricing(
22+
input_cost_per_million_tokens=1.0,
23+
output_cost_per_million_tokens=5.0,
24+
),
25+
"claude-sonnet-4-5-20250929": ModelPricing(
26+
input_cost_per_million_tokens=3.0,
27+
output_cost_per_million_tokens=15.0,
28+
),
29+
"claude-opus-4-5-20251101": ModelPricing(
30+
input_cost_per_million_tokens=5.0,
31+
output_cost_per_million_tokens=25.0,
32+
),
33+
"claude-sonnet-4-6": ModelPricing(
34+
input_cost_per_million_tokens=3.0,
35+
output_cost_per_million_tokens=15.0,
36+
),
37+
"claude-opus-4-6": ModelPricing(
38+
input_cost_per_million_tokens=5.0,
39+
output_cost_per_million_tokens=25.0,
40+
),
41+
}
42+
43+
44+
def resolve_default_pricing(model_id: str) -> ModelPricing | None:
45+
"""Resolve default pricing for a model ID by prefix matching.
46+
47+
Tries exact match first, then longest-prefix match.
48+
49+
Args:
50+
model_id (str): The model identifier.
51+
52+
Returns:
53+
ModelPricing | None: Default pricing, or ``None`` if no match found.
54+
"""
55+
if model_id in _DEFAULT_PRICING:
56+
return _DEFAULT_PRICING[model_id]
57+
return None

0 commit comments

Comments
 (0)