Skip to content

Commit ebe082e

Browse files
committed
feat(core): price table cost computation (ev-afm)
Adds `eval_harness/core/price_tables.py` with a versioned, dated `PriceTable` model and a built-in `DEFAULT_PRICE_TABLE` covering ~5 current-generation models (verified 2026-05-12 against Anthropic and OpenAI public pricing). The runner uses it to fill `Trace.metrics.cost_usd` whenever an adapter reports token counts but no $ figure, so downstream cost rollups stay accurate across adapters that don't compute cost themselves. - `MetricsConfig` (`eval.yaml > metrics.price_table_path`): optional user override; YAML loaded and validated against `PriceTable`. Path is resolved relative to the eval.yaml. - `compute_cost()` returns `None` for unknown models — caller leaves `cost_usd` as `None`, no silent zero. - Thinking tokens are priced separately (`thinking_per_million_tokens`) for providers that bill extended-reasoning tokens at a distinct rate. - Runner emits a single `logging.warning` at startup when the default table is in use, noting its `freshness_date`. Per-cell fills are `logging.debug`. Tests cover the spec list (default loads, correct compute, unknown model -> None, user override, thinking tokens), plus runner-level checks that cost_usd is filled when missing and untouched when the adapter already reported it.
1 parent 74a80b1 commit ebe082e

8 files changed

Lines changed: 512 additions & 0 deletions

File tree

docs/ConfigSchema.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ output list of TraceStores; first one is canonical, others are mirrors
7575
| `run.retry.backoff_seconds` | float | no | Exponential base. |
7676
| `run.baseline_variant` | string | no | Used by ComparisonReport in `summary.yaml`. |
7777
| `run.cost_limit_usd` | float | no | Run-level cost guardrail. When accumulated `trace.metrics.cost_usd` across completed cells reaches this value, queued cells are short-circuited with a `cost_limit` Trace. Independent from and additive to per-evaluator `cost_limit_usd`. |
78+
| `metrics.price_table_path` | string | no | YAML file describing per-model token prices. The runner fills `trace.metrics.cost_usd` from this table when an adapter reports token counts but no $ figure. Path is resolved relative to the `eval.yaml` location. When omitted, the runner uses a versioned built-in `DEFAULT_PRICE_TABLE` and emits one `logging.warning` per run noting its `freshness_date` so the staleness is visible. The model name is read from `systems[].metadata.model` (with `systems[].model` as a fallback). |
7879
| `output[]` | list[dict] | yes | At least one TraceStore. Single mapping is accepted and coerced to a one-element list. |
7980
| `output[].type` | enum | yes | One of the registered TraceStores. |
8081
| `output[].path` | string | type-dependent | Required for `local_files`. |

eval_harness/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
EvalConfig,
66
EvalIdentity,
77
EvaluatorConfig,
8+
MetricsConfig,
89
OutputConfig,
910
PassCriteria,
1011
RetryPolicy,
@@ -58,6 +59,7 @@
5859
"FileEntry",
5960
"FileManifest",
6061
"FilesystemArtifact",
62+
"MetricsConfig",
6163
"OutputConfig",
6264
"PassCriteria",
6365
"Registry",

eval_harness/core/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ class OutputConfig(BaseModel):
7878
path: str | None = None
7979

8080

81+
class MetricsConfig(BaseModel):
82+
model_config = _FORBID
83+
price_table_path: str | None = None
84+
85+
8186
class EvalConfig(BaseModel):
8287
model_config = _FORBID
8388
schema_version: str = "1.0"
@@ -90,6 +95,7 @@ class EvalConfig(BaseModel):
9095
evaluators: list[EvaluatorConfig]
9196
pass_criteria: PassCriteria = Field(default_factory=PassCriteria)
9297
run: RunOptions = Field(default_factory=RunOptions)
98+
metrics: MetricsConfig = Field(default_factory=MetricsConfig)
9399
output: list[OutputConfig]
94100

95101
@model_validator(mode="before")

