tudragon154203 Claude Happy commited on
Commit
ae4e2cf
·
1 Parent(s): ae767be

fix(evals): harden suite model selection and response extraction

Browse files

Handle reasoning-only OpenAI-compatible responses correctly, avoid false PASS results on identical error strings, and let suite benchmarks honor the CLI-selected model instead of defaulting to gpt-4o-mini.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

headroom/evals/runners/before_after.py CHANGED
@@ -377,6 +377,10 @@ Answer:"""
377
  response_compressed = f"ERROR: {e}"
378
  latency_compressed = (time.time() - start) * 1000
379
 
 
 
 
 
380
  # Compute metrics
381
  exact_match = compute_exact_match(response_original, response_compressed)
382
  f1_score = compute_f1(response_original, response_compressed)
@@ -400,9 +404,13 @@ Answer:"""
400
 
401
  # Determine accuracy preservation
402
  accuracy_preserved = (
403
- f1_score > 0.7
404
- or (semantic_sim is not None and semantic_sim > 0.85)
405
- or contains_ground_truth is True
 
 
 
 
406
  )
407
 
408
  return EvalResult(
 
377
  response_compressed = f"ERROR: {e}"
378
  latency_compressed = (time.time() - start) * 1000
379
 
380
+ # Detect error responses — two identical errors must not count as a pass
381
+ original_errored = response_original.startswith("ERROR:")
382
+ compressed_errored = response_compressed.startswith("ERROR:")
383
+
384
  # Compute metrics
385
  exact_match = compute_exact_match(response_original, response_compressed)
386
  f1_score = compute_f1(response_original, response_compressed)
 
404
 
405
  # Determine accuracy preservation
406
  accuracy_preserved = (
407
+ not original_errored
408
+ and not compressed_errored
409
+ and (
410
+ f1_score > 0.7
411
+ or (semantic_sim is not None and semantic_sim > 0.85)
412
+ or contains_ground_truth is True
413
+ )
414
  )
415
 
416
  return EvalResult(
headroom/evals/runners/utils.py CHANGED
@@ -9,11 +9,15 @@ from typing import Any
9
  def extract_openai_text(response: Any) -> str:
10
  """Extract text content from an OpenAI-compatible chat completion response.
11
 
12
- Handles three shapes returned by different providers / OpenRouter models:
13
  1. Standard: ``message.content`` is a non-empty string.
14
- 2. Content-part list: ``message.content`` is a list of typed blocks (vision models).
15
- 3. Reasoning-only: ``message.content`` is null but ``message.reasoning`` has the
16
- answer embedded in a code block (e.g. z-ai/glm-4.5-air via OpenRouter).
 
 
 
 
17
 
18
  Raises:
19
  ValueError: When the response carries no usable text in any of the above forms.
@@ -21,9 +25,13 @@ def extract_openai_text(response: Any) -> str:
21
  if not response.choices:
22
  raise ValueError("model returned no choices")
23
  message = response.choices[0].message
 
 
24
  content = getattr(message, "content", None)
25
  if isinstance(content, str) and content.strip():
26
  return content
 
 
27
  if isinstance(content, list):
28
  text_parts = [
29
  part.text
@@ -32,9 +40,25 @@ def extract_openai_text(response: Any) -> str:
32
  ]
33
  if text_parts:
34
  return "\n".join(text_parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  reasoning = getattr(message, "reasoning", None)
36
  if isinstance(reasoning, str) and reasoning.strip():
37
  match = re.search(r"```(?:python)?\s*(.*?)```", reasoning, re.DOTALL)
38
  if match:
39
- return match.group(0)
 
 
 
40
  raise ValueError("model returned no content")
 
9
  def extract_openai_text(response: Any) -> str:
10
  """Extract text content from an OpenAI-compatible chat completion response.
11
 
12
+ Handles shapes returned by different providers / models:
13
  1. Standard: ``message.content`` is a non-empty string.
14
+ 2. Content-part list: ``message.content`` is a list of typed blocks (vision).
15
+ 3. Reasoning models (Step 3.7 Flash, GLM via OpenRouter):
16
+ ``message.content`` is null but ``message.reasoning_content``
17
+ or ``message.reasoning`` holds the actual answer — extract it directly,
18
+ with code-block extraction as a fallback.
19
+ 4. Reasoning models that embed the final answer in a code block within
20
+ ``message.reasoning``.
21
 
22
  Raises:
23
  ValueError: When the response carries no usable text in any of the above forms.
 
25
  if not response.choices:
26
  raise ValueError("model returned no choices")
27
  message = response.choices[0].message
28
+
29
+ # Shape 1: standard string content
30
  content = getattr(message, "content", None)
31
  if isinstance(content, str) and content.strip():
32
  return content
33
+
34
+ # Shape 2: content-part list (vision models)
35
  if isinstance(content, list):
36
  text_parts = [
37
  part.text
 
40
  ]
41
  if text_parts:
42
  return "\n".join(text_parts)
43
+
44
+ # Shape 3: reasoning_content (Step 3.7 Flash / some NVIDIA NIM models)
45
+ # Use as direct answer — may be the full response with no code blocks.
46
+ reasoning_content = getattr(message, "reasoning_content", None)
47
+ if isinstance(reasoning_content, str) and reasoning_content.strip():
48
+ # Try code-block extraction first
49
+ match = re.search(r"```(?:python)?\s*(.*?)```", reasoning_content, re.DOTALL)
50
+ if match:
51
+ return match.group(1).strip()
52
+ # No code block — use reasoning_content as-is (full answer)
53
+ return reasoning_content.strip()
54
+
55
+ # Shape 4: reasoning (OpenRouter reasoning models)
56
  reasoning = getattr(message, "reasoning", None)
57
  if isinstance(reasoning, str) and reasoning.strip():
58
  match = re.search(r"```(?:python)?\s*(.*?)```", reasoning, re.DOTALL)
59
  if match:
60
+ return match.group(1).strip()
61
+ # Fall back to reasoning text itself as answer
62
+ return reasoning.strip()
63
+
64
  raise ValueError("model returned no content")
headroom/evals/suite_runner.py CHANGED
@@ -38,7 +38,7 @@ class BenchmarkSpec:
38
  tier: int
39
  runner_type: Literal["lm_eval", "before_after", "compression_only", "livecodebench"]
40
  sample_size: int
41
- model: str = "gpt-4o-mini"
42
  dataset_name: str | None = None # For before_after runner
43
  lm_eval_tasks: list[str] | None = None # For lm_eval runner
44
  primary_metric: str = "accuracy"
@@ -372,6 +372,12 @@ class SuiteRunner:
372
  return self._proxy_proc is not None
373
  return False
374
 
 
 
 
 
 
 
375
  def _cleanup_proxy(self) -> None:
376
  """Stop proxy if we started it."""
377
  if self._proxy_proc:
@@ -456,9 +462,7 @@ class SuiteRunner:
456
  suite = load_dataset_by_name(spec.dataset_name or spec.name.lower(), n=spec.sample_size)
457
 
458
  # Configure runner — use proxy for full-stack eval (compression + CCR)
459
- proxy_url = (
460
- f"http://localhost:{self.headroom_port}" if _check_proxy(self.headroom_port) else None
461
- )
462
  runner = BeforeAfterRunner(
463
  llm_config=LLMConfig(
464
  provider=spec.provider,
@@ -506,14 +510,14 @@ class SuiteRunner:
506
  from headroom.evals.runners.livecodebench_runner import LiveCodeBenchRunner
507
 
508
  suite = load_dataset_by_name(spec.dataset_name or "livecodebench", n=spec.sample_size)
 
 
509
  runner = LiveCodeBenchRunner(
510
  llm_config=LLMConfig(
511
  provider=spec.provider,
512
  model=spec.model or self.model,
513
  temperature=0.0,
514
- headroom_proxy_url=(
515
- f"http://localhost:{self.headroom_port}" if _check_proxy(self.headroom_port) else None
516
- ),
517
  )
518
  )
519
  result = runner.run(suite)
 
38
  tier: int
39
  runner_type: Literal["lm_eval", "before_after", "compression_only", "livecodebench"]
40
  sample_size: int
41
+ model: str | None = None
42
  dataset_name: str | None = None # For before_after runner
43
  lm_eval_tasks: list[str] | None = None # For lm_eval runner
44
  primary_metric: str = "accuracy"
 
372
  return self._proxy_proc is not None
373
  return False
374
 
375
+ def _get_proxy_url(self) -> str | None:
376
+ """Return proxy URL if a Headroom proxy is reachable, else None."""
377
+ if _check_proxy(self.headroom_port):
378
+ return f"http://localhost:{self.headroom_port}"
379
+ return None
380
+
381
  def _cleanup_proxy(self) -> None:
382
  """Stop proxy if we started it."""
383
  if self._proxy_proc:
 
462
  suite = load_dataset_by_name(spec.dataset_name or spec.name.lower(), n=spec.sample_size)
463
 
464
  # Configure runner — use proxy for full-stack eval (compression + CCR)
465
+ proxy_url = self._get_proxy_url()
 
 
466
  runner = BeforeAfterRunner(
467
  llm_config=LLMConfig(
468
  provider=spec.provider,
 
510
  from headroom.evals.runners.livecodebench_runner import LiveCodeBenchRunner
511
 
512
  suite = load_dataset_by_name(spec.dataset_name or "livecodebench", n=spec.sample_size)
513
+ proxy_url = self._get_proxy_url()
514
+
515
  runner = LiveCodeBenchRunner(
516
  llm_config=LLMConfig(
517
  provider=spec.provider,
518
  model=spec.model or self.model,
519
  temperature=0.0,
520
+ headroom_proxy_url=proxy_url,
 
 
521
  )
522
  )
523
  result = runner.run(suite)
tests/test_evals/test_eval_runners.py CHANGED
@@ -148,11 +148,18 @@ def test_livecodebench_extract_openai_text_from_reasoning_code_block() -> None:
148
  message = SimpleNamespace(content=None, reasoning="```python\nprint(123)\n```")
149
  response = SimpleNamespace(choices=[SimpleNamespace(message=message)])
150
 
151
- assert extract_openai_text(response) == "```python\nprint(123)\n```"
 
 
 
 
 
 
 
152
 
153
 
154
  def test_livecodebench_extract_openai_text_raises_on_empty_response() -> None:
155
- message = SimpleNamespace(content=None, reasoning=None)
156
  response = SimpleNamespace(choices=[SimpleNamespace(message=message)])
157
 
158
  with pytest.raises(ValueError, match="model returned no content"):
 
148
  message = SimpleNamespace(content=None, reasoning="```python\nprint(123)\n```")
149
  response = SimpleNamespace(choices=[SimpleNamespace(message=message)])
150
 
151
+ assert extract_openai_text(response) == "print(123)"
152
+
153
+
154
+ def test_livecodebench_extract_openai_text_from_reasoning_content() -> None:
155
+ message = SimpleNamespace(content=None, reasoning=None, reasoning_content="print(123)")
156
+ response = SimpleNamespace(choices=[SimpleNamespace(message=message)])
157
+
158
+ assert extract_openai_text(response) == "print(123)"
159
 
160
 
161
  def test_livecodebench_extract_openai_text_raises_on_empty_response() -> None:
162
+ message = SimpleNamespace(content=None, reasoning=None, reasoning_content=None)
163
  response = SimpleNamespace(choices=[SimpleNamespace(message=message)])
164
 
165
  with pytest.raises(ValueError, match="model returned no content"):