Skip to content

Commit bf0289f

Browse files
Merge branch 'main' into fix/adapter-surface-error-body
2 parents 7e9cf3c + 81e1608 commit bf0289f

3 files changed

Lines changed: 391 additions & 5 deletions

File tree

runner/adapters/openrouter_adapter.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,85 @@ def _get_openrouter_timeout() -> float:
9494
return DEFAULT_OPENROUTER_TIMEOUT
9595

9696

97+
#: Default number of HTTP attempts per call (1 initial + retries).
98+
#: Override via the ``GRADE_OPENROUTER_RETRIES`` environment variable.
99+
DEFAULT_OPENROUTER_ATTEMPTS: int = 3
100+
101+
#: HTTP statuses treated as transient and retried with backoff.
102+
RETRYABLE_STATUS_CODES: tuple[int, ...] = (429, 500, 502, 503, 504)
103+
104+
105+
def _get_openrouter_attempts() -> int:
106+
"""Return the configured number of HTTP attempts per OpenRouter call.
107+
108+
Reads ``GRADE_OPENROUTER_RETRIES``. Falls back to
109+
:data:`DEFAULT_OPENROUTER_ATTEMPTS` when unset or unparseable.
110+
111+
Returns:
112+
Attempt count as an :class:`int` (minimum 1).
113+
"""
114+
raw = os.environ.get("GRADE_OPENROUTER_RETRIES", "")
115+
if raw:
116+
try:
117+
return max(1, int(raw))
118+
except ValueError:
119+
pass
120+
return DEFAULT_OPENROUTER_ATTEMPTS
121+
122+
123+
def _post_with_retries(
124+
httpx_mod: Any,
125+
url: str,
126+
headers: dict[str, str],
127+
payload: dict[str, Any],
128+
timeout: float,
129+
) -> Any:
130+
"""POST with retries on transient transport errors and retryable statuses.
131+
132+
Mid-stream connection drops (``peer closed connection``), connect/read
133+
errors, and 429/5xx responses are retried with exponential backoff
134+
(1s, 2s, ... capped at 8s). Timeouts are NOT retried — each attempt
135+
already waits the full configured timeout, so callers keep their
136+
existing fail-fast timeout semantics (raise / bump
137+
``GRADE_OPENROUTER_TIMEOUT`` instead).
138+
139+
Args:
140+
httpx_mod: The imported ``httpx`` module (passed in because the
141+
adapter lazy-imports it).
142+
url: Request URL.
143+
headers: Request headers.
144+
payload: JSON body.
145+
timeout: Per-attempt timeout in seconds.
146+
147+
Returns:
148+
The ``httpx.Response`` of the first successful (or non-retryable)
149+
attempt.
150+
151+
Raises:
152+
RuntimeError: When transport errors persist through all attempts.
153+
httpx.TimeoutException: Propagated unretried.
154+
"""
155+
attempts = _get_openrouter_attempts()
156+
response: Any = None
157+
for attempt in range(attempts):
158+
try:
159+
response = httpx_mod.post(url, headers=headers, json=payload, timeout=timeout)
160+
except httpx_mod.TimeoutException:
161+
raise
162+
except httpx_mod.TransportError as exc:
163+
if attempt < attempts - 1:
164+
time.sleep(min(2**attempt, 8))
165+
continue
166+
raise RuntimeError(
167+
f"OpenRouter transport error persisted through {attempts} attempts: {exc}"
168+
) from exc
169+
if response.status_code in RETRYABLE_STATUS_CODES and attempt < attempts - 1:
170+
time.sleep(min(2**attempt, 8))
171+
continue
172+
return response
173+
return response
174+
175+
97176
#: Seed model shorthand → OpenRouter model slug mapping.
98177
#:
99178
#: These are the launch leaderboard models. Pass a shorthand string as the
@@ -540,11 +619,12 @@ def run(self, task: dict[str, Any], run_index: int = 0) -> dict[str, Any]:
540619
payload["reasoning"] = {"effort": self._reasoning_effort}
541620

