diff --git a/docs/REPORT_SCHEMA.md b/docs/REPORT_SCHEMA.md index 3aceab87..76ec85df 100644 --- a/docs/REPORT_SCHEMA.md +++ b/docs/REPORT_SCHEMA.md @@ -51,6 +51,21 @@ run-level summary; full per-replicate detail lives in each `task.json`. | `framework_version` | `str` | Coder Eval version chip. | | `environment_info` | `dict` | Version/dependency info (may nest, e.g. `tool_plugins`). | +These are **computed**, not stored — derived from the counts and rows above on every +serialization, so they cannot drift from what they summarize. Read them rather than +re-deriving your own; independent re-derivations are how two consumers end up +publishing different numbers for the same run. + +| Key | Type | Meaning | +| --- | --- | --- | +| `pass_rate` | `float \| None` | `tasks_succeeded / tasks_run` — errors are in the denominator, counted as misses. `None` on an empty run (0/0 is unknown, not 0%). | +| `error_share` | `float \| None` | `tasks_error / tasks_run`. Diagnostic only; never adjusts the rate. | +| `total_cost_usd` | `float \| None` | **The bill**: agent + judge + simulator, summed over the rows. `None` when nothing could be priced. | +| `agent_cost_usd` | `float \| None` | Subject-agent spend alone. The harness-vs-harness comparison figure — judge spend is a property of the suite's criteria and identical across harnesses, so leaving it in would make two harnesses look closer than they are. | +| `eval_overhead_cost_usd` | `float \| None` | Judge + simulator spend. The other half of `total_cost_usd`. | +| `tasks_cost_incomplete` | `int` | Rows whose recorded spend is missing money (unpriced model, or a hard kill that lost an in-flight turn). | +| `cost_complete` | `bool` | `tasks_cost_incomplete == 0`. When false, every cost figure above is a **floor**, not the bill. A run is never failed for this — see [Missing cost is never fatal](#missing-cost-is-never-fatal). | + ### `task_results[]` — the flat per-task row Each entry is an **untyped dict** (a denormalization, not a Pydantic model) with keys @@ -58,7 +73,10 @@ including: `task_id`, `replicate_index`, `variant_id`, `status` ([`FinalStatus`](#finalstatus)), `weighted_score`, `duration`, `iteration_count`, `tags`, `task_path`, `model_used`, `reference_similarity`, the token buckets (`input_tokens` = uncached input, `output_tokens`, `cache_creation_input_tokens`, -`cache_read_input_tokens`, `total_tokens`), `total_cost_usd`, `expected_commands`, +`cache_read_input_tokens`, `total_tokens`), the cost fields +(`total_cost_usd` = agent + judge + simulator, plus the `agent_cost_usd` / +`judge_cost_usd` / `simulator_cost_usd` slices and the `cost_complete` flag), +`expected_commands`, `actual_commands`, `commands_efficiency`, `agent_config`, `sdk_options`, `installed_tools`, turn accounting (`total_turns`, `visible_turns`, `expected_turns`, `max_turns_exhausted`, `has_final_reply`), and early-stop fields (`stopped_early`, @@ -66,6 +84,29 @@ including: `task_id`, `replicate_index`, `variant_id`, `status` turn digest (`{iteration, duration_seconds, command_count, assistant_turn_count, crashed, crash_reason}`) — the full transcript is in `task.json`. +### Missing cost is never fatal + +Pricing degrades; the evaluation does not. A model absent from the rate card, a turn +the backend never priced, a hard-killed task that lost its in-flight spend: each one +lowers a total and sets `cost_complete: false`. None of them raises, none of them +books a zero, and none of them changes a run's exit code. + +The reasoning is that the two failure modes are not symmetric. A missing cost is +recoverable after the fact — the token counts are on the record, so a corrected rate +card reprices the run from its artifacts. A failed run is not: the tokens are already +spent and the only way back is to run it again. So the framework warns loudly and +keeps going. + +The warning fires up front. `check_pricing_coverage` walks every model the run pins +(subject agents and judge criteria) before the first task dispatches, and logs the +ones the card cannot price — early enough to fix the card and restart while it is +still cheap. After that the run is on its own: totals become floors, and +`tasks_cost_incomplete` says how many rows are behind that floor. + +Consumers should treat any cost field as a lower bound whenever `cost_complete` is +false, and must not read `None` as `0.0` — "nothing could be priced" and "it was +free" are different facts. + --- ## `task.json` — `EvaluationResult` @@ -247,6 +288,12 @@ respectively), checked after each completed agent turn — see - `TokenUsage.total_tokens` is not serialized; sum the buckets (or use the computed `input_tokens` + `output_tokens` + cache buckets). - `EarlyStopInfo` presence is itself the "stopped early" signal. +- `total_cost_usd` is the whole bill (agent + judge + simulator) at both row and run + level; `agent_cost_usd` is the agent-only slice. `TokenUsage.total_cost_usd` is a + different thing: the cost of those tokens, so always agent-only. `run_limits.max_usd` + gates on that one, since judge and simulator spend is not known mid-run. +- A cost of `None` means unpriced, not free, and any total is a floor while + `cost_complete` is false — see [Missing cost is never fatal](#missing-cost-is-never-fatal). ## See also diff --git a/src/coder_eval/agent.py b/src/coder_eval/agent.py index 2b244ce2..4b4510a2 100644 --- a/src/coder_eval/agent.py +++ b/src/coder_eval/agent.py @@ -147,6 +147,17 @@ def _finalize_and_raise_crash( raise AgentCrashError(message) from cause raise AgentCrashError(message) + def _finalize_external_cancel(self, finalize: _FinalizeFn) -> None: + """Finalize a turn cancelled from outside (the task watchdog) as a crash. Does NOT raise. + + Only the ``crashed`` branch parks the record on ``pending_turn``; finalizing + as ``COMPLETED`` drops it, and the unwinding frame takes the return value + with it, so a killed turn's telemetry survives only via this path. The caller + re-raises the ``CancelledError`` afterwards. + """ + self._state = AgentState.ERROR + finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled") + def _capture_partial_turn(self, collector: EventCollector) -> None: """Build the crashed partial ``TurnRecord`` into ``pending_turn`` (best-effort). diff --git a/src/coder_eval/agents/antigravity_agent.py b/src/coder_eval/agents/antigravity_agent.py index b27527f9..4e1475be 100644 --- a/src/coder_eval/agents/antigravity_agent.py +++ b/src/coder_eval/agents/antigravity_agent.py @@ -514,8 +514,7 @@ def _on_turn_timeout() -> None: raise except asyncio.CancelledError: if not state.finalized: - self._state = AgentState.ERROR - state.finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled") + self._finalize_external_cancel(state.finalize) raise except Exception as e: if state.stopped_early_hit and not state.timeout_hit: diff --git a/src/coder_eval/agents/claude_code_agent.py b/src/coder_eval/agents/claude_code_agent.py index 9c2e2887..01ce89ca 100644 --- a/src/coder_eval/agents/claude_code_agent.py +++ b/src/coder_eval/agents/claude_code_agent.py @@ -980,6 +980,11 @@ def _on_turn_timeout() -> None: if self._timed_out(state.timeout_hit, deadline): assert timeout is not None self._finalize_and_raise_timeout(state.finalize, timeout) + # Cancelled from outside this turn: park the telemetry on `pending_turn` + # for the caller to drain. Otherwise the `finally` below finalizes as + # COMPLETED, which keeps no record. + if not state.finalized: + self._finalize_external_cancel(state.finalize) raise except ProcessError as e: # When the watchdog SIGKILLs the subprocess, the SDK surfaces it as a diff --git a/src/coder_eval/agents/codex_agent.py b/src/coder_eval/agents/codex_agent.py index b22407bc..c6e4b2d3 100644 --- a/src/coder_eval/agents/codex_agent.py +++ b/src/coder_eval/agents/codex_agent.py @@ -864,8 +864,7 @@ def _on_turn_timeout() -> None: # stays balanced and the pending-turn contract holds. finalize is # idempotent, so the timeout case is a no-op here. if not state.finalized: - self._state = AgentState.ERROR - state.finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled") + self._finalize_external_cancel(state.finalize) raise except Exception as e: # Catches failures OUTSIDE the inner turn block — notably thread_start diff --git a/src/coder_eval/criteria/llm_judge.py b/src/coder_eval/criteria/llm_judge.py index ac0335d7..a1cb2020 100644 --- a/src/coder_eval/criteria/llm_judge.py +++ b/src/coder_eval/criteria/llm_judge.py @@ -201,7 +201,7 @@ async def _invoke_tool_channel( tool_spec=SUBMIT_VERDICT_ANTHROPIC_TOOL, ) verdict, err = extract_verdict_from_anthropic_response(response) - response_usage = token_usage_from_anthropic_dict(response) + response_usage = token_usage_from_anthropic_dict(response, model=criterion.model) case DirectRoute(): anthropic_response = await invoke_anthropic_judge_async( model=criterion.model, @@ -212,7 +212,7 @@ async def _invoke_tool_channel( tool_spec=SUBMIT_VERDICT_ANTHROPIC_TOOL, ) verdict, err = extract_verdict_from_anthropic_response(anthropic_response) - response_usage = token_usage_from_anthropic_dict(anthropic_response) + response_usage = token_usage_from_anthropic_dict(anthropic_response, model=criterion.model) case LiteLLMRoute(): # Defensive: the evaluation route is pinned to Bedrock/Direct by # resolve_evaluation_route, so a LiteLLM route should never reach the diff --git a/src/coder_eval/evaluation/judge_usage.py b/src/coder_eval/evaluation/judge_usage.py index 4727cf25..dfe51dd0 100644 --- a/src/coder_eval/evaluation/judge_usage.py +++ b/src/coder_eval/evaluation/judge_usage.py @@ -15,6 +15,7 @@ from typing import Any from coder_eval.models import TokenUsage +from coder_eval.pricing import calculate_cost def _coerce_int(value: Any) -> int: @@ -31,12 +32,17 @@ def _coerce_int(value: Any) -> int: return 0 -def token_usage_from_anthropic_dict(resp: dict[str, Any]) -> TokenUsage | None: +def token_usage_from_anthropic_dict(resp: dict[str, Any], *, model: str | None = None) -> TokenUsage | None: """Extract usage from an Anthropic / Bedrock-invoke Messages response dict. Both ``invoke_anthropic_judge_async`` (``response.model_dump()``) and ``invoke_bedrock_judge_async`` (parsed ``/invoke`` JSON) carry an Anthropic-shaped ``usage`` block. Returns ``None`` when usage is missing or carries no tokens. + + ``model`` prices the call from the rate card. Neither judge backend returns a + cost, so without it the judge's spend is invisible in every rollup. Left + unpriced (``total_cost_usd=None``) when the model is absent from the card, + which ``RunSummary.tasks_cost_incomplete`` then surfaces. """ u = resp.get("usage") if not isinstance(u, dict): @@ -47,4 +53,14 @@ def token_usage_from_anthropic_dict(resp: dict[str, Any]) -> TokenUsage | None: cache_creation_input_tokens=_coerce_int(u.get("cache_creation_input_tokens")), cache_read_input_tokens=_coerce_int(u.get("cache_read_input_tokens")), ) - return None if tu.is_empty() else tu + if tu.is_empty(): + return None + if model: + tu.total_cost_usd = calculate_cost( + model, + uncached_input_tokens=tu.uncached_input_tokens, + output_tokens=tu.output_tokens, + cache_creation_tokens=tu.cache_creation_input_tokens, + cache_read_tokens=tu.cache_read_input_tokens, + ) + return tu diff --git a/src/coder_eval/models/__init__.py b/src/coder_eval/models/__init__.py index 3a9b3796..084b0c2a 100644 --- a/src/coder_eval/models/__init__.py +++ b/src/coder_eval/models/__init__.py @@ -127,6 +127,12 @@ TaskConfigRecord, ThresholdCheck, TurnRecord, + eval_overhead_cost, + eval_result_total_cost, + judge_cost_usd, + row_cost_incomplete, + simulator_cost_usd, + sum_costs, ) # Routing @@ -291,6 +297,14 @@ "TaskConfigRecord", "RunSummary", "SkippedTask", + # Cost helpers, shared by RunSummary's computed fields and the reports so + # every surface agrees on what a total costs and which rows lost money. + "row_cost_incomplete", + "eval_overhead_cost", + "sum_costs", + "eval_result_total_cost", + "judge_cost_usd", + "simulator_cost_usd", # Judge defaults "DEFAULT_JUDGE_MODEL", # Judge diff --git a/src/coder_eval/models/experiment.py b/src/coder_eval/models/experiment.py index eefe638f..847a8475 100644 --- a/src/coder_eval/models/experiment.py +++ b/src/coder_eval/models/experiment.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Literal, Self -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator from coder_eval.models.enums import FinalStatus from coder_eval.models.limits import RunLimits @@ -194,7 +194,12 @@ class VariantResult(BaseModel): # noqa: CE009 -- persisted result model; round- class VariantAggregate(BaseModel): # noqa: CE009 -- persisted result model; round-trip leniency like models/results.py - """Aggregated statistics for a single variant across all tasks.""" + """Aggregated statistics for a single variant across all tasks. + + ``pass_rate`` uses the same denominator as ``RunSummary.pass_rate``: every task + the variant ran, errors included as misses. Otherwise an A/B whose variants + error at different rates compares two different denominators. + """ variant_id: str tasks_run: int @@ -228,6 +233,12 @@ def _check_task_count_invariant(self) -> VariantAggregate: raise ValueError(f"Task count invariant violated: {total} != {self.tasks_run}") return self + @computed_field # type: ignore[prop-decorator] + @property + def pass_rate(self) -> float | None: + """``tasks_succeeded / tasks_run`` as a 0-1 fraction. ``None`` on an empty variant.""" + return self.tasks_succeeded / self.tasks_run if self.tasks_run else None + class TaskExperimentSummary(BaseModel): # noqa: CE009 -- persisted result model; round-trip leniency like models/results.py """Cross-variant summary for a single task.""" diff --git a/src/coder_eval/models/results.py b/src/coder_eval/models/results.py index ef3336e7..feecdbe5 100644 --- a/src/coder_eval/models/results.py +++ b/src/coder_eval/models/results.py @@ -2,11 +2,21 @@ from __future__ import annotations +from collections.abc import Iterable, Mapping from datetime import datetime from enum import StrEnum from typing import Annotated, Any, Literal -from pydantic import AliasChoices, BaseModel, ConfigDict, Discriminator, Field, Tag, model_validator +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Discriminator, + Field, + Tag, + computed_field, + model_validator, +) from coder_eval.models.agent_config import ResolvedAgentConfig from coder_eval.models.criteria import SuccessCriterion @@ -837,8 +847,100 @@ class SkippedTask(BaseModel): ) +def row_cost_incomplete(row: Mapping[str, Any]) -> bool: + """True when a task row's recorded spend is missing money. + + Reads the row's own ``cost_complete`` flag. Absent means complete: rows written + before the field existed are read as priced rather than inferred from their + token counts, which would be a second definition of the same predicate. + + Defined here, on the row schema, so the reports and ``RunSummary`` cannot + disagree about which rows lost money. + """ + return row.get("cost_complete") is False + + +def sum_costs(*components: float | None) -> float | None: + """Add whichever cost components were priced. ``None`` when none were. + + The one way cost components are combined, so every surface adds them up the + same way. Never raises and never invents a zero: an unpriced component is + skipped, which makes the result a floor rather than a failure. + """ + priced = [c for c in components if c is not None] + return sum(priced) if priced else None + + +def eval_overhead_cost(rows: Iterable[Mapping[str, Any]]) -> float | None: + """Total judge + simulator spend across ``rows``, or ``None`` if none was recorded. + + ``None`` rather than ``0.0`` so "no judge ran" stays distinct from "a judge ran free". + """ + return sum_costs(*(row.get(key) for row in rows for key in ("judge_cost_usd", "simulator_cost_usd"))) + + +def judge_cost_usd(result: EvaluationResult) -> float | None: + """Sum the priced judge spend across an evaluation's criterion results. + + Covers both flavors: ``llm_judge`` prices its own one-shot call from the + criterion's model, ``agent_judge`` inherits the SDK's cost on the sub-agent's + turn. ``None`` when no criterion reported cost. + """ + usages = [u for cr in result.success_criteria_results if (u := getattr(cr, "token_usage", None)) is not None] + return sum_costs(*(u.total_cost_usd for u in usages)) + + +def simulator_cost_usd(result: EvaluationResult) -> float | None: + """Price an evaluation's simulator turns. ``None`` outside simulation mode. + + Priced at the ROUTE's model, not the subject's: ``UserSimulator`` pins + ``model=None`` so it resolves to ``BEDROCK_MODEL``, which differs from the + subject on any task that pins ``agent.model``. + + A floor. ``UserSimulator`` records only ``uncached_input_tokens`` and drops + both cache buckets, so a cached prefix is largely absent from the count. + """ + from coder_eval.pricing import calculate_cost + + sim = result.simulation + if sim is None or not (sim.simulator_input_tokens or sim.simulator_output_tokens): + return None + route_model = (result.environment_info or {}).get("bedrock_model") + # Falls back to the subject's model on a non-Bedrock route, where the SDK picks + # its own default and nothing on the record names it. + model = route_model if isinstance(route_model, str) and route_model else result.model_used + if not model: + return None + return calculate_cost( + model, + uncached_input_tokens=sim.simulator_input_tokens, + output_tokens=sim.simulator_output_tokens, + ) + + +def eval_result_total_cost(result: EvaluationResult) -> float | None: + """Everything one evaluation cost: agent + judge + simulator. + + ``None`` when nothing could be priced, a floor when only part of it could. The + same figure the row projection publishes as ``total_cost_usd``, for the surfaces + that hold an ``EvaluationResult`` rather than a row dict. + """ + agent = result.total_token_usage.total_cost_usd if result.total_token_usage else None + return sum_costs(agent, judge_cost_usd(result), simulator_cost_usd(result)) + + class RunSummary(BaseModel): - """Summary of an entire evaluation run across multiple tasks.""" + """Summary of an entire evaluation run across multiple tasks. + + ``pass_rate`` is ``tasks_succeeded / tasks_run``: every dispatched task is in the + denominator, errors included as misses. The previous formula excluded errors, + which paid a bonus for erroring. ``error_share`` reports how much of the rate is + errors, so a bad infrastructure night shows instead of being absorbed. + + This is the framework's single denominator: every reporting surface reads + ``pass_rate`` rather than re-deriving one. Derived metrics here are computed, + never stored, so they cannot drift from the counts they come from. + """ run_id: str = Field(description="Run identifier (timestamp like '2025-10-09_15-30-45')") start_time: datetime = Field(description="Run start time") @@ -901,3 +1003,64 @@ def _check_task_count_invariant(self) -> RunSummary: total = f"{self.tasks_succeeded} + {self.tasks_failed} + {self.tasks_error}" raise ValueError(f"Task count invariant violated: {total} != {self.tasks_run}") return self + + # Derived run metrics: computed_fields over the stored counts and + # ``task_results``, so they serialize into run.json while staying impossible to + # set to something the rows disagree with. Consumers should read these rather + # than re-derive them. + + @computed_field # type: ignore[prop-decorator] + @property + def pass_rate(self) -> float | None: + """``tasks_succeeded / tasks_run`` as a 0-1 fraction. ``None`` on an empty run.""" + return self.tasks_succeeded / self.tasks_run if self.tasks_run else None + + @computed_field # type: ignore[prop-decorator] + @property + def error_share(self) -> float | None: + """``tasks_error / tasks_run`` as a 0-1 fraction. ``None`` on an empty run. + + Diagnostic only, never adjusts the rate: a drop at a high error share is an + infrastructure night, the same drop at a normal share is the model. + """ + return self.tasks_error / self.tasks_run if self.tasks_run else None + + @computed_field # type: ignore[prop-decorator] + @property + def tasks_cost_incomplete(self) -> int: + """Rows with money missing from their recorded spend (see ``row_cost_incomplete``).""" + return sum(1 for row in self.task_results if row_cost_incomplete(row)) + + @computed_field # type: ignore[prop-decorator] + @property + def cost_complete(self) -> bool: + """False when any row's spend is incomplete, so every cost total here is a floor.""" + return self.tasks_cost_incomplete == 0 + + @computed_field # type: ignore[prop-decorator] + @property + def agent_cost_usd(self) -> float | None: + """Subject-agent spend across the run. ``None`` when no row reported cost. + + The comparison figure, not the bill: judge spend is a property of the suite's + criteria and identical across harnesses, so folding it in would make two + harnesses look closer than they are. ``total_cost_usd`` is the bill. + """ + return sum_costs(*(row.get("agent_cost_usd") for row in self.task_results)) + + @computed_field # type: ignore[prop-decorator] + @property + def eval_overhead_cost_usd(self) -> float | None: + """Judge + simulator spend across the run. ``None`` when neither reported cost.""" + return eval_overhead_cost(self.task_results) + + @computed_field # type: ignore[prop-decorator] + @property + def total_cost_usd(self) -> float | None: + """What the run cost: agent + judge + simulator. + + The number to quote for a run's bill, and what every surface means by "total + cost". ``None`` when nothing could be priced, and a floor rather than an error + when only some of it could. + """ + return sum_costs(*(row.get("total_cost_usd") for row in self.task_results)) diff --git a/src/coder_eval/orchestration/batch.py b/src/coder_eval/orchestration/batch.py index d4c218f5..8576cdfc 100644 --- a/src/coder_eval/orchestration/batch.py +++ b/src/coder_eval/orchestration/batch.py @@ -12,7 +12,7 @@ import json import logging import shutil -from collections.abc import Callable +from collections.abc import Callable, Iterator from datetime import datetime from pathlib import Path from typing import Any @@ -29,6 +29,7 @@ TaskResult, ) from ..path_utils import format_task_log_id +from ..pricing import unpriced_models from ..reports_experiment import eval_result_to_task_dict from ..streaming.callbacks import StreamCallback from ..utils import get_version_info, looks_like_version @@ -38,6 +39,47 @@ logger = logging.getLogger(__name__) +def _run_models(resolved_tasks: list[ResolvedTask]) -> Iterator[str | None]: + """Every model id this run pins up front: subject agents and judge criteria. + + A criterion's model is always priced from the rate card (no judge backend + reports a cost), so it matters more here than the agent's, which only falls + back to the card on a turn the backend never priced. + """ + for rt in resolved_tasks: + yield rt.task.agent.model if rt.task.agent else None + for criterion in rt.task.success_criteria: + yield getattr(criterion, "model", None) + + +def check_pricing_coverage(resolved_tasks: list[ResolvedTask]) -> list[str]: + """Pre-flight the rate card against the models this run will use. + + The rate card is a static table baked into the installed version, so a model + released after it has no rate and its tokens book no money. A warning rather + than a refusal, so a brand-new model stays evaluable the day it ships: cost is + what degrades, not the evaluation. What the run actually lost is counted after + the fact by ``RunSummary.tasks_cost_incomplete``. + + Only models pinned in the task YAML are visible here; one deferred to the route + resolves inside the agent and can't be pre-flighted. + + Returns: + The sorted, de-duplicated unpriced model ids (empty when all are priced). + """ + missing = unpriced_models(_run_models(resolved_tasks)) + if not missing: + return [] + logger.warning( + "No pricing rate for %s. Turns the agent's own backend prices are unaffected, but any " + + "judge call, timed-out turn or killed partial will book its tokens with no cost, so " + + "run-level totals will understate the bill (RunSummary.cost_complete reports false). " + + "Add the rate to coder_eval.pricing or register it from a plugin via register_pricing().", + ", ".join(repr(m) for m in missing), + ) + return missing + + async def run_batch( resolved_tasks: list[ResolvedTask], config: BatchRunConfig, @@ -75,6 +117,8 @@ async def run_batch( start_time = datetime.now() + check_pricing_coverage(resolved_tasks) + if on_batch_start is not None: on_batch_start(len(resolved_tasks)) diff --git a/src/coder_eval/orchestrator.py b/src/coder_eval/orchestrator.py index 286e5991..796520c6 100644 --- a/src/coder_eval/orchestrator.py +++ b/src/coder_eval/orchestrator.py @@ -529,6 +529,12 @@ def _kill_agent_subprocess_sync() -> None: ) logger.error(f"Task timed out: {e}") + + # Recover the turn in flight when the watchdog killed the agent. + # Nothing else on this path does: the cancel arrives as a + # BaseException, so it never reaches the retry executor's + # per-attempt hook that drains the slot on a turn-level timeout. + await self._drain_killed_turn() except BudgetExceededError as e: # Map token-budget breaches and cost-budget breaches to distinct # statuses so per-task records preserve the failure mode. @@ -612,6 +618,40 @@ def _kill_agent_subprocess_sync() -> None: return self.result + async def _drain_killed_turn(self) -> None: + """Move a hard-killed turn's partial record from the agent onto the result. + + The only reader of ``pending_turn`` on the task-timeout path. Ordering + matters both ways: it must run before ``_cleanup`` (whose ``agent.stop()`` + clears the slot) and before ``_finalize_result``, so the recovered turn + feeds token aggregation and command stats like any other. + + Best-effort: a task killed before its first turn has nothing parked, and + this runs on the way to a saved row, so it must not raise. + """ + if self.agent is None or self.result is None: + return + try: + partial = self.agent.pending_turn + # `pending_turn` is a slot any agent implementation fills, so a non-record + # here would fail validation during teardown and take the row down with it. + if not isinstance(partial, TurnRecord): + logger.debug("[%s] Hard-killed task preserved no partial turn", self.task.task_id) + return + self.result.iterations.append(partial) + await self.agent.discard_pending_turn() + usage = partial.token_usage + logger.info( + "[%s] Recovered the hard-killed turn: %d tokens, %s", + self.task.task_id, + usage.total_tokens if usage is not None else 0, + f"${usage.total_cost_usd:.4f}" + if usage is not None and usage.total_cost_usd is not None + else "unpriced", + ) + except Exception: + logger.warning("[%s] Could not recover the hard-killed turn", self.task.task_id, exc_info=True) + def _finalize_result(self, start_time: float) -> None: """Finalize the evaluation result: scores, telemetry, and persistence.""" if not self.result: diff --git a/src/coder_eval/pricing.py b/src/coder_eval/pricing.py index 28784360..27132444 100644 --- a/src/coder_eval/pricing.py +++ b/src/coder_eval/pricing.py @@ -1,10 +1,12 @@ """Model pricing for cost calculation. -Anthropic/OpenAI built-in rates; plugins contribute additional rates via +Anthropic/OpenAI/Google built-in rates; plugins contribute additional rates via ``register_pricing()``. Prices are per million tokens (MTok). -Source: https://www.anthropic.com/pricing +Sources: https://claude.com/pricing#api, https://developers.openai.com/api/docs/pricing, +https://ai.google.dev/gemini-api/docs/pricing (all verified 2026-07-29). """ +from collections.abc import Iterable from dataclasses import dataclass @@ -18,25 +20,33 @@ class ModelPricing: cache_read_per_mtok: float # prompt caching read -# Official Anthropic pricing as of 2025 +# Official vendor rate cards, verified 2026-07-29. # Key: CLI model name (before gateway mapping) _PRICING: dict[str, ModelPricing] = { - # Claude 4.8 / 4.7 / 4.6 / 4.5 / 4 Opus - "claude-opus-4-8": ModelPricing(15.0, 75.0, 18.75, 1.50), - "claude-opus-4-7": ModelPricing(15.0, 75.0, 18.75, 1.50), - "claude-opus-4-6": ModelPricing(15.0, 75.0, 18.75, 1.50), - "claude-opus-4-6-20250514": ModelPricing(15.0, 75.0, 18.75, 1.50), - "claude-opus-4-5-20251101": ModelPricing(15.0, 75.0, 18.75, 1.50), + "claude-fable-5": ModelPricing(10.0, 50.0, 12.50, 1.0), + # Opus 4.5 and later dropped to $5/$25; 4.1 and 4 keep the old $15/$75. The + # version boundary is the price boundary: a newer Opus is not the dearer one. + "claude-opus-5": ModelPricing(5.0, 25.0, 6.25, 0.50), + "claude-opus-4-8": ModelPricing(5.0, 25.0, 6.25, 0.50), + "claude-opus-4-7": ModelPricing(5.0, 25.0, 6.25, 0.50), + "claude-opus-4-6": ModelPricing(5.0, 25.0, 6.25, 0.50), + "claude-opus-4-5": ModelPricing(5.0, 25.0, 6.25, 0.50), + "claude-opus-4-5-20251101": ModelPricing(5.0, 25.0, 6.25, 0.50), + "claude-opus-4-1": ModelPricing(15.0, 75.0, 18.75, 1.50), + "claude-opus-4": ModelPricing(15.0, 75.0, 18.75, 1.50), "claude-opus-4-20250514": ModelPricing(15.0, 75.0, 18.75, 1.50), - # Claude 5 Sonnet (2026-06-30 on Bedrock): standard $3/$15 (promo $2/$10 thru 2026-08-31). + # Standard $3/$15, not the $2/$10 promo running through 2026-08-31: a static + # table cannot express a window, and overstating for a few weeks beats + # understating indefinitely after it lapses. "claude-sonnet-5": ModelPricing(3.0, 15.0, 3.75, 0.30), - # Claude 4.6 / 4.5 / 4 Sonnet "claude-sonnet-4-6": ModelPricing(3.0, 15.0, 3.75, 0.30), - "claude-sonnet-4-6-20250514": ModelPricing(3.0, 15.0, 3.75, 0.30), + "claude-sonnet-4-5": ModelPricing(3.0, 15.0, 3.75, 0.30), "claude-sonnet-4-5-20250929": ModelPricing(3.0, 15.0, 3.75, 0.30), "claude-sonnet-4-20250514": ModelPricing(3.0, 15.0, 3.75, 0.30), - # Claude 4.5 Haiku - "claude-haiku-4-5-20251001": ModelPricing(0.80, 4.0, 1.0, 0.08), + # $1/$5. Not $0.80/$4 — those are Haiku 3.5's rates. + "claude-haiku-4-5": ModelPricing(1.0, 5.0, 1.25, 0.10), + "claude-haiku-4-5-20251001": ModelPricing(1.0, 5.0, 1.25, 0.10), + "claude-haiku-3-5": ModelPricing(0.80, 4.0, 1.0, 0.08), # Claude 3.7 Sonnet "claude-3-7-sonnet-20250219": ModelPricing(3.0, 15.0, 3.75, 0.30), # Claude 3.5 Sonnet @@ -48,60 +58,58 @@ class ModelPricing: "claude-3-sonnet-20240229": ModelPricing(3.0, 15.0, 3.75, 0.30), # Claude 3 Haiku "claude-3-haiku-20240307": ModelPricing(0.25, 1.25, 0.30, 0.03), - # OpenAI GPT-5 / Codex (direct or via Azure OpenAI). Released 2025-09-23. - # Source: https://openai.com/api/pricing (gpt-5-codex: $1.25/M in, - # $0.125/M cached in, $10/M out). OpenAI does not bill cache writes - # separately, so cache_write == input rate. + # OpenAI GPT-5 / Codex (direct or via Azure OpenAI). OpenAI bills no separate + # cache-write fee, so cache_write == input on every entry below. "gpt-5-codex": ModelPricing(1.25, 10.0, 1.25, 0.125), "gpt-5": ModelPricing(1.25, 10.0, 1.25, 0.125), - # gpt-5.3-codex (2026-02-24): $1.75/M input, $0.175/M cached, $14/M output. + "gpt-5.1-codex-max": ModelPricing(1.25, 10.0, 1.25, 0.125), + "gpt-5.1-codex": ModelPricing(1.25, 10.0, 1.25, 0.125), + "gpt-5.1-codex-mini": ModelPricing(0.25, 2.0, 0.25, 0.025), + # The one OpenAI entry whose cached rate is 25% of input, not 10%. + "codex-mini-latest": ModelPricing(1.50, 6.0, 1.50, 0.375), "gpt-5.3-codex": ModelPricing(1.75, 14.0, 1.75, 0.175), - # gpt-5.4 (2026-03-05): $2.50/M input, $0.25/M cached, $15/M output. + "gpt-5.2-codex": ModelPricing(1.75, 14.0, 1.75, 0.175), "gpt-5.4": ModelPricing(2.5, 15.0, 2.5, 0.25), - # gpt-5.5: $5/M input, $0.50/M cached, $30/M output. - # CAVEAT: this flat rate does NOT model gpt-5.5's long-context surcharge - # (2x input / 1.5x output once a session exceeds 272K input tokens), so cost - # for very-large-context runs reads low. Fine for typical eval tasks; revisit - # if benchmarking large-context Codex runs. + # CAVEAT: flat rate, so gpt-5.5's long-context surcharge (2x input / 1.5x + # output past 272K input tokens) is not modelled and reads low. "gpt-5.5": ModelPricing(5.0, 30.0, 5.0, 0.50), - # gpt-5.5-pro / gpt-5.4-pro: $30/M in, $180/M out; pro tiers offer NO prompt - # caching (cache_read nominal, never billed since pro doesn't cache). + # Pro tiers offer no prompt caching, so cache_read is nominal. "gpt-5.5-pro": ModelPricing(30.0, 180.0, 30.0, 3.0), "gpt-5.4-pro": ModelPricing(30.0, 180.0, 30.0, 3.0), - # gpt-5.4 economy tiers: mini $0.75/$4.50, nano $0.20/$1.25. "gpt-5.4-mini": ModelPricing(0.75, 4.5, 0.75, 0.075), "gpt-5.4-nano": ModelPricing(0.20, 1.25, 0.20, 0.02), - # GPT-5.6 family (2026-07-09): sol flagship / terra balanced (Codex default) / luna - # economy. Terra matches gpt-5.4's rate; sol matches gpt-5.5. - "gpt-5.6-sol": ModelPricing(5.0, 30.0, 6.25, 0.50), - "gpt-5.6-terra": ModelPricing(2.5, 15.0, 3.125, 0.25), - "gpt-5.6-luna": ModelPricing(1.0, 6.0, 1.25, 0.10), - # Google Gemini (AntigravityAgent, via the Gemini Developer API). Per-MTok - # rates from ai.google.dev/gemini-api/docs/pricing (2026). Gemini bills no - # separate cache-WRITE fee, so cache_write == input (the agent maps - # cache_creation_tokens to 0, so this value is effectively unused); cache_read - # is the cached-input rate (~10% of input). CAVEAT: Pro's >200K-token tier is - # higher ($4/$18); this flat rate reads low for very-large-context runs — fine - # for typical eval tasks. Keyed on the bare model id in agent.model. - "gemini-3-pro-preview": ModelPricing(2.0, 12.0, 2.0, 0.20), + # GPT-5.6: sol flagship / terra balanced (Codex default) / luna economy. + "gpt-5.6-sol": ModelPricing(5.0, 30.0, 5.0, 0.50), + "gpt-5.6-terra": ModelPricing(2.5, 15.0, 2.5, 0.25), + "gpt-5.6-luna": ModelPricing(1.0, 6.0, 1.0, 0.10), + # Google Gemini (AntigravityAgent, via the Gemini Developer API), keyed on the + # literal ids the ListModels endpoint returns. No cache-write fee, so + # cache_write == input (unused: the agent maps cache_creation_tokens to 0). + # CAVEAT: Pro's >200K-token tier costs more ($4/$18, $0.40 cached), so a + # very-large-context run reads low. + "gemini-3.6-flash": ModelPricing(1.5, 7.5, 1.5, 0.15), + "gemini-3.5-flash": ModelPricing(1.5, 9.0, 1.5, 0.15), + "gemini-3.5-flash-lite": ModelPricing(0.30, 2.5, 0.30, 0.03), "gemini-3.1-pro-preview": ModelPricing(2.0, 12.0, 2.0, 0.20), "gemini-3.1-pro-preview-customtools": ModelPricing(2.0, 12.0, 2.0, 0.20), - "gemini-3.5-flash": ModelPricing(1.5, 9.0, 1.5, 0.15), - "gemini-3-flash-preview": ModelPricing(1.5, 9.0, 1.5, 0.15), - # Open-weight models on Bedrock (eu-north-1), driven via the LiteLLM backend. - # Bedrock lists no prompt-cache read/write rate for these, so cache-creation - # is priced at the input rate and cache-read at 0 (conservative — see the - # per-provider cost-accounting caveat; revisit against the AWS model cards). + "gemini-3.1-flash-lite": ModelPricing(0.25, 1.5, 0.25, 0.025), + "gemini-3.1-flash-lite-preview": ModelPricing(0.25, 1.5, 0.25, 0.025), + "gemini-3-flash-preview": ModelPricing(0.50, 3.0, 0.50, 0.05), + # Off the public card (superseded by 3.1 Pro); last published rate kept so + # historical runs still price. + "gemini-3-pro-preview": ModelPricing(2.0, 12.0, 2.0, 0.20), + # Open-weight models on Bedrock, driven via the LiteLLM backend. These are the + # eu-north-1 rates, a ~20% premium over us-east-1 — do NOT "correct" them + # against the US column. Bedrock publishes no prompt-cache rate for these, so + # cache-creation is priced at input and cache-read at 0. "deepseek.v3.2": ModelPricing(0.74, 2.22, 0.74, 0.0), "zai.glm-5": ModelPricing(1.2, 3.84, 1.2, 0.0), "moonshotai.kimi-k2.5": ModelPricing(0.72, 3.6, 0.72, 0.0), - # OpenRouter models (cost-optimization path). These providers cache prompt - # prefixes IMPLICITLY (no cache_control, no cache-write fee), so cache-creation - # is priced at input (unused — cache_creation_tokens is always 0) and cache-read - # at OpenRouter's published input_cache_read rate. Rates per OpenRouter's - # /models endpoint (per-token x 1e6). + # OpenRouter models. These providers cache prefixes implicitly (no + # cache_control, no write fee), so cache-creation is priced at input (unused) + # and cache-read at OpenRouter's published input_cache_read rate. "moonshotai/kimi-k3": ModelPricing(3.0, 15.0, 3.0, 0.30), - "z-ai/glm-5.2": ModelPricing(0.826, 2.596, 0.826, 0.1534), + "z-ai/glm-5.2": ModelPricing(0.7168, 2.2528, 0.7168, 0.13312), "deepseek/deepseek-v4-pro": ModelPricing(0.435, 0.87, 0.435, 0.003625), } @@ -174,6 +182,20 @@ def _normalize_model(model: str) -> str: return model +def is_priced(model: str) -> bool: + """Whether the rate card can price this model (after prefix normalization).""" + return _lookup_rate(_normalize_model(model)) is not None + + +def unpriced_models(models: Iterable[str | None]) -> list[str]: + """Sorted, de-duplicated models from ``models`` that the rate card can't price. + + Falsy entries are dropped: an unpinned model resolves at the route level, which + a pre-flight check cannot see. + """ + return sorted({m for m in models if m and not is_priced(m)}) + + def calculate_cost( model: str, uncached_input_tokens: int, diff --git a/src/coder_eval/reports.py b/src/coder_eval/reports.py index 1fabbb17..743dcf92 100644 --- a/src/coder_eval/reports.py +++ b/src/coder_eval/reports.py @@ -15,6 +15,9 @@ SuiteRollup, TaskResult, ThresholdCheck, + eval_overhead_cost, + row_cost_incomplete, + sum_costs, ) from .path_utils import build_task_run_dir @@ -158,6 +161,22 @@ def _iteration_of(t: dict[str, Any]) -> int | None: return total, recovered, terminal +def _fmt_rate(rate: float | None) -> str: + """Render a 0-1 rate as a percentage, or ``n/a`` when it is unknown.""" + return f"{rate * 100:.1f}%" if rate is not None else "n/a" + + +def _pass_rate_lines(summary: RunSummary) -> list[str]: + """The pass rate over every dispatched task, plus the error share when non-zero.""" + lines = [f"- **Pass Rate**: {_fmt_rate(summary.pass_rate)} ({summary.tasks_succeeded}/{summary.tasks_run})"] + if summary.tasks_error: + lines.append( + f"- **Error Share**: {_fmt_rate(summary.error_share)} of tasks never produced a " + + "gradeable attempt and count as misses" + ) + return lines + + class ReportGenerator: """Generates reports from evaluation results.""" @@ -286,9 +305,6 @@ def _summary_section_lines(summary: RunSummary) -> list[str]: Returns a ``## Header``-led block with no leading blank (caller prepends it). """ - evaluable = summary.tasks_run - summary.tasks_error - success_rate = (summary.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0 - failed_line = f"- **Failed**: {summary.tasks_failed}" if summary.tasks_token_budget_exceeded or summary.tasks_cost_budget_exceeded: failed_line += ( @@ -303,7 +319,7 @@ def _summary_section_lines(summary: RunSummary) -> list[str]: f"- **Succeeded**: {summary.tasks_succeeded}", failed_line, f"- **Errors**: {summary.tasks_error}", - f"- **Success Rate**: {success_rate:.1f}%", + *_pass_rate_lines(summary), ] # Aggregate P0 metrics @@ -527,14 +543,34 @@ def _generate_token_usage_section(task_results: list[dict[str, Any]]) -> list[st total_cache_write = sum(t.get("cache_creation_input_tokens") or 0 for t in tasks_with_tokens) total_cache_read = sum(t.get("cache_read_input_tokens") or 0 for t in tasks_with_tokens) total_tokens = sum(t["total_tokens"] for t in tasks_with_tokens) - costs = [t["total_cost_usd"] for t in tasks_with_tokens if t.get("total_cost_usd") is not None] - total_cost = sum(costs) if costs else None + agent_cost = sum_costs(*(t.get("agent_cost_usd") for t in tasks_with_tokens)) + + # Same helpers RunSummary uses, so the report and run.json cannot disagree + # about the bill. Worded cause-agnostically ("spend missing") because an + # unpriced turn and a hard kill reach the same conclusion and the report + # cannot always tell which applied. + incomplete = [t for t in task_results if row_cost_incomplete(t)] + overhead = eval_overhead_cost(task_results) + total_cost = sum_costs(*(t.get("total_cost_usd") for t in task_results)) lines.append(f"**Total Tokens**: {total_tokens:,} (input: {total_input:,}, output: {total_output:,})") if total_cache_write > 0 or total_cache_read > 0: lines.append(f"**Cache Tokens**: write: {total_cache_write:,}, read: {total_cache_read:,}") + # The agent bill is broken out separately only when there is overhead to + # distinguish it from: judge spend is a property of the suite's criteria and + # identical across harnesses, so comparing harnesses means comparing the + # agent line. **Total Cost** always means the whole bill. + if overhead is not None: + if agent_cost is not None: + lines.append(f"**Agent Cost**: ${agent_cost:.4f}") + lines.append(f"**Eval Overhead (judge + simulator)**: ${overhead:.4f}") if total_cost is not None: - lines.append(f"**Total Cost**: ${total_cost:.4f}") + cost_line = f"**Total Cost**: ${total_cost:.4f}" + if incomplete: + cost_line += f" (floor — {len(incomplete)} task(s) have spend missing from this total)" + lines.append(cost_line) + elif incomplete: + lines.append(f"**Total Cost**: unavailable — {len(incomplete)} task(s) have spend missing from this total") lines.append(f"**Avg Tokens/Task**: {total_tokens // len(tasks_with_tokens):,}") lines.append("") @@ -551,6 +587,7 @@ def _generate_token_usage_section(task_results: list[dict[str, Any]]) -> list[st cache_write = t.get("cache_creation_input_tokens") or 0 cache_read = t.get("cache_read_input_tokens") or 0 tokens = t.get("total_tokens", 0) + # The row's whole bill, so the column sums to **Total Cost** above. cost = t.get("total_cost_usd") cost_str = f"${cost:.4f}" if cost is not None else "N/A" row = ( diff --git a/src/coder_eval/reports_experiment.py b/src/coder_eval/reports_experiment.py index cafe9e4c..e362d271 100644 --- a/src/coder_eval/reports_experiment.py +++ b/src/coder_eval/reports_experiment.py @@ -6,11 +6,16 @@ from pathlib import Path from typing import Any +from coder_eval.errors import truncate_crash_message from coder_eval.models import ( EvaluationResult, ExperimentDefinition, ExperimentResult, + FinalStatus, TaskExperimentSummary, + judge_cost_usd, + simulator_cost_usd, + sum_costs, ) from coder_eval.path_utils import replicate_subdir_name from coder_eval.reports import resolve_agent_settings @@ -34,6 +39,36 @@ # Default pass_threshold from BaseSuccessCriterion — used for Wilson pass-rate in replicate stats. _REPLICATE_PASS_THRESHOLD = 0.9 +# Cap on the ``error_message`` carried into each run.json row: enough to identify +# a failure without fetching the task artifact, short enough that a wholly-errored +# run doesn't bloat run.json. The untruncated message stays on task.json. +_ROW_ERROR_MESSAGE_MAX_CHARS = 400 + + +def _cost_complete(result: EvaluationResult) -> bool: + """Whether this row's recorded agent spend accounts for everything it spent. + + False means the costs on the row are a floor, not the bill. Two ways in: + + 1. A turn burned tokens the rate card could not price. The card is the fallback + for anything the backend did not price itself, so with no rate those tokens + book no money. + 2. The task was hard-killed by the task-level timeout. Keyed on the status + rather than on emptiness: the watchdog fires while the evaluation loop is + running, so a TIMEOUT row always lost an in-flight turn, even one that + completed earlier turns that do carry costs. + + True for a row that burned nothing: an error before the agent ran genuinely + cost zero, and a slow setup failure is as free as a fast one. + """ + if result.final_status is FinalStatus.TIMEOUT: + return False + return all( + usage.total_cost_usd is not None + for t in result.iterations + if (usage := t.token_usage) is not None and not usage.is_empty() + ) + # --------------------------------------------------------------------------- # Helper: build task_result dict from EvaluationResult (for variant reports) @@ -84,6 +119,11 @@ def eval_result_to_task_dict( # per-task content. has_reply = _has_final_reply(result) + agent_cost = result.total_token_usage.total_cost_usd if result.total_token_usage else None + judge_cost = judge_cost_usd(result) + simulator_cost = simulator_cost_usd(result) + row_total_cost = sum_costs(agent_cost, judge_cost, simulator_cost) + expected_turns_value: int | None = None if result.task_config is not None: rl = (result.task_config.resolved or {}).get("run_limits") or {} @@ -123,7 +163,30 @@ def eval_result_to_task_dict( result.total_token_usage.cache_read_input_tokens if result.total_token_usage else None ), "total_tokens": (result.total_token_usage.total_tokens if result.total_token_usage else None), - "total_cost_usd": (result.total_token_usage.total_cost_usd if result.total_token_usage else None), + # What the task cost: agent + judge + simulator. `total_cost_usd` means the + # whole bill on every surface, so a consumer that reads it gets the real + # number without adding anything up. None when nothing was priced at all. + "total_cost_usd": row_total_cost, + # Subject-agent spend alone, broken out for harness-vs-harness comparison: + # judge cost is a property of the suite's criteria and identical across + # harnesses, so leaving it in would make two harnesses look closer than they + # are. Rolled up as RunSummary.agent_cost_usd. + "agent_cost_usd": agent_cost, + # False when the agent spend above is missing money, so it is a floor. + # Rolled up as RunSummary.tasks_cost_incomplete / cost_complete. + "cost_complete": _cost_complete(result), + # The two halves of the eval-machinery bill, rolled up as + # RunSummary.eval_overhead_cost_usd. + "judge_cost_usd": judge_cost, + "simulator_cost_usd": simulator_cost, + # Errors count as misses, so the rollup has to say why it lost those points. + # Without these, triaging an errored run needs one task.json fetch per row. + "error_message": ( + truncate_crash_message(result.error_message, limit=_ROW_ERROR_MESSAGE_MAX_CHARS) + if result.error_message + else None + ), + "error_category": (result.error_details or {}).get("error_category"), "expected_commands": result.expected_commands, "actual_commands": result.actual_commands, "commands_efficiency": result.commands_efficiency, @@ -276,13 +339,11 @@ def _aggregate_count_rows(result: ExperimentResult, show_p_values: bool) -> list row += " | —" lines.append(row + " |") - # Row: Success Rate (errors excluded from denominator — they're infrastructure failures, not task failures) - row = "| Success Rate" + # Every task the variant ran is in the denominator, errors included. + row = "| Pass Rate" for vid in result.variant_ids: - agg = result.variant_aggregates[vid] - evaluable = agg.tasks_run - agg.tasks_error - rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0 - row += f" | {rate:.1f}%" + rate = result.variant_aggregates[vid].pass_rate + row += f" | {rate * 100:.1f}%" if rate is not None else " | n/a" if show_p_values: row += " | —" lines.append(row + " |") @@ -539,8 +600,7 @@ def generate_variant_report(variant_id: str, result: ExperimentResult, run_dir: from coder_eval.reports import ReportGenerator agg = result.variant_aggregates[variant_id] - evaluable = agg.tasks_run - agg.tasks_error - success_rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0 + pass_rate_str = f"{agg.pass_rate * 100:.1f}%" if agg.pass_rate is not None else "n/a" tokens_str = f"{agg.total_tokens:,}" if agg.total_tokens is not None else "N/A" failed_line = f"- **Failed**: {agg.tasks_failed}" @@ -562,7 +622,7 @@ def generate_variant_report(variant_id: str, result: ExperimentResult, run_dir: f"- **Succeeded**: {agg.tasks_succeeded}", failed_line, f"- **Errors**: {agg.tasks_error}", - f"- **Success Rate**: {success_rate:.1f}%", + f"- **Pass Rate**: {pass_rate_str} ({agg.tasks_succeeded}/{agg.tasks_run})", f"- **Average Score**: {agg.average_score:.3f}", f"- **Average Duration**: {agg.average_duration:.1f}s", f"- **Total Tokens**: {tokens_str}", diff --git a/src/coder_eval/reports_html.py b/src/coder_eval/reports_html.py index a6f74991..3fef5d12 100644 --- a/src/coder_eval/reports_html.py +++ b/src/coder_eval/reports_html.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from coder_eval.models import FinalStatus +from coder_eval.models import FinalStatus, eval_result_total_cost, sum_costs if TYPE_CHECKING: @@ -335,8 +335,9 @@ def _render_header(result: EvaluationResult) -> str: subtitle = _esc(result.task_description.strip().splitlines()[0] if result.task_description else "") turns_count = result.total_assistant_turns or 0 cost_badge = "" - if result.total_token_usage and result.total_token_usage.total_cost_usd is not None: - cost_badge = f'${result.total_token_usage.total_cost_usd:.4f}' + task_cost = eval_result_total_cost(result) + if task_cost is not None: + cost_badge = f'${task_cost:.4f}' expected_turns_badge = "" overage = expected_turns_overage(result) if overage is not None: @@ -870,12 +871,13 @@ def _render_error_details(result: EvaluationResult) -> str: def _render_token_usage(result: EvaluationResult) -> str: - """Render Token Usage section — totals + cost.""" + """Render Token Usage section — totals + cost (agent + judge + simulator).""" tu = result.total_token_usage if tu is None: return "" total = tu.total_tokens - cost_str = f"${tu.total_cost_usd:.4f}" if tu.total_cost_usd is not None else "N/A" + task_cost = eval_result_total_cost(result) + cost_str = f"${task_cost:.4f}" if task_cost is not None else "N/A" uncached_fmt = f"{tu.uncached_input_tokens:,}" cache_write_fmt = f"{tu.cache_creation_input_tokens:,}" cache_read_fmt = f"{tu.cache_read_input_tokens:,}" @@ -1167,7 +1169,7 @@ def _render_variant_generation_metrics(eval_results: list[EvaluationResult]) -> def _render_variant_token_usage(eval_results: list[EvaluationResult]) -> str: - """Aggregate token usage across all tasks in a variant.""" + """Aggregate token usage across all tasks in a variant. Cost is the whole bill.""" usages = [r.total_token_usage for r in eval_results if r.total_token_usage is not None] if not usages: return "" @@ -1176,8 +1178,8 @@ def _render_variant_token_usage(eval_results: list[EvaluationResult]) -> str: cache_write = sum(u.cache_creation_input_tokens for u in usages) cache_read = sum(u.cache_read_input_tokens for u in usages) total = input_tok + output_tok + cache_write + cache_read - costs = [u.total_cost_usd for u in usages if u.total_cost_usd is not None] - cost_str = f"${sum(costs):.4f}" if costs else "N/A" + variant_cost = sum_costs(*(eval_result_total_cost(r) for r in eval_results)) + cost_str = f"${variant_cost:.4f}" if variant_cost is not None else "N/A" return f"""