Skip to content

Commit ad23dd6

Browse files
TanishGudiseclaude
andcommitted
fix: save online responses that parse_func rejects or filters out
append_generic_response returned before writing the response when parse_func raised or returned no rows. The response was paid for but never saved, so a resumed run sent the request again, and a fixed parse_func couldn't rebuild the dataset from the cache. With the default require_all_responses=True, a parse_func that filters rows by returning [] also made every run fail with "Some requests do not have responses". Write the response first and skip only the parsed-row count and viewer stream when there are no rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 31abdb7 commit ad23dd6

2 files changed

Lines changed: 108 additions & 2 deletions

File tree

‎src/bespokelabs/curator/request_processor/online/base_online_request_processor.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -621,15 +621,17 @@ async def append_generic_response(self, status_tracker: OnlineStatusTracker, dat
621621
"""
622622
raw_response = data.response_message
623623
responses = self._process_response(data)
624-
if not responses:
625-
return
626624
data.parsed_response_message = responses
627625
data.response_message = raw_response
628626
data_dump = data.model_dump()
629627
json_string = json.dumps(data_dump, default=str)
628+
# Save the response even when `parse_func` failed on it or returned no rows: a resumed run
629+
# skips completed requests, and the dataset is rebuilt from this file.
630630
async with aiofiles.open(filename, "a") as f:
631631
await f.write(json_string + "\n")
632632
logger.debug(f"Successfully appended response to {filename}")
633+
if not responses:
634+
return
633635
filename = os.path.basename(filename).split(".")[0]
634636
idx = status_tracker.num_parsed_responses
635637
status_tracker.num_parsed_responses = idx + len(responses)

‎tests/unittests/test_base_online_request_processor.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import datetime
2+
import json
3+
import os
4+
import types
5+
16
import pytest
27

8+
from bespokelabs.curator.llm.prompt_formatter import PromptFormatter
9+
from bespokelabs.curator.request_processor.config import OnlineRequestProcessorConfig
310
from bespokelabs.curator.request_processor.online.anthropic_online_request_processor import AnthropicOnlineRequestProcessor
11+
from bespokelabs.curator.request_processor.online.openai_online_request_processor import OpenAIOnlineRequestProcessor
12+
from bespokelabs.curator.types.generic_request import GenericRequest
13+
from bespokelabs.curator.types.generic_response import GenericResponse
414

515

616
@pytest.mark.asyncio
@@ -18,3 +28,97 @@ async def test_aiohttp_connector_trusts_env_proxy():
1828

1929
async with processor.aiohttp_connector(tcp_limit=1) as session:
2030
assert session.trust_env is True
31+
32+
33+
_QUESTIONS = ["q0", "q1", "q2"]
34+
_REJECTED = "q1"
35+
36+
37+
class _ViewerClient:
38+
async def stream_response(self, response_data, idx):
39+
pass
40+
41+
42+
def _parse(row, response):
43+
return {"question": row["question"], "answer": response}
44+
45+
46+
def _parse_raises_on_rejected_row(row, response):
47+
if row["question"] == _REJECTED:
48+
raise KeyError("bug in parse_func")
49+
return _parse(row, response)
50+
51+
52+
def _parse_filters_out_rejected_row(row, response):
53+
if row["question"] == _REJECTED:
54+
return []
55+
return _parse(row, response)
56+
57+
58+
def _online_processor(working_dir, parse_func):
59+
# append_generic_response and the cache helpers only need these attributes, so skip __init__,
60+
# which makes a network call to read rate limits.
61+
processor = object.__new__(OpenAIOnlineRequestProcessor)
62+
processor.config = OnlineRequestProcessorConfig(model="gpt-4o-mini")
63+
processor.working_dir = str(working_dir)
64+
processor.prompt_formatter = PromptFormatter(model_name="gpt-4o-mini", prompt_func=lambda row: row["question"], parse_func=parse_func)
65+
processor.viewer_client = _ViewerClient()
66+
return processor
67+
68+
69+
async def _record_api_responses(processor, working_dir):
70+
"""Write the request file and record an API response for every request, as an online run does."""
71+
tracker = types.SimpleNamespace(num_parsed_responses=0)
72+
now = datetime.datetime.now()
73+
with open(os.path.join(working_dir, "requests_0.jsonl"), "w") as requests_file:
74+
for idx, question in enumerate(_QUESTIONS):
75+
request = GenericRequest(
76+
model="gpt-4o-mini",
77+
messages=[{"role": "user", "content": question}],
78+
original_row={"question": question},
79+
original_row_idx=idx,
80+
)
81+
requests_file.write(json.dumps(request.model_dump(), default=str) + "\n")
82+
response = GenericResponse(
83+
response_message=f"answer to {question}",
84+
raw_response={},
85+
generic_request=request,
86+
created_at=now,
87+
finished_at=now,
88+
)
89+
await processor.append_generic_response(tracker, response, os.path.join(working_dir, "responses_0.jsonl"))
90+
return tracker
91+
92+
93+
@pytest.mark.asyncio
94+
@pytest.mark.parametrize(
95+
"parse_func",
96+
[_parse_raises_on_rejected_row, _parse_filters_out_rejected_row],
97+
ids=["parse_func_raises", "parse_func_filters_out_row"],
98+
)
99+
async def test_response_rejected_by_parse_func_is_not_requested_again_on_resume(tmp_path, parse_func):
100+
processor = _online_processor(tmp_path, parse_func)
101+
tracker = await _record_api_responses(processor, tmp_path)
102+
103+
completed_request_ids, _ = processor.validate_existing_response_file(str(tmp_path / "responses_0.jsonl"))
104+
105+
assert completed_request_ids == {0, 1, 2}
106+
assert tracker.num_parsed_responses == 2
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_dataset_is_rebuilt_from_cached_responses_after_parse_func_is_fixed(tmp_path):
111+
await _record_api_responses(_online_processor(tmp_path, _parse_raises_on_rejected_row), tmp_path)
112+
113+
dataset = _online_processor(tmp_path, _parse).create_dataset_files("fixed_parse_func_hash")
114+
115+
assert dataset["answer"] == [f"answer to {question}" for question in _QUESTIONS]
116+
117+
118+
@pytest.mark.asyncio
119+
async def test_rows_filtered_out_by_parse_func_do_not_fail_the_run(tmp_path):
120+
await _record_api_responses(_online_processor(tmp_path, _parse_filters_out_rejected_row), tmp_path)
121+
122+
dataset = _online_processor(tmp_path, _parse_filters_out_rejected_row).create_dataset_files("parse_func_hash")
123+
124+
assert dataset["question"] == ["q0", "q2"]

0 commit comments

Comments
 (0)