542621
t_start = time.monotonic()
543-
response = httpx.post(
622+
response = _post_with_retries(
623+
httpx,
544624
f"{OPENROUTER_BASE_URL}/chat/completions",
545625
headers=headers,
546-
json=payload,
547-
timeout=120.0,
626+
payload=payload,
627+
timeout=_get_openrouter_timeout(),
548628
)
549629
latency_ms = (time.monotonic() - t_start) * 1000.0
550630

@@ -790,15 +870,16 @@ def post_chat_completion(
790870
timeout = _get_openrouter_timeout()
791871

792872
try:
793-
response = httpx.post(
873+
response = _post_with_retries(
874+
httpx,
794875
f"{OPENROUTER_BASE_URL}/chat/completions",
795876
headers={
796877
"Authorization": f"Bearer {key}",
797878
"Content-Type": "application/json",
798879
"HTTP-Referer": HTTP_REFERER,
799880
"X-Title": X_TITLE,
800881
},
801-
json=payload,
882+
payload=payload,
802883
timeout=timeout,
803884
)
804885
except httpx.TimeoutException as exc:

scripts/backfill_tasks.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
r"""backfill_tasks — re-run only the missing/failed tasks of an existing model row.
2+
3+
Reads the model's existing ``result.json`` + ``raw_outputs.jsonl`` from
4+
``<out>/<safe_model_slug>/``, re-runs ONLY the requested task IDs against the
5+
live pack, merges old and new per-task results, re-aggregates, and rewrites
6+
both files in place (originals are backed up with a ``.bak`` suffix).
7+
8+
Usage::
9+
10+
python -m scripts.backfill_tasks \
11+
--model anthropic/claude-sonnet-4.6 \
12+
--tasks T1-OPS-003,T1-OPS-004,T1-OPS-005,T1-OPS-006 \
13+
--pack operations --runs 5 --judge --out results/operations
14+
15+
If the model has no existing row (e.g. gpt-5.5@xhigh), all tasks in
16+
``--tasks`` are run and the merged row is just the new tasks — equivalent to
17+
a fresh partial run.
18+
19+
Judge cost in the merged ``cost_metrics`` is the sum of the old row's
20+
``judge_cost_usd`` and the backfill's judge spend (token-level judge counts
21+
are only available for the backfill portion and are reported as-is).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import json
28+
import shutil
29+
import sys
30+
from pathlib import Path
31+
from typing import Any
32+
33+
from runner.aggregator import aggregate
34+
from runner.dispatcher import TaskRunResult, load_pack, run_task
35+
from runner.io import write_raw_outputs, write_result
36+
from scripts.run_models import _build_adapter, _resolve_pack, _safe_slug
37+
38+
39+
def _load_existing(
40+
model_dir: Path,
41+
skip_task_ids: set[str],
42+
) -> tuple[list[TaskRunResult], float]:
43+
"""Reconstruct TaskRunResults for tasks NOT being backfilled.
44+
45+
Args:
46+
model_dir: Existing per-model result directory.
47+
skip_task_ids: Task IDs being re-run (excluded from reconstruction).
48+
49+
Returns:
50+
``(kept_results, old_judge_cost_usd)``. Empty list / 0.0 when no
51+
prior row exists.
52+
"""
53+
result_path = model_dir / "result.json"
54+
raw_path = model_dir / "raw_outputs.jsonl"
55+
if not result_path.exists():
56+
return [], 0.0
57+
58+
result = json.loads(result_path.read_text(encoding="utf-8"))
59+
60+
outputs_by_task: dict[str, list[dict[str, Any]]] = {}
61+
if raw_path.exists():
62+
with raw_path.open(encoding="utf-8") as fh:
63+
for line in fh:
64+
line = line.strip()
65+
if not line:
66+
continue
67+
out = json.loads(line)
68+
outputs_by_task.setdefault(out["task_id"], []).append(out)
69+
70+
kept: list[TaskRunResult] = []
71+
for entry in result.get("per_task_scores", []):
72+
tid = entry["task_id"]
73+
if tid in skip_task_ids:
74+
continue
75+
kept.append(
76+
TaskRunResult(
77+
task_id=tid,
78+
track=entry["track"],
79+
pack_id=entry.get("pack_id"),
80+
run_count=entry["run_count"],
81+
outputs=outputs_by_task.get(tid, []),
82+
scores=dict(entry["scores"]),
83+
composite=entry.get("composite"),
84+
scorer_flags=list(entry.get("scorer_flags") or []),
85+
)
86+
)
87+
88+
old_judge_cost = (result.get("cost_metrics") or {}).get("judge_cost_usd") or 0.0
89+
return kept, float(old_judge_cost)
90+
91+
92+
def main(argv: list[str] | None = None) -> int:
93+
"""CLI entry point: re-run the named tasks and merge into the existing row.
94+
95+
Args:
96+
argv: Argument list (defaults to ``sys.argv[1:]`` when ``None``).
97+
98+
Returns:
99+
Exit code: 0 on full success, 1 when any backfill task failed.
100+
"""
101+
parser = argparse.ArgumentParser(
102+
prog="python -m scripts.backfill_tasks",
103+
description="Re-run only the named tasks for one model row and merge results.",
104+
)
105+
parser.add_argument("--model", required=True, help="Model ID (supports @<effort> suffix).")
106+
parser.add_argument(
107+
"--tasks", required=True, metavar="T1,T2,...", help="Comma-separated task IDs to re-run."
108+
)
109+
parser.add_argument("--pack", required=True, help="Pack short name or path.")
110+
parser.add_argument("--adapter", default="openrouter")
111+
parser.add_argument("--runs", type=int, default=5)
112+
parser.add_argument("--judge", action="store_true")
113+
parser.add_argument("--judge-model", default=None)
114+
parser.add_argument("--temperature", type=float, default=1.0)
115+
parser.add_argument("--out", required=True, help="Output root (same as the original run).")
116+
parser.add_argument("--grade-version", default="0.1.0")
117+
args = parser.parse_args(argv)
118+
119+
wanted_ids = {t.strip() for t in args.tasks.split(",") if t.strip()}
120+
if not wanted_ids:
121+
print("ERROR: --tasks must be a non-empty comma-separated list.", file=sys.stderr)
122+
return 1
123+
124+
pack_path, pack_id = _resolve_pack(args.pack)
125+
tasks = [t for t in load_pack(pack_path) if t.get("task_id") in wanted_ids]
126+
missing = wanted_ids - {t["task_id"] for t in tasks}
127+
if missing:
128+
print(f"ERROR: task IDs not found in pack: {sorted(missing)}", file=sys.stderr)
129+
return 1
130+
131+
model_dir = Path(args.out) / _safe_slug(args.model)
132+
kept_results, old_judge_cost = _load_existing(model_dir, wanted_ids)
133+
print(
134+
f"Backfilling {len(tasks)} task(s) for {args.model}; "
135+
f"keeping {len(kept_results)} existing task result(s)."
136+
)
137+
138+
adapter = _build_adapter(args.adapter, model=args.model, temperature=args.temperature)
139+
140+
judge_client: Any | None = None
141+
if args.judge:
142+
from benchmark.rubrics.judge_client import JudgeClient, select_judge_model
143+
144+
if args.judge_model:
145+
judge_model, judge_effort = args.judge_model, None
146+
else:
147+
judge_model, judge_effort = select_judge_model(args.model)
148+
print(f" judge: {judge_model}" + (f" (effort: {judge_effort})" if judge_effort else ""))
149+
judge_client = JudgeClient(model=judge_model, reasoning_effort=judge_effort)
150+
151+
new_results: list[TaskRunResult] = []
152+
failures: list[dict[str, str]] = []
153+
for i, task in enumerate(tasks, start=1):
154+
tid = task["task_id"]
155+
print(f" [{i}/{len(tasks)}] {tid} ...", end=" ", flush=True)
156+
try:
157+
tr = run_task(
158+
task=task,
159+
adapter=adapter,
160+
runs=args.runs,
161+
pack_id=pack_id,
162+
judge_client=judge_client,
163+
)
164+
except Exception as exc: # noqa: BLE001
165+
print(f"FAILED ({exc})", flush=True)
166+
failures.append({"task_id": tid, "error": str(exc)})
167+
continue
168+
comp = f"{tr.composite:.4f}" if tr.composite is not None else "n/a"
169+
print(f"composite={comp}", flush=True)
170+
new_results.append(tr)
171+
172+
if not new_results:
173+
print("No backfill task succeeded — leaving existing row untouched.", file=sys.stderr)
174+
return 1
175+
176+
# Merge, preserving canonical pack order.
177+
pack_order = {t["task_id"]: i for i, t in enumerate(load_pack(pack_path))}
178+
merged = sorted(
179+
kept_results + new_results,
180+
key=lambda tr: pack_order.get(tr.task_id, 10_000),
181+
)
182+
183+
judge_metrics: dict[str, Any] | None = None
184+
if judge_client is not None:
185+
judge_metrics = {
186+
"cumulative_cost_usd": (judge_client.cumulative_cost_usd or 0.0) + old_judge_cost,
187+
"cumulative_prompt_tokens": judge_client.cumulative_prompt_tokens,
188+
"cumulative_completion_tokens": judge_client.cumulative_completion_tokens,
189+
"judge_call_count": judge_client.judge_call_count,
190+
}
191+
elif old_judge_cost:
192+
judge_metrics = {"cumulative_cost_usd": old_judge_cost}
193+
194+
scorecard = aggregate(
195+
task_results=merged,
196+
model_id=args.model,
197+
grade_version=args.grade_version,
198+
judge_metrics=judge_metrics,
199+
)
200+
201+
# Back up originals, then write merged row.
202+
model_dir.mkdir(parents=True, exist_ok=True)
203+
for name in ("result.json", "raw_outputs.jsonl", "failures.json"):
204+
p = model_dir / name
205+
if p.exists():
206+
shutil.copy2(p, p.with_suffix(p.suffix + ".bak"))
207+
208+
all_outputs = [out for tr in merged for out in tr.outputs]
209+
raw_path = write_raw_outputs(all_outputs, model_dir)
210+
result_path = write_result(scorecard, model_dir)
211+
212+
failures_path = model_dir / "failures.json"
213+
if failures:
214+
failures_path.write_text(json.dumps(failures, indent=2) + "\n", encoding="utf-8")
215+
elif failures_path.exists():
216+
failures_path.unlink() # row is now complete — stale failure list removed
217+
218+
overall = scorecard.get("overall_composite")
219+
comp_str = f"{overall:.4f}" if overall is not None else "n/a"
220+
print(f"\nMerged row: {len(merged)} task(s), composite={comp_str}")
221+
print(f"Raw outputs → {raw_path}")
222+
print(f"Result JSON → {result_path}")
223+
if failures:
224+
print(f"Backfill failures recorded → {failures_path}", file=sys.stderr)
225+
return 1 if failures else 0
226+
227+
228+
if __name__ == "__main__":
229+
sys.exit(main())

0 commit comments

Comments
 (0)