eval_harness/core/price_tables.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Per-model price tables and cost computation.
2+
3+
The runner uses this to fill `Trace.metrics.cost_usd` when an adapter reported
4+
token counts but no $ figure. Ships `DEFAULT_PRICE_TABLE` with a small set of
5+
current-generation models and a `freshness_date`; the runner emits a single
6+
warning per run when the default is in use so the staleness is visible. Users
7+
override via `eval.yaml > metrics.price_table_path`.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
from datetime import date
14+
from pathlib import Path
15+
16+
import yaml
17+
from pydantic import BaseModel, ConfigDict, Field
18+
19+
from eval_harness.core.errors import ConfigError
20+
21+
logger = logging.getLogger(__name__)
22+
23+
_FORBID = ConfigDict(extra="forbid")
24+
25+
26+
class ModelPrice(BaseModel):
27+
"""Per-million-token pricing for one model.
28+
29+
Thinking tokens (extended-thinking / reasoning) are billed separately on
30+
some providers; default to 0.0 when a model has no thinking surcharge.
31+
"""
32+
33+
model_config = _FORBID
34+
input_per_million_tokens: float
35+
output_per_million_tokens: float
36+
thinking_per_million_tokens: float = 0.0
37+
38+
39+
class PriceTable(BaseModel):
40+
"""Versioned, dated price table.
41+
42+
`freshness_date` is the day prices were verified against provider
43+
documentation. The runner warns once per run when `DEFAULT_PRICE_TABLE` is
44+
in use so users know the cost figures may be stale.
45+
"""
46+
47+
model_config = _FORBID
48+
table_version: str
49+
freshness_date: date
50+
models: dict[str, ModelPrice] = Field(default_factory=dict)
51+
52+
53+
# Sources verified 2026-05-12 against provider docs:
54+
# - Anthropic: https://www.anthropic.com/pricing#anthropic-api
55+
# - OpenAI: https://openai.com/api/pricing/
56+
# Prices are USD per million tokens. Thinking-token pricing covers extended
57+
# reasoning where the provider bills a distinct rate (e.g. Anthropic 1M
58+
# extended thinking pricing matches output rate at time of capture).
59+
DEFAULT_PRICE_TABLE = PriceTable(
60+
table_version="2026-05-12",
61+
freshness_date=date(2026, 5, 12),
62+
models={
63+
"claude-opus-4-7": ModelPrice(
64+
input_per_million_tokens=15.0,
65+
output_per_million_tokens=75.0,
66+
thinking_per_million_tokens=75.0,
67+
),
68+
"claude-sonnet-4-6": ModelPrice(
69+
input_per_million_tokens=3.0,
70+
output_per_million_tokens=15.0,
71+
thinking_per_million_tokens=15.0,
72+
),
73+
"claude-haiku-4-5-20251001": ModelPrice(
74+
input_per_million_tokens=0.25,
75+
output_per_million_tokens=1.25,
76+
),
77+
"claude-4-7": ModelPrice(
78+
input_per_million_tokens=3.0,
79+
output_per_million_tokens=15.0,
80+
thinking_per_million_tokens=15.0,
81+
),
82+
"gpt-5": ModelPrice(
83+
input_per_million_tokens=5.0,
84+
output_per_million_tokens=15.0,
85+
),
86+
},
87+
)
88+
89+
90+
def load_price_table(path: Path | None) -> PriceTable:
91+
"""Load a price table from YAML; `None` returns `DEFAULT_PRICE_TABLE`."""
92+
if path is None:
93+
return DEFAULT_PRICE_TABLE
94+
if not path.exists():
95+
raise ConfigError(f"price_table_path does not exist: {path}")
96+
try:
97+
data = yaml.safe_load(path.read_text())
98+
except yaml.YAMLError as e:
99+
raise ConfigError(f"price_table_path is not valid YAML ({path}): {e}") from e
100+
if not isinstance(data, dict):
101+
raise ConfigError(
102+
f"price_table_path must be a YAML mapping ({path}); got {type(data).__name__}"
103+
)
104+
return PriceTable.model_validate(data)
105+
106+
107+
def compute_cost(
108+
table: PriceTable,
109+
model: str,
110+
token_input: int,
111+
token_output: int,
112+
token_thinking: int = 0,
113+
) -> float | None:
114+
"""Compute the call's $ cost from token counts. Returns `None` when the
115+
model is not in the table — callers handle the gap (e.g. leave
116+
`Trace.metrics.cost_usd` as `None`)."""
117+
price = table.models.get(model)
118+
if price is None:
119+
return None
120+
return (
121+
(token_input / 1_000_000.0) * price.input_per_million_tokens
122+
+ (token_output / 1_000_000.0) * price.output_per_million_tokens
123+
+ (token_thinking / 1_000_000.0) * price.thinking_per_million_tokens
124+
)
125+
126+
127+
def warn_default_table_in_use(table: PriceTable) -> None:
128+
"""Emit a single warning when `DEFAULT_PRICE_TABLE` is the active table.
129+
130+
Idempotent: the runner calls this once at startup. Kept as a function so
131+
other entry points (e.g. ad-hoc tooling) can opt-in to the same warning.
132+
"""
133+
if table is DEFAULT_PRICE_TABLE:
134+
logger.warning(
135+
"Using DEFAULT_PRICE_TABLE (freshness_date=%s, table_version=%s). "
136+
"Prices may be stale. Override via eval.yaml > metrics.price_table_path.",
137+
table.freshness_date.isoformat(),
138+
table.table_version,
139+
)
140+
141+
142+
__all__ = [
143+
"DEFAULT_PRICE_TABLE",
144+
"ModelPrice",
145+
"PriceTable",
146+
"compute_cost",
147+
"load_price_table",
148+
"warn_default_table_in_use",
149+
]

