Skip to content

Commit 25a9627

Browse files
Add experiment-level cost tracking with token-based estimation (#23)
Harbor's AgentContext.cost_usd is almost always null, so the dashboard and experiment views have been showing no cost information at all. This adds a static LiteLLM-sourced pricing table (oddish/model_pricing.py) that estimates trial cost from the captured input / cache / output token counts when no native cost is reported. The trial response now carries a cost_is_estimated flag and the experiment summary bar aggregates cost across all trials in an experiment, marking mixed native + estimated totals with a trailing '*' and pure-estimate totals with a leading '~'. Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 6175dea commit 25a9627

7 files changed

Lines changed: 315 additions & 6 deletions

File tree

frontend/src/components/experiment-detail-view.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ type ExperimentSummary = {
8080
failCount: number;
8181
harnessErrorCount: number;
8282
pendingCount: number;
83+
costUsd: number;
84+
costTrialCount: number;
85+
costHasEstimated: boolean;
86+
costHasNative: boolean;
8387
};
8488

8589
function buildExperimentSummary(tasksForExperiment: Task[]): ExperimentSummary {
@@ -96,11 +100,25 @@ function buildExperimentSummary(tasksForExperiment: Task[]): ExperimentSummary {
96100
let harnessErrorCount = 0;
97101
let pendingCount = 0;
98102

103+
let costUsd = 0;
104+
let costTrialCount = 0;
105+
let costHasEstimated = false;
106+
let costHasNative = false;
107+
99108
for (const task of tasksForExperiment) {
100109
const trials = task.trials ?? [];
101110
if (trials.length > 0) {
102111
// Compute from the (already version-filtered) trials array
103112
for (const trial of trials) {
113+
if (trial.cost_usd != null) {
114+
costUsd += trial.cost_usd;
115+
costTrialCount += 1;
116+
if (trial.cost_is_estimated === true) {
117+
costHasEstimated = true;
118+
} else {
119+
costHasNative = true;
120+
}
121+
}
104122
if (trial.status === "success" && trial.reward != null) {
105123
rewardSum += trial.reward;
106124
rewardTotal++;
@@ -146,9 +164,21 @@ function buildExperimentSummary(tasksForExperiment: Task[]): ExperimentSummary {
146164
failCount,
147165
harnessErrorCount,
148166
pendingCount,
167+
costUsd,
168+
costTrialCount,
169+
costHasEstimated,
170+
costHasNative,
149171
};
150172
}
151173

174+
function formatCostUsd(value: number): string {
175+
if (!Number.isFinite(value) || value <= 0) return "$0.00";
176+
if (value < 0.01) return `$${value.toFixed(4)}`;
177+
if (value < 1) return `$${value.toFixed(3)}`;
178+
if (value < 100) return `$${value.toFixed(2)}`;
179+
return `$${value.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
180+
}
181+
152182
function ExperimentHeaderMeta({
153183
isLoading,
154184
isInitialLoading,
@@ -229,6 +259,34 @@ function ExperimentSummaryBar({
229259
: "—"}
230260
</div>
231261
<div className="text-muted-foreground"></div>
262+
<div
263+
className="font-mono text-muted-foreground"
264+
title={
265+
summary.costTrialCount > 0
266+
? `Summed across ${summary.costTrialCount} trial${
267+
summary.costTrialCount === 1 ? "" : "s"
268+
}${
269+
summary.costHasEstimated && summary.costHasNative
270+
? ". Mixed native + estimated values; ~ marks estimates."
271+
: summary.costHasEstimated
272+
? ". Estimated from token counts × static model pricing."
273+
: ". Reported by the agent runtime."
274+
}`
275+
: "No cost data reported yet"
276+
}
277+
>
278+
Cost{" "}
279+
{summary.costTrialCount > 0 ? (
280+
<>
281+
{summary.costHasEstimated && !summary.costHasNative ? "~" : ""}
282+
{formatCostUsd(summary.costUsd)}
283+
{summary.costHasEstimated && summary.costHasNative ? "*" : ""}
284+
</>
285+
) : (
286+
"—"
287+
)}
288+
</div>
289+
<div className="text-muted-foreground"></div>
232290
<div className="flex items-center gap-2 font-mono text-muted-foreground">
233291
<span className="inline-flex items-center gap-0.5 text-emerald-400">
234292
{summary.passCount}

frontend/src/lib/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export interface Trial {
7171
queue_info?: TrialQueueInfo | null;
7272
task_version?: number | null;
7373
task_version_id?: string | null;
74+
input_tokens?: number | null;
75+
cache_tokens?: number | null;
76+
output_tokens?: number | null;
77+
cost_usd?: number | null;
78+
cost_is_estimated?: boolean | null;
7479
created_at: string;
7580
started_at?: string | null;
7681
finished_at?: string | null;

oddish/src/oddish/core/endpoints.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ async def list_tasks_core(
124124
TrialModel.has_trajectory,
125125
TrialModel.phase_timing,
126126
TrialModel.analysis_status,
127+
TrialModel.input_tokens,
128+
TrialModel.cache_tokens,
129+
TrialModel.output_tokens,
130+
TrialModel.cost_usd,
127131
TrialModel.created_at,
128132
TrialModel.started_at,
129133
TrialModel.finished_at,

oddish/src/oddish/core/helpers.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,33 @@
1919
TrialModel,
2020
TrialStatus,
2121
)
22+
from oddish.model_pricing import estimate_cost_usd
2223
from oddish.schemas import TaskStatusResponse, TrialQueueInfo, TrialResponse
2324

25+
26+
def _resolve_trial_cost(
27+
trial: TrialModel, model_name: str | None
28+
) -> tuple[float | None, bool | None]:
29+
"""Return ``(cost_usd, cost_is_estimated)`` for a trial.
30+
31+
Prefers the native cost reported by the agent runtime. Falls back to
32+
estimating from the pricing table when native cost is missing but we
33+
have token counts and a known model.
34+
"""
35+
if trial.cost_usd is not None:
36+
return float(trial.cost_usd), False
37+
if trial.input_tokens is None and trial.output_tokens is None:
38+
return None, None
39+
estimated = estimate_cost_usd(
40+
model_name or trial.model,
41+
trial.input_tokens,
42+
trial.output_tokens,
43+
trial.cache_tokens,
44+
)
45+
if estimated is None:
46+
return None, None
47+
return estimated, True
48+
2449
_ANALYSIS_SUMMARY_UNSET = object()
2550
_VERSION_ID_UNSET: object = object()
2651
_QUEUE_PENDING_STATUSES = {TrialStatus.QUEUED, TrialStatus.RETRYING}
@@ -204,6 +229,7 @@ def build_trial_response(
204229
"""Build a TrialResponse from a TrialModel."""
205230
normalized_model = settings.normalize_trial_model(trial.agent, trial.model)
206231
task_version, task_version_id = _resolve_trial_version_fields(trial)
232+
cost_usd, cost_is_estimated = _resolve_trial_cost(trial, normalized_model)
207233
return TrialResponse(
208234
id=trial.id,
209235
name=trial.name,
@@ -227,7 +253,8 @@ def build_trial_response(
227253
input_tokens=trial.input_tokens,
228254
cache_tokens=trial.cache_tokens,
229255
output_tokens=trial.output_tokens,
230-
cost_usd=trial.cost_usd,
256+
cost_usd=cost_usd,
257+
cost_is_estimated=cost_is_estimated,
231258
phase_timing=trial.phase_timing,
232259
has_trajectory=trial.has_trajectory,
233260
analysis_status=trial.analysis_status,
@@ -265,6 +292,7 @@ def build_compact_trial_response(
265292
)
266293
normalized_model = settings.normalize_trial_model(trial.agent, trial.model)
267294
task_version, task_version_id = _resolve_trial_version_fields(trial)
295+
cost_usd, cost_is_estimated = _resolve_trial_cost(trial, normalized_model)
268296

269297
return TrialResponse(
270298
id=trial.id,
@@ -286,10 +314,11 @@ def build_compact_trial_response(
286314
reward=trial.reward,
287315
error_message=trial.error_message,
288316
result=None,
289-
input_tokens=None,
290-
cache_tokens=None,
291-
output_tokens=None,
292-
cost_usd=None,
317+
input_tokens=trial.input_tokens,
318+
cache_tokens=trial.cache_tokens,
319+
output_tokens=trial.output_tokens,
320+
cost_usd=cost_usd,
321+
cost_is_estimated=cost_is_estimated,
293322
phase_timing=trial.phase_timing,
294323
has_trajectory=trial.has_trajectory,
295324
analysis_status=trial.analysis_status,

oddish/src/oddish/model_pricing.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Model pricing table for estimating trial cost from token counts.
2+
3+
Harbor does not populate ``cost_usd`` on AgentContext for every provider, so
4+
we fall back to a static pricing table to estimate cost from the captured
5+
input / cache / output token counts. Prices are per-token (not per-million)
6+
and sourced from LiteLLM's ``model_prices_and_context_window.json`` reference.
7+
8+
Model-name matching uses substring patterns because trajectory / agent data
9+
uses varying formats (e.g. ``claude-sonnet-4-5-20250929``,
10+
``anthropic/claude-opus-4-5``, ``bedrock/global.anthropic.claude-sonnet-4-5``,
11+
``gpt-5.1-codex``). More-specific patterns MUST appear before less-specific
12+
ones to avoid false substring matches (e.g. ``gpt-5-mini`` before ``gpt-5``).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass
18+
19+
20+
@dataclass(frozen=True)
21+
class ModelPricing:
22+
"""Per-token pricing for a single model family."""
23+
24+
input: float
25+
output: float
26+
cache_read: float | None = None
27+
28+
29+
PRICING_TABLE: list[tuple[str, ModelPricing]] = [
30+
# Anthropic — Claude 4.x family. Opus 4.5 dropped in price vs 4 / 4.1.
31+
("claude-opus-4-5", ModelPricing(input=5e-6, output=25e-6, cache_read=5e-7)),
32+
("claude-opus-4-1", ModelPricing(input=15e-6, output=75e-6, cache_read=1.5e-6)),
33+
("claude-opus-4", ModelPricing(input=15e-6, output=75e-6, cache_read=1.5e-6)),
34+
("claude-sonnet-4-5", ModelPricing(input=3e-6, output=15e-6, cache_read=3e-7)),
35+
("claude-sonnet-4", ModelPricing(input=3e-6, output=15e-6, cache_read=3e-7)),
36+
("claude-haiku-4-5", ModelPricing(input=1e-6, output=5e-6, cache_read=1e-7)),
37+
("claude-haiku-4", ModelPricing(input=1e-6, output=5e-6, cache_read=1e-7)),
38+
# Anthropic — older Claude 3.x still showing up in legacy runs.
39+
("claude-3-7-sonnet", ModelPricing(input=3e-6, output=15e-6, cache_read=3e-7)),
40+
("claude-3-5-sonnet", ModelPricing(input=3e-6, output=15e-6, cache_read=3e-7)),
41+
("claude-3-5-haiku", ModelPricing(input=8e-7, output=4e-6, cache_read=8e-8)),
42+
("claude-3.5-sonnet", ModelPricing(input=3e-6, output=15e-6, cache_read=3e-7)),
43+
("claude-3.5-haiku", ModelPricing(input=8e-7, output=4e-6, cache_read=8e-8)),
44+
("claude-3-opus", ModelPricing(input=15e-6, output=75e-6, cache_read=1.5e-6)),
45+
# Google — Gemini 3.x
46+
("gemini-3-pro", ModelPricing(input=2e-6, output=12e-6, cache_read=2e-7)),
47+
("gemini-3-flash", ModelPricing(input=5e-7, output=3e-6, cache_read=5e-8)),
48+
# Google — Gemini 2.5
49+
("gemini-2.5-flash-lite", ModelPricing(input=1e-7, output=4e-7, cache_read=1e-8)),
50+
("gemini-2.5-flash", ModelPricing(input=3e-7, output=2.5e-6, cache_read=3e-8)),
51+
("gemini-2.5-pro", ModelPricing(input=1.25e-6, output=10e-6, cache_read=1.25e-7)),
52+
# OpenAI — GPT-5.x
53+
("gpt-5.1-codex-mini", ModelPricing(input=2.5e-7, output=2e-6, cache_read=2.5e-8)),
54+
("gpt-5.1-codex", ModelPricing(input=1.25e-6, output=10e-6, cache_read=1.25e-7)),
55+
("gpt-5.1", ModelPricing(input=1.25e-6, output=10e-6, cache_read=1.25e-7)),
56+
("gpt-5-codex", ModelPricing(input=1.25e-6, output=10e-6, cache_read=1.25e-7)),
57+
("gpt-5-mini", ModelPricing(input=2.5e-7, output=2e-6, cache_read=2.5e-8)),
58+
("gpt-5-nano", ModelPricing(input=5e-8, output=4e-7, cache_read=5e-9)),
59+
("gpt-5-pro", ModelPricing(input=15e-6, output=120e-6)),
60+
("gpt-5", ModelPricing(input=1.25e-6, output=10e-6, cache_read=1.25e-7)),
61+
# OpenAI — GPT-4.1 / 4o.
62+
("gpt-4.1-mini", ModelPricing(input=4e-7, output=1.6e-6, cache_read=1e-7)),
63+
("gpt-4.1-nano", ModelPricing(input=1e-7, output=4e-7, cache_read=2.5e-8)),
64+
("gpt-4.1", ModelPricing(input=2e-6, output=8e-6, cache_read=5e-7)),
65+
("gpt-4o-mini", ModelPricing(input=1.5e-7, output=6e-7, cache_read=7.5e-8)),
66+
("gpt-4o", ModelPricing(input=2.5e-6, output=10e-6, cache_read=1.25e-6)),
67+
# OpenAI — reasoning.
68+
("o4-mini", ModelPricing(input=1.1e-6, output=4.4e-6, cache_read=2.75e-7)),
69+
("o3-pro", ModelPricing(input=20e-6, output=80e-6)),
70+
("o3-mini", ModelPricing(input=1.1e-6, output=4.4e-6, cache_read=5.5e-7)),
71+
("o3", ModelPricing(input=2e-6, output=8e-6, cache_read=5e-7)),
72+
# OpenAI — standalone codex endpoint.
73+
("codex-mini", ModelPricing(input=1.5e-6, output=6e-6, cache_read=3.75e-7)),
74+
]
75+
76+
77+
def _find_pricing(model_name: str) -> ModelPricing | None:
78+
lower = model_name.lower()
79+
for pattern, pricing in PRICING_TABLE:
80+
if pattern in lower:
81+
return pricing
82+
return None
83+
84+
85+
def has_pricing(model_name: str | None) -> bool:
86+
"""Return True iff the pricing table has an entry for this model."""
87+
if not model_name:
88+
return False
89+
return _find_pricing(model_name) is not None
90+
91+
92+
def estimate_cost_usd(
93+
model_name: str | None,
94+
input_tokens: int | None,
95+
output_tokens: int | None,
96+
cached_tokens: int | None = None,
97+
) -> float | None:
98+
"""Estimate USD cost from token counts and a model name.
99+
100+
``cached_tokens`` is the subset of ``input_tokens`` that were served from
101+
prompt cache; those tokens are billed at ``cache_read`` instead of the
102+
full input rate. Returns ``None`` when the model is not in the pricing
103+
table or there are no tokens to price.
104+
"""
105+
if not model_name:
106+
return None
107+
input_total = int(input_tokens or 0)
108+
output_total = int(output_tokens or 0)
109+
if input_total == 0 and output_total == 0:
110+
return None
111+
pricing = _find_pricing(model_name)
112+
if pricing is None:
113+
return None
114+
115+
cached = int(cached_tokens or 0)
116+
uncached_input = max(0, input_total - cached)
117+
cache_rate = pricing.cache_read if pricing.cache_read is not None else pricing.input
118+
119+
return (
120+
uncached_input * pricing.input
121+
+ cached * cache_rate
122+
+ output_total * pricing.output
123+
)

oddish/src/oddish/schemas.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,23 @@ class TrialResponse(BaseModel):
420420
)
421421
cache_tokens: int | None = Field(None, description="Cache tokens used")
422422
output_tokens: int | None = Field(None, description="Output tokens generated")
423-
cost_usd: float | None = Field(None, description="Estimated cost in USD")
423+
cost_usd: float | None = Field(
424+
None,
425+
description=(
426+
"Trial cost in USD. Native value from the agent runtime when "
427+
"available; otherwise estimated from token counts and a static "
428+
"model pricing table (see ``cost_is_estimated``)."
429+
),
430+
)
431+
cost_is_estimated: bool | None = Field(
432+
None,
433+
description=(
434+
"True when ``cost_usd`` was derived from the static model "
435+
"pricing table because the agent runtime did not report a "
436+
"native cost. False when the cost came directly from the "
437+
"runtime. Null when no cost is available."
438+
),
439+
)
424440

425441
# Per-phase timing breakdown
426442
phase_timing: dict | None = Field(

0 commit comments

Comments
 (0)