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"""

Token Usage

@@ -1320,13 +1322,11 @@ def _row(label: str, values: list[str], p: str | None) -> str: rows.append(_row("Failed", [str(result.variant_aggregates[vid].tasks_failed) for vid in result.variant_ids], None)) rows.append(_row("Errors", [str(result.variant_aggregates[vid].tasks_error) for vid in result.variant_ids], None)) - def _success_rate(vid: str) -> str: - agg = result.variant_aggregates[vid] - evaluable = agg.tasks_run - agg.tasks_error - rate = (agg.tasks_succeeded / evaluable * 100) if evaluable > 0 else 0.0 - return f"{rate:.1f}%" + def _pass_rate(vid: str) -> str: + rate = result.variant_aggregates[vid].pass_rate + return f"{rate * 100:.1f}%" if rate is not None else "n/a" - rows.append(_row("Success Rate", [_success_rate(vid) for vid in result.variant_ids], None)) + rows.append(_row("Pass Rate", [_pass_rate(vid) for vid in result.variant_ids], None)) rows.append( _row( diff --git a/tests/_fixtures/report_snapshots/experiment_2variant.md b/tests/_fixtures/report_snapshots/experiment_2variant.md index e19649fd..2cf11a74 100644 --- a/tests/_fixtures/report_snapshots/experiment_2variant.md +++ b/tests/_fixtures/report_snapshots/experiment_2variant.md @@ -19,7 +19,7 @@ | - Token budget | 1 | 0 | — | | - Cost budget | 0 | 1 | — | | Errors | 0 | 0 | — | -| Success Rate | 50.0% | 50.0% | — | +| Pass Rate | 50.0% | 50.0% | — | | Score | 0.700 ± 0.283 | 0.825 ± 0.177 | 0.658 | | Avg Duration (s) | 35.0 ± 7.1 | 55.0 ± 7.1 | 0.106 | | Assistant Turns | 5.5 ± 0.7 | 7.5 ± 0.7 | 0.106 | diff --git a/tests/_fixtures/report_snapshots/experiment_3variant.md b/tests/_fixtures/report_snapshots/experiment_3variant.md index ff93b009..40ede665 100644 --- a/tests/_fixtures/report_snapshots/experiment_3variant.md +++ b/tests/_fixtures/report_snapshots/experiment_3variant.md @@ -12,7 +12,7 @@ | Succeeded | 1 | 1 | 1 | | Failed | 1 | 1 | 1 | | Errors | 0 | 0 | 0 | -| Success Rate | 50.0% | 50.0% | 50.0% | +| Pass Rate | 50.0% | 50.0% | 50.0% | | Score | 0.650 ± 0.354 | 0.775 ± 0.247 | 0.650 ± 0.212 | | Avg Duration (s) | 21.0 ± 1.4 | 26.5 ± 2.1 | 31.5 ± 2.1 | | Assistant Turns | 3.5 ± 0.7 | 6.0 ± 1.4 | 5.5 ± 0.7 | diff --git a/tests/_fixtures/report_snapshots/experiment_replicates.md b/tests/_fixtures/report_snapshots/experiment_replicates.md index dfcd8988..73333d07 100644 --- a/tests/_fixtures/report_snapshots/experiment_replicates.md +++ b/tests/_fixtures/report_snapshots/experiment_replicates.md @@ -12,7 +12,7 @@ | Succeeded | 1 | 1 | — | | Failed | 0 | 0 | — | | Errors | 0 | 0 | — | -| Success Rate | 100.0% | 100.0% | — | +| Pass Rate | 100.0% | 100.0% | — | | Score | 0.900 | 0.650 | — | | Avg Duration (s) | 3.3 | 3.3 | — | | Tokens | 1,000 | 1,000 | — | diff --git a/tests/_fixtures/report_snapshots/run_full.md b/tests/_fixtures/report_snapshots/run_full.md index 1b9dc828..3e14b5ec 100644 --- a/tests/_fixtures/report_snapshots/run_full.md +++ b/tests/_fixtures/report_snapshots/run_full.md @@ -11,7 +11,7 @@ - **Succeeded**: 1 - **Failed**: 1 (incl. 1 token budget, 0 cost budget exceeded) - **Errors**: 0 -- **Success Rate**: 50.0% +- **Pass Rate**: 50.0% (1/2) - **Avg Reliability Score**: 0.625 - **Avg Generation Latency**: 10.2s - **Total Assistant Turns**: 4 diff --git a/tests/_fixtures/report_snapshots/run_minimal.md b/tests/_fixtures/report_snapshots/run_minimal.md index 87969535..be0e8308 100644 --- a/tests/_fixtures/report_snapshots/run_minimal.md +++ b/tests/_fixtures/report_snapshots/run_minimal.md @@ -10,7 +10,7 @@ - **Succeeded**: 0 - **Failed**: 0 - **Errors**: 0 -- **Success Rate**: 0.0% +- **Pass Rate**: n/a (0/0) ## Task Details diff --git a/tests/test_agent_timeout.py b/tests/test_agent_timeout.py index bd9fff7b..5777dea0 100644 --- a/tests/test_agent_timeout.py +++ b/tests/test_agent_timeout.py @@ -258,3 +258,60 @@ async def mock_query(prompt, options): ): await agent.communicate("prompt") # no timeout mock_transport_cls.assert_not_called() + + +class _MockTextBlock: + def __init__(self, text: str) -> None: + self.text = text + + +class _MockAssistantMessage: + """Duck-typed SDK AssistantMessage carrying one text block and a usage dict.""" + + def __init__(self, text: str, usage: dict[str, int]) -> None: + self.content = [_MockTextBlock(text)] + self.model = "claude-sonnet-5" + self.usage = usage + self.stop_reason = "end_turn" + self.message_id = "msg_partial_1" + + +@pytest.mark.asyncio +async def test_external_cancel_parks_the_turn_it_interrupted(): + """A turn cancelled from outside must leave its telemetry on ``pending_turn``. + + The task-timeout kill path: the turn never returns a record and the frame that + held one unwinds, so the pending slot is the only place its spend can survive. + """ + config = parse_agent_config(type=AgentKind.CLAUDE_CODE, permission_mode="acceptEdits", model="claude-sonnet-5") + agent = ClaudeCodeAgent(config) + + with tempfile.TemporaryDirectory() as tmpdir: + await agent.start(tmpdir) + + streaming = asyncio.Event() + + async def mock_query(prompt, options): + yield _MockAssistantMessage("working on it", {"input_tokens": 40_000, "output_tokens": 2_000}) + streaming.set() + # Still mid-turn when the cancel lands, as on a real hard kill: no + # terminal ResultMessage is ever delivered. + await asyncio.sleep(30) + + with patch("coder_eval.agents.claude_code_agent.query", mock_query): + turn = asyncio.create_task(agent.communicate("prompt")) + await streaming.wait() + turn.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(turn, timeout=5) + + partial = agent.pending_turn + assert partial is not None, "the interrupted turn's record was discarded" + assert partial.crashed is True + usage = partial.token_usage + assert usage is not None + assert usage.output_tokens == 2_000 + # No ResultMessage means the backend never priced this turn, so the cost is + # backfilled from the buckets at the model's rate. + assert usage.total_cost_usd is not None + assert usage.total_cost_usd > 0 diff --git a/tests/test_antigravity_agent.py b/tests/test_antigravity_agent.py index 4ac68408..99747950 100644 --- a/tests/test_antigravity_agent.py +++ b/tests/test_antigravity_agent.py @@ -202,7 +202,11 @@ def test_tool_name_map_covers_core_builtins(): "gemini-3.1-pro-preview", "gemini-3.1-pro-preview-customtools", "gemini-3-pro-preview", + "gemini-3.6-flash", "gemini-3.5-flash", + "gemini-3.5-flash-lite", + "gemini-3.1-flash-lite", + "gemini-3.1-flash-lite-preview", "gemini-3-flash-preview", ], ) diff --git a/tests/test_cost_accounting_paths.py b/tests/test_cost_accounting_paths.py new file mode 100644 index 00000000..9193f7dc --- /dev/null +++ b/tests/test_cost_accounting_paths.py @@ -0,0 +1,362 @@ +"""Cost accounting on the error and timeout paths, where spend went missing. + +Two seams: the ``check_pricing_coverage`` pre-flight, which warns when a model the +run will use has no rate, and the ``eval_result_to_task_dict`` row projection, +which reports judge/simulator spend and flags a row whose costs are only partial. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest + +from coder_eval.models import ( + ClaudeCodeAgentConfig, + EvaluationResult, + FinalStatus, + JudgeCriterionResult, + ResolvedTask, + SimulationTelemetry, + TaskDefinition, + TokenUsage, + TurnRecord, +) +from coder_eval.orchestration.batch import check_pricing_coverage +from coder_eval.reports_experiment import eval_result_to_task_dict + + +def _resolved(model: str | None, tmp_path: Path, criteria: list[dict[str, Any]] | None = None) -> ResolvedTask: + task = TaskDefinition( + task_id="t1", + description="d", + initial_prompt="p", + agent=ClaudeCodeAgentConfig(type="claude-code", model=model), + sandbox={"driver": "tempdir"}, + success_criteria=criteria or [{"type": "file_exists", "path": "f.py", "description": "d"}], + ) + return ResolvedTask( + task=task, + task_file=tmp_path / "t1.yaml", + run_dir=tmp_path / "runs" / "t1", + variant_id="default", + ) + + +def _turn(iteration: int, usage: TokenUsage | None, model: str | None = None, crashed: bool = False) -> TurnRecord: + return TurnRecord( + iteration=iteration, + user_input="p", + agent_output="o", + token_usage=usage, + model_used=model, + crashed=crashed, + ) + + +def _result(turns: list[TurnRecord], *, model: str | None = "claude-sonnet-5", **extra) -> EvaluationResult: + return EvaluationResult( + task_id="t1", + task_description="d", + agent_type="claude-code", + model_used=model, + started_at=datetime(2026, 7, 28, 0, 0, 0), + final_status=FinalStatus.SUCCESS, + iteration_count=len(turns), + iterations=turns, + **extra, + ) + + +class TestPricingPreflight: + def test_priced_model_is_silent(self, tmp_path, caplog): + with caplog.at_level(logging.WARNING): + assert check_pricing_coverage([_resolved("claude-sonnet-5", tmp_path)]) == [] + assert "No pricing rate" not in caplog.text + + def test_unpriced_model_warns_but_runs(self, tmp_path, caplog): + """Default is a loud warning, not a refusal: a brand-new model stays evaluable.""" + with caplog.at_level(logging.WARNING): + missing = check_pricing_coverage([_resolved("claude-sonnet-99", tmp_path)]) + assert missing == ["claude-sonnet-99"] + assert "No pricing rate" in caplog.text + assert "understate the bill" in caplog.text + + def test_unpinned_model_is_not_flagged(self, tmp_path): + """A task deferring its model to the route can't be pre-flighted from here.""" + assert check_pricing_coverage([_resolved(None, tmp_path)]) == [] + + def test_judge_criterion_model_is_pre_flighted(self, tmp_path): + """A judge call is ALWAYS priced from the rate card, so its model matters most. + + No judge backend reports a cost, unlike an agent whose SDK prices a clean + turn, so an unpriced ``criterion.model`` loses money on every graded row. + """ + judge = {"type": "llm_judge", "description": "d", "prompt": "grade it", "model": "claude-sonnet-99"} + assert check_pricing_coverage([_resolved("claude-sonnet-5", tmp_path, [judge])]) == ["claude-sonnet-99"] + + def test_default_judge_model_is_priced(self, tmp_path): + """The out-of-the-box judge must not warn on a stock task.""" + judge = {"type": "llm_judge", "description": "d", "prompt": "grade it"} + assert check_pricing_coverage([_resolved("claude-sonnet-5", tmp_path, [judge])]) == [] + + +class TestRowCostProjection: + def test_cost_complete_false_when_a_turn_is_unpriced(self): + result = _result( + [ + _turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1)), + _turn(2, TokenUsage(uncached_input_tokens=10, output_tokens=1)), + ] + ) + assert eval_result_to_task_dict(result)["cost_complete"] is False + + def test_cost_complete_true_when_every_burning_turn_is_priced(self): + result = _result([_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1))]) + assert eval_result_to_task_dict(result)["cost_complete"] is True + + def test_cost_complete_true_when_nothing_burned(self): + """An error before the agent ran genuinely cost nothing — not 'missing cost'.""" + assert eval_result_to_task_dict(_result([]))["cost_complete"] is True + assert eval_result_to_task_dict(_result([_turn(1, TokenUsage())]))["cost_complete"] is True + + def test_cost_complete_false_when_a_task_timeout_preserved_no_turn(self): + """A task timeout with zero turns is unrecorded spend, not free. + + The watchdog SIGKILLs the agent by PID, so unlike a turn-level timeout this + never reaches ``_on_attempt_failure`` and no partial turn is drained. The + row lands with no turns, no tokens and no cost. Since a task timeout means + the evaluation loop was still running, calling that row fully priced would + be a false claim — the one case where cost is missing with no tokens to + point at. + """ + result = _result([]) + result.final_status = FinalStatus.TIMEOUT + assert eval_result_to_task_dict(result)["cost_complete"] is False + + def test_recovered_timeout_row_reports_its_cost_and_still_flags_incomplete(self): + """Recovering the killed turn narrows the gap; it does not close it. + + The interrupted turn is drained onto the row and priced, so the row carries + a real number instead of nothing. The generation the agent was waiting on + when it died was never delivered by anyone, so the number is still a floor. + """ + killed = _turn(1, TokenUsage(uncached_input_tokens=40_000, output_tokens=2_000, total_cost_usd=0.15)) + killed.crashed = True + result = _result([killed]) + result.final_status = FinalStatus.TIMEOUT + result.total_token_usage = TokenUsage(uncached_input_tokens=40_000, output_tokens=2_000, total_cost_usd=0.15) + + row = eval_result_to_task_dict(result) + assert row["total_cost_usd"] == pytest.approx(0.15) + assert row["cost_complete"] is False + + def test_cost_complete_true_for_a_fast_error_with_no_turn(self): + """The companion: a setup failure has no turns either, and really is free. + + Keyed on status rather than elapsed time so a slow setup failure stays free + while a task timeout never does. + """ + result = _result([]) + result.final_status = FinalStatus.ERROR + assert eval_result_to_task_dict(result)["cost_complete"] is True + + def test_judge_cost_rolls_up_onto_the_row(self): + """Judge spend was captured per criterion and totalled nowhere.""" + result = _result([_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1))]) + result.total_token_usage = TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1) + result.success_criteria_results = [ + JudgeCriterionResult( + criterion_type="llm_judge", + description="d", + score=1.0, + token_usage=TokenUsage(uncached_input_tokens=5000, output_tokens=500, total_cost_usd=0.02), + ), + JudgeCriterionResult( + criterion_type="llm_judge", + description="d2", + score=1.0, + token_usage=TokenUsage(uncached_input_tokens=5000, output_tokens=500, total_cost_usd=0.03), + ), + ] + row = eval_result_to_task_dict(result) + assert row["judge_cost_usd"] == pytest.approx(0.05) + # Folded into the row's bill, and kept out of the agent-only slice. + assert row["total_cost_usd"] == pytest.approx(0.15) + assert row["agent_cost_usd"] == pytest.approx(0.1) + + def test_no_judge_means_no_judge_cost(self): + """None, not 0.0 — 'no judge ran' must stay distinct from 'a judge ran free'.""" + result = _result([_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1))]) + assert eval_result_to_task_dict(result)["judge_cost_usd"] is None + + def test_unpriced_judge_is_skipped_not_fatal(self): + """An unpriced judge lowers the total; it must never raise or zero the row. + + The pre-flight warns about this at dispatch. After that the run carries on + and the figures are a floor, which is the trade this framework makes + everywhere: cost degrades, the evaluation does not. + """ + result = _result([_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1))]) + result.total_token_usage = TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1) + result.success_criteria_results = [ + JudgeCriterionResult( + criterion_type="llm_judge", + description="priced", + score=1.0, + token_usage=TokenUsage(uncached_input_tokens=5000, output_tokens=500, total_cost_usd=0.02), + ), + JudgeCriterionResult( + criterion_type="llm_judge", + description="unpriced", + score=1.0, + token_usage=TokenUsage(uncached_input_tokens=5000, output_tokens=500), + ), + ] + row = eval_result_to_task_dict(result) + assert row["judge_cost_usd"] == pytest.approx(0.02) + assert row["total_cost_usd"] == pytest.approx(0.12) + + @staticmethod + def _simulated(**env) -> EvaluationResult: + return _result( + [_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1))], + simulation=SimulationTelemetry( + n_trials=1, + replicate_index=0, + stop_reason="stop_token", + simulator_input_tokens=1_000_000, + simulator_output_tokens=100_000, + total_turns=3, + ), + environment_info=env, + ) + + def test_simulator_prices_at_the_route_model_not_the_subject(self): + """UserSimulator pins model=None, so it bills at BEDROCK_MODEL. + + A task that pins ``agent.model`` would otherwise mis-bill every simulated + row. Here the subject is sonnet-5 ($3/$15) while the route is haiku-4.5 + ($1/$5), so the simulator must cost the haiku rate. + """ + result = self._simulated(bedrock_model="claude-haiku-4-5-20251001") + # 1M uncached input at $1/MTok + 100K output at $5/MTok. + assert eval_result_to_task_dict(result)["simulator_cost_usd"] == pytest.approx(1.00 + 0.50) + + def test_simulator_falls_back_to_the_subject_model_off_bedrock(self): + """A non-Bedrock route names no model on the record; the subject's is the best available.""" + result = self._simulated() + # sonnet-5 at $3/$15 per MTok. + assert eval_result_to_task_dict(result)["simulator_cost_usd"] == pytest.approx(3.0 + 1.5) + + def test_single_shot_row_has_no_simulator_cost(self): + result = _result([_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1))]) + assert eval_result_to_task_dict(result)["simulator_cost_usd"] is None + + def test_unpriced_simulator_route_is_skipped_not_fatal(self): + """An unpriced simulator route leaves the rest of the row intact.""" + result = self._simulated(bedrock_model="claude-sonnet-99") + result.total_token_usage = TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=0.1) + row = eval_result_to_task_dict(result) + assert row["simulator_cost_usd"] is None + # The agent's own spend still lands; only the simulator slice is missing. + assert row["total_cost_usd"] == pytest.approx(0.1) + + +class TestTotalCost: + """``total_cost_usd`` is the whole bill: agent + judge + simulator.""" + + def test_sums_every_component(self): + result = _result( + [_turn(1, TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=1.0))], + simulation=SimulationTelemetry( + n_trials=1, + replicate_index=0, + stop_reason="stop_token", + simulator_input_tokens=1_000_000, + simulator_output_tokens=0, + total_turns=2, + ), + environment_info={"bedrock_model": "claude-haiku-4-5-20251001"}, + ) + result.total_token_usage = TokenUsage(uncached_input_tokens=10, output_tokens=1, total_cost_usd=1.0) + result.success_criteria_results = [ + JudgeCriterionResult( + criterion_type="llm_judge", + description="d", + score=1.0, + token_usage=TokenUsage(uncached_input_tokens=5000, output_tokens=500, total_cost_usd=0.25), + ), + ] + row = eval_result_to_task_dict(result) + # 1.0 agent + 0.25 judge + 1M input at haiku's $1/MTok. + assert row["total_cost_usd"] == pytest.approx(2.25) + # The agent slice stays broken out so harnesses stay comparable. + assert row["agent_cost_usd"] == pytest.approx(1.0) + + def test_none_when_nothing_was_priced(self): + """Not 0.0 — an unpriced row must not read as a free one.""" + assert eval_result_to_task_dict(_result([]))["total_cost_usd"] is None + + def test_run_level_total_is_the_sum_of_both_halves(self): + from coder_eval.models import RunSummary + + summary = RunSummary( + run_id="2026-07-29_00-00-00", + start_time=datetime(2026, 7, 29, 0, 0, 0), + end_time=datetime(2026, 7, 29, 1, 0, 0), + total_duration_seconds=3600.0, + tasks_run=1, + tasks_succeeded=1, + tasks_failed=0, + tasks_error=0, + task_results=[ + { + "task_id": "t", + "total_cost_usd": 1.25, + "agent_cost_usd": 1.0, + "judge_cost_usd": 0.2, + "simulator_cost_usd": 0.05, + }, + ], + framework_version="0.0.0-test", + ) + assert summary.agent_cost_usd == pytest.approx(1.0) + assert summary.eval_overhead_cost_usd == pytest.approx(0.25) + assert summary.total_cost_usd == pytest.approx(1.25) + assert summary.model_dump()["total_cost_usd"] == pytest.approx(1.25) + + +class TestErrorDiagnosticsOnTheRow: + """Errors count as misses, so the rollup has to say why it lost those points. + + Without these, a run's zero-iteration errors cannot be characterised from + run.json at all: every one needs its own task.json fetch. + """ + + def test_error_message_and_category_land_on_the_row(self): + result = _result([], model=None) + result.final_status = FinalStatus.ERROR + result.error_message = "sandbox setup failed: no space left on device" + result.error_details = {"error_category": "disk_full", "component": "orchestrator.setup"} + + row = eval_result_to_task_dict(result) + assert row["error_message"] == "sandbox setup failed: no space left on device" + assert row["error_category"] == "disk_full" + + def test_error_message_is_truncated(self): + result = _result([], model=None) + result.final_status = FinalStatus.ERROR + result.error_message = "x" * 5000 + + row = eval_result_to_task_dict(result) + assert len(row["error_message"]) < 500 + assert row["error_message"].endswith("…") + + def test_clean_row_carries_no_error_fields(self): + row = eval_result_to_task_dict(_result([])) + assert row["error_message"] is None + assert row["error_category"] is None diff --git a/tests/test_experiment_reports.py b/tests/test_experiment_reports.py index de432278..3e212da5 100644 --- a/tests/test_experiment_reports.py +++ b/tests/test_experiment_reports.py @@ -529,7 +529,7 @@ def test_variant_report_content(self, tmp_path, sample_result): # New: variant report should include experiment context and summary assert "**Experiment**: model-comparison" in content assert "## Summary" in content - assert "Success Rate" in content + assert "Pass Rate" in content def test_variant_html_task_links_include_replicate_segment(self, tmp_path, sample_result): """variant.html task links must include the /00/ replicate segment so they @@ -641,7 +641,7 @@ def test_experiment_report_has_aggregate_metrics(self): assert "| Avg Duration (s) |" in md assert "| Tokens |" in md assert "| Tasks Run |" in md - assert "| Success Rate |" in md + assert "| Pass Rate |" in md # Two-variant comparison should show p-value column assert "p-value" in md diff --git a/tests/test_reports.py b/tests/test_reports.py index 8b741ddb..756a345f 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -212,7 +212,9 @@ def test_generate_markdown_basic(): assert "Succeeded**: 2" in report_md assert "Failed**: 1" in report_md assert "Errors**: 0" in report_md - assert "Success Rate**: 66.7%" in report_md + assert "Pass Rate**: 66.7% (2/3)" in report_md + # No errors in this run, so no error-share line to explain the denominator. + assert "Error Share" not in report_md # Check P0 aggregate metrics assert "Avg Reliability Score**:" in report_md @@ -259,7 +261,8 @@ def test_generate_markdown_empty_tasks(): report_md = ReportGenerator.generate_markdown(summary) assert "Total Tasks**: 0" in report_md - assert "Success Rate**: 0.0%" in report_md + # A run with no tasks has no pass rate; 0.0% would read as "everything failed". + assert "Pass Rate**: n/a (0/0)" in report_md # No aggregate metrics when there are no tasks assert "Avg Reliability Score" not in report_md assert "Generation Metrics" not in report_md diff --git a/tests/test_route_seam_exhaustiveness.py b/tests/test_route_seam_exhaustiveness.py index 45265fb1..b58a1307 100644 --- a/tests/test_route_seam_exhaustiveness.py +++ b/tests/test_route_seam_exhaustiveness.py @@ -72,7 +72,7 @@ async def _stub_anthropic(**_: object) -> dict[str, object]: monkeypatch.setattr(llm_judge, "invoke_bedrock_judge_async", _stub_bedrock) monkeypatch.setattr(llm_judge, "invoke_anthropic_judge_async", _stub_anthropic) monkeypatch.setattr(llm_judge, "extract_verdict_from_anthropic_response", lambda _resp: (None, "stub")) - monkeypatch.setattr(llm_judge, "token_usage_from_anthropic_dict", lambda _resp: None) + monkeypatch.setattr(llm_judge, "token_usage_from_anthropic_dict", lambda _resp, **_kwargs: None) criterion = MagicMock() for r in _INSTANCES: result = await _invoke_tool_channel(criterion=criterion, route=r, system_msg="s", user_msg="u") # type: ignore[arg-type] diff --git a/tests/test_run_metrics.py b/tests/test_run_metrics.py new file mode 100644 index 00000000..6ac5baff --- /dev/null +++ b/tests/test_run_metrics.py @@ -0,0 +1,253 @@ +"""Run-level derived metrics: one pass-rate denominator, and honest cost totals. + +Two bugs are pinned here. + +**The denominator.** ``pass_rate`` used to be ``succeeded / (run - error)``, which +paid a bonus for erroring: the more a run fell over, the smaller its denominator +got, up to the degenerate case of a run rendering as a perfect score while passing +a handful of rows. Every surface now divides by ``tasks_run``. + +**The bill.** Cost was summed over whatever rows happened to carry one, so a run +whose model was missing from the rate card, or whose turns were killed before the +backend reported a cost, understated its spend silently. Unpriced spend is now +counted and the total is labelled a floor. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from coder_eval.models import FinalStatus, RunSummary, sum_costs +from coder_eval.pricing import is_priced, unpriced_models + + +def _row(status: FinalStatus | str, **extra: Any) -> dict[str, Any]: + """A run.json row. ``total_cost_usd`` is derived the way the real projection derives it. + + Callers give the three cost slices; the row's total is their sum, so a test can + never assert against a total the projection would not have written. + """ + row: dict[str, Any] = {"task_id": f"t{id(extra)}", "status": status, **extra} + if any(k in extra for k in ("agent_cost_usd", "judge_cost_usd", "simulator_cost_usd")): + row["total_cost_usd"] = sum_costs( + extra.get("agent_cost_usd"), extra.get("judge_cost_usd"), extra.get("simulator_cost_usd") + ) + return row + + +def _summary(rows: list[dict[str, Any]]) -> RunSummary: + """Build a RunSummary whose stored counts are derived from ``rows``.""" + statuses = [FinalStatus(r["status"]) for r in rows] + return RunSummary( + run_id="2026-07-28_00-00-00", + start_time=datetime(2026, 7, 28, 0, 0, 0), + end_time=datetime(2026, 7, 28, 1, 0, 0), + total_duration_seconds=3600.0, + tasks_run=len(rows), + tasks_succeeded=sum(1 for s in statuses if s.category == "succeeded"), + tasks_failed=sum(1 for s in statuses if s.category == "failed"), + tasks_error=sum(1 for s in statuses if s.category == "error"), + task_results=rows, + framework_version="0.0.0-test", + ) + + +class TestPassRateDenominator: + def test_errors_count_as_misses(self): + """The whole point: an error is in the denominator, not excluded from it.""" + summary = _summary( + [ + _row(FinalStatus.SUCCESS), + _row(FinalStatus.FAILURE), + _row(FinalStatus.ERROR), + _row(FinalStatus.ERROR), + ] + ) + assert summary.pass_rate == pytest.approx(0.25) + # The old formula would have divided by 2 and published 50%. + assert summary.error_share == pytest.approx(0.5) + + def test_build_failed_is_an_error_row(self): + """BUILD_FAILED buckets to 'error' and so still lands in the denominator.""" + summary = _summary([_row(FinalStatus.SUCCESS), _row(FinalStatus.BUILD_FAILED)]) + assert summary.tasks_error == 1 + assert summary.pass_rate == pytest.approx(0.5) + + def test_timeout_and_budget_statuses_are_failures_not_errors(self): + """These are task outcomes; they were always in the denominator and stay there.""" + summary = _summary( + [ + _row(FinalStatus.SUCCESS), + _row(FinalStatus.TIMEOUT), + _row(FinalStatus.MAX_TURNS_EXHAUSTED), + _row(FinalStatus.TOKEN_BUDGET_EXCEEDED), + _row(FinalStatus.COST_BUDGET_EXCEEDED), + ] + ) + assert summary.tasks_error == 0 + assert summary.error_share == pytest.approx(0.0) + assert summary.pass_rate == pytest.approx(0.2) + + def test_mostly_errored_run_cannot_render_as_perfect(self): + """The reductio: 854 errors out of 861 rows. + + Under the old formula this reported 100.0% (7 evaluable, 7 passed). It must + read as what it was. + """ + rows = [_row(FinalStatus.SUCCESS) for _ in range(7)] + [_row(FinalStatus.ERROR) for _ in range(854)] + summary = _summary(rows) + assert summary.pass_rate == pytest.approx(7 / 861) + assert summary.pass_rate < 0.01 + + def test_empty_run_has_no_rate(self): + """0/0 is unknown, not 0% — which would read as 'everything failed'.""" + summary = _summary([]) + assert summary.pass_rate is None + assert summary.error_share is None + + def test_variant_aggregate_shares_the_denominator(self): + """An A/B whose variants error at different rates must compare like with like.""" + from coder_eval.models import VariantAggregate + + agg = VariantAggregate( + variant_id="v", + tasks_run=4, + tasks_succeeded=1, + tasks_failed=1, + tasks_error=2, + average_score=0.5, + average_duration=1.0, + ) + assert agg.pass_rate == pytest.approx(0.25) + + def test_rates_serialize_into_run_json(self): + """Downstream consumers must be able to READ the rate instead of re-deriving it. + + Independent re-derivations drift, and then two consumers publish different + rates for the same run. + """ + summary = _summary([_row(FinalStatus.SUCCESS), _row(FinalStatus.ERROR)]) + dumped = summary.model_dump() + assert dumped["pass_rate"] == pytest.approx(0.5) + assert dumped["error_share"] == pytest.approx(0.5) + # And the round-trip tolerates its own output (computed fields aren't inputs). + assert RunSummary.model_validate_json(summary.model_dump_json()).pass_rate == pytest.approx(0.5) + + +class TestCostCompleteness: + def test_unpriced_row_is_counted_and_flagged(self): + summary = _summary( + [ + _row(FinalStatus.SUCCESS, total_tokens=1000, agent_cost_usd=0.5, cost_complete=True), + _row(FinalStatus.TIMEOUT, total_tokens=8_000_000, agent_cost_usd=None, cost_complete=False), + ] + ) + assert summary.tasks_cost_incomplete == 1 + assert summary.cost_complete is False + # The priced row's cost still totals — a floor beats no number at all. + assert summary.agent_cost_usd == pytest.approx(0.5) + + def test_partially_priced_row_is_counted(self): + """A row whose cost is a partial sum of its own turns is incomplete. + + This is the timeout shape: turn 1 priced by the SDK, turn 2 killed + mid-flight with real tokens and no cost. Summing turn 1 alone and calling + it the row's cost is the silent 19% understatement. + """ + summary = _summary( + [_row(FinalStatus.TIMEOUT, total_tokens=5_000_000, agent_cost_usd=1.25, cost_complete=False)] + ) + assert summary.tasks_cost_incomplete == 1 + assert summary.cost_complete is False + + def test_zero_token_error_is_not_unpriced(self): + """A row that died before the agent ran genuinely cost nothing.""" + summary = _summary([_row(FinalStatus.ERROR, total_tokens=0, agent_cost_usd=None, cost_complete=True)]) + assert summary.tasks_cost_incomplete == 0 + assert summary.cost_complete is True + assert summary.agent_cost_usd is None + + def test_eval_overhead_is_separate_from_the_agent_bill(self): + """Judge/simulator spend is real money but must not inflate the agent's cost. + + It 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. + """ + summary = _summary( + [ + _row( + FinalStatus.SUCCESS, + total_tokens=1000, + agent_cost_usd=1.0, + cost_complete=True, + judge_cost_usd=0.2, + simulator_cost_usd=0.05, + ), + ] + ) + assert summary.agent_cost_usd == pytest.approx(1.0) + assert summary.eval_overhead_cost_usd == pytest.approx(0.25) + # And the bill is the two together, which is what `total_cost_usd` means. + assert summary.total_cost_usd == pytest.approx(1.25) + + def test_no_overhead_reports_none(self): + summary = _summary([_row(FinalStatus.SUCCESS, total_tokens=1, agent_cost_usd=0.1, cost_complete=True)]) + assert summary.eval_overhead_cost_usd is None + + +class TestPricingCoverage: + def test_is_priced_normalizes_bedrock_prefixes(self): + assert is_priced("claude-sonnet-5") + assert is_priced("eu.anthropic.claude-sonnet-5") + assert not is_priced("claude-sonnet-99") + + def test_unpriced_models_dedupes_and_drops_empty(self): + assert unpriced_models(["claude-sonnet-5", None, "", "made-up-1", "made-up-1", "made-up-0"]) == [ + "made-up-0", + "made-up-1", + ] + + +class TestRateCardCoversCheckedInExperiments: + """CI guard: a model referenced by a committed experiment must be priced. + + The rate card is a static table baked into the installed version, so a model + whose rates land in a later release prices as null on any turn the agent's own + backend did not price. This class is what makes the next new model fail CI + instead of a run's cost column. + """ + + @staticmethod + def _models_in(doc: dict[str, Any]) -> set[str]: + found: set[str] = set() + for block in (doc.get("defaults") or {}, *(doc.get("variants") or [])): + if not isinstance(block, dict): + continue + agent = block.get("agent") + if isinstance(agent, dict) and agent.get("model"): + found.add(str(agent["model"])) + return found + + def test_every_experiment_model_is_priced(self): + experiments_dir = Path(__file__).resolve().parents[1] / "experiments" + referenced: dict[str, str] = {} + for path in sorted(experiments_dir.glob("*.yaml")): + doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + for model in self._models_in(doc): + referenced.setdefault(model, path.name) + + # Guard the guard: an empty scan would pass vacuously. + assert referenced, f"No agent.model found under {experiments_dir}; the discovery filter is broken." + + missing = {m: src for m, src in referenced.items() if not is_priced(m)} + assert not missing, ( + "Models referenced by committed experiments have no pricing rate: " + + ", ".join(f"{m!r} (from {src})" for m, src in sorted(missing.items())) + + ". Add the rate to coder_eval.pricing._PRICING — an unpriced model records " + + "null cost for every task and understates the run's bill silently." + ) diff --git a/tests/test_timeout_orchestrator.py b/tests/test_timeout_orchestrator.py index 395ffdba..2bca85b1 100644 --- a/tests/test_timeout_orchestrator.py +++ b/tests/test_timeout_orchestrator.py @@ -16,6 +16,7 @@ FileExistsCriterion, SandboxConfig, TaskDefinition, + TokenUsage, TurnRecord, ) from coder_eval.orchestrator import Orchestrator @@ -227,6 +228,87 @@ async def slow_loop(): mock_agent.kill_sync.assert_called_once() +@pytest.mark.asyncio +async def test_task_timeout_recovers_the_killed_turn(tmp_path) -> None: + """A hard-killed task's spend lands on the result instead of vanishing. + + The agent parks the interrupted turn on ``pending_turn`` when it is cancelled, + and the task-timeout handler is the only reader of that slot: the cancel is a + BaseException, so it never reaches the retry executor's per-attempt hook that + drains it on a turn-level timeout. Without the drain the row reports no turns + and no cost for a task that spent real money. + """ + task = _make_task(task_timeout=0.1) + run_dir = tmp_path / "run" / "timeout_test" + run_dir.mkdir(parents=True) + + orchestrator = Orchestrator(task=task, run_dir=run_dir, variant_id="test-variant") + orchestrator._setup = AsyncMock() # type: ignore[method-assign] + orchestrator._cleanup = AsyncMock() # type: ignore[method-assign] + + partial = TurnRecord( + iteration=1, + user_input="test prompt", + agent_output="", + crashed=True, + token_usage=TokenUsage(uncached_input_tokens=40_000, output_tokens=2_000, total_cost_usd=0.15), + ) + + mock_agent = MagicMock() + mock_agent.pending_turn = partial + mock_agent.discard_pending_turn = AsyncMock() + mock_agent.get_sdk_options = MagicMock(return_value=None) + orchestrator.agent = mock_agent + + async def slow_loop(): + await asyncio.sleep(10) + return False + + orchestrator._evaluation_loop = slow_loop # type: ignore[method-assign] + + result = await orchestrator.run() + + assert result.final_status == "TIMEOUT" + assert result.iterations == [partial] + assert result.total_token_usage is not None + assert result.total_token_usage.output_tokens == 2_000 + assert result.total_token_usage.total_cost_usd == pytest.approx(0.15) + # Drained through the documented contract, so the slot is left clean. + mock_agent.discard_pending_turn.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_task_timeout_with_nothing_to_recover_still_lands(tmp_path) -> None: + """A task killed before its first turn has nothing parked, and that is not an error. + + The recovery is best-effort and runs on the way to a saved row, so an empty slot + must leave the TIMEOUT row intact rather than raising through teardown. + """ + task = _make_task(task_timeout=0.1) + run_dir = tmp_path / "run" / "timeout_test" + run_dir.mkdir(parents=True) + + orchestrator = Orchestrator(task=task, run_dir=run_dir, variant_id="test-variant") + orchestrator._setup = AsyncMock() # type: ignore[method-assign] + orchestrator._cleanup = AsyncMock() # type: ignore[method-assign] + + mock_agent = MagicMock() + mock_agent.pending_turn = None + mock_agent.get_sdk_options = MagicMock(return_value=None) + orchestrator.agent = mock_agent + + async def slow_loop(): + await asyncio.sleep(10) + return False + + orchestrator._evaluation_loop = slow_loop # type: ignore[method-assign] + + result = await orchestrator.run() + + assert result.final_status == "TIMEOUT" + assert result.iterations == [] + + @pytest.mark.asyncio async def test_turn_timeout_not_rewrapped_as_task_timeout(tmp_path) -> None: """A per-turn TurnTimeoutError propagates unchanged even when a larger diff --git a/tests/test_token_usage.py b/tests/test_token_usage.py index 7eb70b72..71bf6035 100644 --- a/tests/test_token_usage.py +++ b/tests/test_token_usage.py @@ -436,8 +436,8 @@ def test_timeout_turn_backfills_cost_from_buckets(self): ] usage = ClaudeCodeAgent._build_token_usage(messages, None, None, None, "claude-opus-4-8") assert usage is not None - # opus-4-8: 15/M in, 75/M out, 18.75/M cache-write, 1.50/M cache-read. - expected = (1_000_000 * 15.0 + 500_000 * 75.0 + 200_000 * 18.75 + 4_000_000 * 1.50) / 1_000_000 + # opus-4-8: 5/M in, 25/M out, 6.25/M cache-write, 0.50/M cache-read. + expected = (1_000_000 * 5.0 + 500_000 * 25.0 + 200_000 * 6.25 + 4_000_000 * 0.50) / 1_000_000 assert usage.total_cost_usd == pytest.approx(expected) def test_backfill_via_resultmessage_snapshot_path(self): @@ -875,6 +875,7 @@ def test_token_section_handles_missing_cost(self): "output_tokens": 2000, "total_tokens": 5000, "total_cost_usd": None, + "cost_complete": False, }, ] lines = ReportGenerator._generate_token_usage_section(task_results) @@ -882,9 +883,92 @@ def test_token_section_handles_missing_cost(self): assert "## Token Usage" in joined assert "**Total Tokens**: 5,000 (input: 3,000, output: 2,000)" in joined - assert "**Total Cost**" not in joined # No cost line when all costs are None + # Tokens were burned, so there IS a bill — the report must say the number is + # missing rather than omit the cost line, which reads as "this run was free". + assert "**Total Cost**: unavailable" in joined + assert "1 task(s) have spend missing from this total" in joined assert "| task1 | 3,000 | 2,000 | 0 | 0 | 5,000 | N/A |" in joined + def test_token_section_leaves_a_legacy_row_uncaveated(self): + """A row predating ``cost_complete`` is read as priced, not inferred. + + The same shape as above minus the flag, which is what runs written before + the field look like. Deliberately silent: inferring unpriced-ness from + tokens would give old runs a caveat at the price of a second definition of + "unpriced", and every new run carries the flag. + """ + task_results = [ + {"task_id": "task1", "total_tokens": 5000, "total_cost_usd": None}, + ] + joined = "\n".join(ReportGenerator._generate_token_usage_section(task_results)) + + assert "## Token Usage" in joined + assert "spend missing" not in joined + + def test_token_section_omits_cost_when_no_tokens_burned(self): + """A row that burned nothing is genuinely free — no unpriced warning.""" + task_results = [ + { + "task_id": "task1", + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "total_cost_usd": None, + }, + ] + joined = "\n".join(ReportGenerator._generate_token_usage_section(task_results)) + + assert "## Token Usage" in joined + assert "**Total Cost**" not in joined + + def test_token_section_marks_partial_cost_as_floor(self): + """A priced row beside an unpriced one reports the total as a floor. + + The failure this guards: summing only the rows that carry a cost and + presenting it as the run's bill. The unpriced rows are typically the + killed ones, which are also the ones that burned the most getting there. + """ + task_results = [ + { + "task_id": "priced", + "total_tokens": 1000, + "total_cost_usd": 0.25, + "agent_cost_usd": 0.25, + "cost_complete": True, + }, + { + "task_id": "unpriced", + "total_tokens": 4_000_000, + "total_cost_usd": None, + "agent_cost_usd": None, + "cost_complete": False, + }, + ] + joined = "\n".join(ReportGenerator._generate_token_usage_section(task_results)) + + assert "**Total Cost**: $0.2500 (floor" in joined + assert "1 task(s) have spend missing from this total" in joined + + def test_token_section_breaks_out_eval_overhead(self): + """Judge spend is reported beside the agent bill, and folded into the total.""" + task_results = [ + { + "task_id": "task1", + "total_tokens": 1000, + "total_cost_usd": 1.25, + "agent_cost_usd": 1.0, + "cost_complete": True, + "judge_cost_usd": 0.25, + }, + ] + joined = "\n".join(ReportGenerator._generate_token_usage_section(task_results)) + + assert "**Agent Cost**: $1.0000" in joined + assert "**Eval Overhead (judge + simulator)**: $0.2500" in joined + assert "**Total Cost**: $1.2500" in joined + # And the per-task column carries the whole bill, so it sums to that total. + assert "| task1 | 0 | 0 | 0 | 0 | 1,000 | $1.2500 |" in joined + def test_token_section_in_full_report(self): """Test that token section appears in generate_markdown when data is available.""" summary = RunSummary(