Skip to content

Commit 9f9a325

Browse files
committed
Mock LLM tests: add llm_call marker and auto-mock fixture to prevent CI hangs
Tests in test_refiner.py, test_cache_evaluation_storage.py, and test_best_example_evals.py called real OpenRouter APIs, causing 6-hour hangs in CI (no API keys). Add an autouse conftest fixture that intercepts litellm.completion for @pytest.mark.llm_call tests, returning randomized JSON candidates. All 404 tests now pass in ~20s.
1 parent 4c571e5 commit 9f9a325

5 files changed

Lines changed: 62 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ gepa = ["py.typed"]
8080

8181
[tool.pytest.ini_options]
8282
testpaths = ["tests"]
83+
markers = [
84+
"llm_call: tests that call LLM APIs (auto-mocked via conftest fixture)",
85+
]
8386

8487
[tool.ruff]
8588
include = ["src/**/*.py"]

tests/conftest.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
22
import os
33
import random
4+
import re
45
from pathlib import Path
6+
from unittest.mock import MagicMock, patch
57

68
import pytest
79

@@ -98,3 +100,55 @@ def mocked_lms(recorder_dir):
98100
@pytest.fixture
99101
def rng():
100102
return random.Random(42)
103+
104+
105+
def _make_litellm_mock_response(content: str):
106+
"""Create a mock litellm.completion response."""
107+
resp = MagicMock()
108+
resp.choices = [MagicMock()]
109+
resp.choices[0].message.content = content
110+
resp.choices[0].finish_reason = "stop"
111+
return resp
112+
113+
114+
@pytest.fixture(autouse=True)
115+
def _mock_litellm_for_llm_call_tests(request):
116+
"""Auto-mock litellm.completion for tests marked with @pytest.mark.llm_call.
117+
118+
Returns a JSON dict with randomized values that satisfies both the reflection
119+
proposer (expects fenced code block) and the refiner (expects JSON dict).
120+
"""
121+
marker = request.node.get_closest_marker("llm_call")
122+
if marker is None:
123+
yield
124+
return
125+
126+
call_rng = random.Random(42)
127+
128+
def fake_completion(*_args, **kwargs):
129+
messages = kwargs.get("messages", [])
130+
last_content = messages[-1]["content"] if messages else ""
131+
132+
# Check for multi-param candidates
133+
param_a_match = re.search(r'"param_a"\s*:\s*"(\d+)"', last_content)
134+
param_b_match = re.search(r'"param_b"\s*:\s*"(\d+)"', last_content)
135+
if param_a_match and param_b_match:
136+
a = int(param_a_match.group(1)) + call_rng.randint(-5, 5)
137+
b = 100 - a + call_rng.randint(-3, 3)
138+
response_text = json.dumps({"param_a": str(a), "param_b": str(b)})
139+
else:
140+
# Each call returns a unique candidate so the cache can't stall budget
141+
number_match = re.search(r'"number"\s*:\s*"(\d+)"', last_content)
142+
if number_match:
143+
old = int(number_match.group(1))
144+
new_number = old + call_rng.randint(-10, 10)
145+
else:
146+
new_number = 42 + call_rng.randint(-10, 10)
147+
response_text = json.dumps({"number": str(new_number)})
148+
149+
# Wrap in fenced block for reflection proposer compatibility
150+
content = f"```\n{response_text}\n```"
151+
return _make_litellm_mock_response(content)
152+
153+
with patch("litellm.completion", side_effect=fake_completion):
154+
yield

tests/test_best_example_evals.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def fitness_fn(
5353
return fitness_fn
5454

5555

56+
@pytest.mark.llm_call
5657
class TestExampleBestEvals:
5758
"""Tests for OptimizationState / best_example_evals feature."""
5859

tests/test_cache_evaluation_storage.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def fitness_fn(candidate: dict[str, str], **kwargs) -> tuple[float, dict]:
4343
return fitness_fn
4444

4545

46+
@pytest.mark.llm_call
4647
class TestCacheEvaluationStorage:
4748
"""Tests for cache_evaluation_storage parameter."""
4849

tests/test_refiner.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def fitness_fn(candidate: dict[str, str], example, **kwargs) -> tuple[float, dic
111111
DATASET = [{"golden": 40}, {"golden": 60}]
112112

113113

114+
@pytest.mark.llm_call
114115
class TestRefiner:
115116
"""Tests for refiner functionality."""
116117

@@ -605,6 +606,7 @@ def bad_refiner_lm(prompt: str) -> str:
605606
assert score == -abs(50 - GOLDEN_NUMBER)
606607

607608

609+
@pytest.mark.llm_call
608610
class TestRefinerWithDataset:
609611
"""Test refiner with a dataset (per-instance evaluation)."""
610612

@@ -668,6 +670,7 @@ def test_refiner_dataset_mode(self):
668670
print(f"Metric calls: {result.total_metric_calls}, Fitness calls: {call_counter['count']}")
669671

670672

673+
@pytest.mark.llm_call
671674
class TestRefinerFrontierTypes:
672675
"""Test refiner with each frontier type using a dataset."""
673676

0 commit comments

Comments
 (0)