eval_harness/runner/plan_builder.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from eval_harness.core.config import EvalConfig, RetryPolicy, SystemConfig
99
from eval_harness.core.errors import ConfigError
1010
from eval_harness.core.models import EvalCase, RunVariant
11+
from eval_harness.core.price_tables import PriceTable, load_price_table
1112
from eval_harness.core.time import make_run_id
1213
from eval_harness.factories.dataset_adapter_factory import DatasetAdapterFactory
1314
from eval_harness.factories.evaluator_factory import EvaluatorFactory
@@ -37,6 +38,7 @@ class RunPlan:
3738
evaluators: list[Evaluator]
3839
retry_policy: RetryPolicy
3940
baseline_variant: str | None
41+
price_table: PriceTable | None = None
4042
# Optional whitelist of `(case_id, variant_name)` cells. When set, run_eval
4143
# executes only these specific cells (instead of the full cases x variants
4244
# product). Used by `evalh run --retry-only-failed` to amend an existing
@@ -120,6 +122,8 @@ async def build_plan(
120122
f"defined: {sorted(known_variants)}"
121123
)
122124

125+
price_table = _build_price_table(config, config_path)
126+
123127
return RunPlan(
124128
config=config,
125129
run_id=run_id,
@@ -132,9 +136,20 @@ async def build_plan(
132136
evaluators=evaluators,
133137
retry_policy=config.run.retry,
134138
baseline_variant=baseline,
139+
price_table=price_table,
135140
)
136141

137142

143+
def _build_price_table(config: EvalConfig, config_path: Path) -> PriceTable:
144+
raw = config.metrics.price_table_path
145+
if raw is None:
146+
return load_price_table(None)
147+
path = Path(raw)
148+
if not path.is_absolute():
149+
path = (config_path.parent / path).resolve()
150+
return load_price_table(path)
151+
152+
138153
def _system_extras(sys_cfg: SystemConfig) -> dict[str, object]:
139154
dumped = sys_cfg.model_dump()
140155
return {k: v for k, v in dumped.items() if k not in _STRUCTURAL_KEYS}

eval_harness/runner/run_eval.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import itertools
5+
import logging
56
from contextlib import AsyncExitStack, suppress
67
from dataclasses import dataclass
78
from typing import TYPE_CHECKING
@@ -14,10 +15,17 @@
1415
Trace,
1516
TraceError,
1617
)
18+
from eval_harness.core.price_tables import (
19+
PriceTable,
20+
compute_cost,
21+
warn_default_table_in_use,
22+
)
1723
from eval_harness.core.time import utc_now
1824
from eval_harness.runner.cost_accumulator import CostAccumulator
1925
from eval_harness.runner.summary import SummaryAggregator
2026

