feat: TODO batch — OpenRouter cost reconciliation, safer cleanup manifest, pinned harness tests, docs#12
Conversation
The claude CLI prices runs at Anthropic list prices regardless of the
endpoint it talks to, so cost_usd was wrong whenever the harness routed
claude-code through OpenRouter. The generation API would give exact
billed amounts but needs per-request ids the CLI does not expose;
instead, recompute from the run's token counts and OpenRouter's
published per-model rates (fetched once per process from the public
models endpoint).
timing.json now records cost_usd_source ("cli", "openrouter-pricing",
or "cli-unreconciled" when the model cannot be mapped to an OpenRouter
slug) and keeps the CLI's original estimate in cost_usd_cli. Cache
creation tokens are now captured too so the reconciled cost includes
cache-write charges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build_command was only pinned for codex (and only the trust-check flag). Pin the full argv for all three real harnesses so a flag regression fails loudly, and cover cache_creation_input_tokens parsing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The PR snapshot is taken with `gh pr list --state all`, so _build_cleanup_entry could record PRs that were merged or closed during the run, and even PRs someone else opened on the source repo mid-run — cleanup would then try to close a stranger's PR with --delete-branch. Record a new PR number only when the PR is still OPEN and its head branch is one this run pushed. Snapshots from before state/headRefName were captured keep the old behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mparison - pyproject authors: replace the placeholder with the real maintainer - README: document running the CLI as `python -m agent_skill_eval` - README: fix the stale codex invocation row (--full-auto was replaced by --sandbox workspace-write --skip-git-repo-check) - README: document cost_usd semantics and the OpenRouter reconciliation (cost_usd_source / cost_usd_cli in timing.json) - README: add a multi-agent comparison walkthrough showing how to read per-agent with/without-skill deltas from the report and benchmark.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two-line print fixture gave agents nothing plausible to commit. The new fixture is a small user-directory module whose contents match what the eval prompts claim about it: a login fix (email normalization in authenticate) and a new search feature (search_users). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local-only cleanup also performed (not visible in the diff): stale merged local branches deleted, both .claude/worktrees copies of the old skill_eval module tree removed, stale untracked HANDOFF.md deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces cost reconciliation for Claude models routed through OpenRouter, captures cache creation tokens, and refines PR cleanup logic to exclude merged, closed, or external PRs. It also expands documentation, adds realistic test fixtures, and introduces comprehensive unit tests. Feedback on the changes highlights a concurrency issue in the newly added OpenRouter pricing fetcher, suggesting the use of a thread lock, a custom User-Agent to prevent CDN blocking, and defensive type-checking on the API response.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import re | ||
| import urllib.request | ||
| from typing import Optional | ||
|
|
||
| from agent_skill_eval.models import TimingData | ||
|
|
||
| OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" | ||
| _FETCH_TIMEOUT_SECONDS = 10 | ||
|
|
||
| # model id -> pricing dict, populated once per process. The models endpoint | ||
| # is public (no API key) but we still never want it on a hot path. | ||
| _pricing_cache: Optional[dict[str, dict]] = None | ||
|
|
||
|
|
||
| def fetch_openrouter_pricing() -> dict[str, dict]: | ||
| """Return ``{model_id: pricing}`` from the OpenRouter models endpoint. | ||
|
|
||
| Cached for the process lifetime. Raises on network/parse errors; callers | ||
| that must not fail (the harness) wrap this in try/except. | ||
| """ | ||
| global _pricing_cache | ||
| if _pricing_cache is not None: | ||
| return _pricing_cache | ||
|
|
||
| with urllib.request.urlopen(OPENROUTER_MODELS_URL, timeout=_FETCH_TIMEOUT_SECONDS) as resp: | ||
| data = json.loads(resp.read().decode("utf-8")) | ||
|
|
||
| pricing: dict[str, dict] = {} | ||
| for model in data.get("data", []): | ||
| model_id = model.get("id") | ||
| if model_id and isinstance(model.get("pricing"), dict): | ||
| pricing[model_id] = model["pricing"] | ||
| _pricing_cache = pricing | ||
| return pricing |
There was a problem hiding this comment.
The fetch_openrouter_pricing function is called concurrently by multiple threads when evaluations are run in parallel. Without synchronization, multiple threads can concurrently trigger redundant HTTP requests to the OpenRouter API when _pricing_cache is initially None.
Additionally, public APIs protected by Cloudflare or other CDNs often block the default Python urllib User-Agent with a 403 Forbidden error. It is safer to specify a custom User-Agent header. Finally, we should defensively verify that the parsed JSON data is a dictionary before calling .get() to avoid potential AttributeError exceptions if the API returns an unexpected format.
from __future__ import annotations
import json
import re
import threading
import urllib.request
from typing import Optional
from agent_skill_eval.models import TimingData
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
_FETCH_TIMEOUT_SECONDS = 10
# model id -> pricing dict, populated once per process. The models endpoint
# is public (no API key) but we still never want it on a hot path.
_pricing_cache: Optional[dict[str, dict]] = None
_pricing_lock = threading.Lock()
def fetch_openrouter_pricing() -> dict[str, dict]:
"""Return {model_id: pricing} from the OpenRouter models endpoint.
Cached for the process lifetime. Raises on network/parse errors; callers
that must not fail (the harness) wrap this in try/except.
"""
global _pricing_cache
if _pricing_cache is not None:
return _pricing_cache
with _pricing_lock:
if _pricing_cache is not None:
return _pricing_cache
req = urllib.request.Request(
OPENROUTER_MODELS_URL,
headers={"User-Agent": "agent-skill-eval/0.4.1"},
)
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT_SECONDS) as resp:
data = json.loads(resp.read().decode("utf-8"))
pricing: dict[str, dict] = {}
if isinstance(data, dict):
for model in data.get("data", []):
model_id = model.get("id")
if model_id and isinstance(model.get("pricing"), dict):
pricing[model_id] = model["pricing"]
_pricing_cache = pricing
return pricingThere was a problem hiding this comment.
Addressed in d40feba: added a lock around the cache fill, a real User-Agent header (version-derived rather than hardcoded), and isinstance checks on the response shape.
Guard the pricing cache with a lock (eval threads run concurrently and could fire redundant fetches), send a real User-Agent (CDN-fronted APIs commonly 403 urllib's default), and type-check the response shape before indexing into it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All three real harnesses (claude-code via OpenRouter, opencode, codex) completed with real output, zero skipped grades, and each fully passed at least one positive eval. The new reconciliation path fired live — {
"cached_tokens": 61288,
"cache_creation_tokens": 13918,
"cost_usd": 0.0255923,
"cost_usd_source": "openrouter-pricing",
"cost_usd_cli": 0.025592300000000002
}(The reconciled value equals the CLI estimate here because OpenRouter currently prices Anthropic models at Anthropic list price — the two columns will diverge whenever that stops being true, which is the point of recording both.) 🤖 Generated with Claude Code |
Summary
Works through the remaining TODO.md items (everything except the subagent-evals generalization, which is deferred).
feat(harness): reconcile claude-code cost against OpenRouter pricing
cost_usdwas wrong whenever claude-code was routed through OpenRouter.timing.jsongainscost_usd_source(cli/openrouter-pricing/cli-unreconciled) andcost_usd_cli(the CLI's original estimate), pluscache_creation_tokensso cache-write charges are included.fix(runner): only record eval-created open PRs in cleanup manifest
gh pr list --state all, so the manifest could record PRs merged/closed during the run — and even PRs someone else opened mid-run, whichcleanupwould then close with--delete-branch.state/headRefNamekeep the previous behavior.test(harness)
build_command(was codex-only, flag-presence-only)._build_cleanup_entrycovered for merged/closed/external/pre-existing/number-less PRs.docs / examples
authorsfilled with the real maintainer.python -m agent_skill_evalusage, cost reporting + reconciliation semantics, multi-agent comparison walkthrough (how to read per-agent with/without-skill deltas), stale codex invocation row fixed.examples/commit-push-pr/evals/files/sample_change.pyexpanded from two print lines into a realistic user-directory fixture matching what the eval prompts claim about it (login fix + search feature).local cleanup (not in the diff)
.claude/worktrees/copies of the oldskill_evalmodule tree removed, stale untrackedHANDOFF.mddeleted.Test plan
make test— 260 passed (includes free e2e tier)make lint— cleanmake test-live— 7 passed (all real harnesses, zero skipped grades); details in comment🤖 Generated with Claude Code