27+
logger = logging.getLogger(__name__)
28+
2129
if TYPE_CHECKING:
2230
from eval_harness.adapters.workspace.base import Workspace
2331
from eval_harness.evaluators.base import Evaluator
@@ -37,6 +45,9 @@ async def run_eval(plan: RunPlan) -> RunSummary:
3745
accumulator = CostAccumulator()
3846
cost_limit = plan.config.run.cost_limit_usd
3947
aggregator = SummaryAggregator(plan=plan)
48+
if plan.price_table is not None:
49+
warn_default_table_in_use(plan.price_table)
50+
variant_models = _build_variant_model_index(plan.variants)
4051

4152
async with AsyncExitStack() as stack:
4253
await stack.enter_async_context(plan.trace_store)
@@ -68,6 +79,9 @@ async def run_cell(case: EvalCase, variant: RunVariant) -> None:
6879
)
6980
else:
7081
outcome = await _run_one(case, variant, plan)
82+
_fill_cost_from_price_table(
83+
outcome.trace, variant_models.get(variant.name), plan.price_table
84+
)
7185
accumulator.tally(outcome.trace)
7286
# Stream the outcome into the aggregator immediately and let
7387
# it fall out of scope. This is the streaming-summary
@@ -87,6 +101,55 @@ async def run_cell(case: EvalCase, variant: RunVariant) -> None:
87101
raise RuntimeError("unreachable: AsyncExitStack never re-raises")
88102

89103

104+
def _build_variant_model_index(variants: list[RunVariant]) -> dict[str, str]:
105+
"""Map variant name -> declared model. Looks in `metadata.model` first
106+
(the canonical place per docs/ConfigSchema.md), then `config.model` for
107+
adapters that nest it there. Variants without a declared model are
108+
omitted; the runner just doesn't fill cost for them."""
109+
out: dict[str, str] = {}
110+
for v in variants:
111+
raw = v.metadata.get("model") or v.config.get("model")
112+
if isinstance(raw, str) and raw:
113+
out[v.name] = raw
114+
return out
115+
116+
117+
def _fill_cost_from_price_table(
118+
trace: Trace, model: str | None, table: PriceTable | None
119+
) -> None:
120+
"""If the adapter didn't fill `cost_usd` but did report token counts, use
121+
the price table. No-op when prices are unavailable for the model."""
122+
if table is None or model is None:
123+
return
124+
metrics = trace.metrics
125+
if metrics.cost_usd is not None:
126+
return
127+
token_input = metrics.token_input or 0
128+
token_output = metrics.token_output or 0
129+
token_thinking = metrics.token_thinking or 0
130+
if token_input == 0 and token_output == 0 and token_thinking == 0:
131+
return
132+
cost = compute_cost(table, model, token_input, token_output, token_thinking)
133+
if cost is None:
134+
logger.debug(
135+
"price_table: no entry for model %r (variant %r); cost_usd left None",
136+
model,
137+
trace.variant_name,
138+
)
139+
return
140+
metrics.cost_usd = cost
141+
logger.debug(
142+
"price_table: filled cost_usd=%.6f for variant=%r model=%r "
143+
"(in=%d, out=%d, thinking=%d)",
144+
cost,
145+
trace.variant_name,
146+
model,
147+
token_input,
148+
token_output,
149+
token_thinking,
150+
)
151+
152+
90153
async def _short_circuit_cost_limit(
91154
case: EvalCase,
92155
variant: RunVariant,

0 commit comments

Comments
 (0)