Skip to content

Online runs don't save responses that parse_func rejects, so re-runs pay for them again #744

Description

@TanishGudise

In online mode, append_generic_response returns before writing a response to responses_0.jsonl when parse_func raises or returns []:

raw_response = data.response_message
responses = self._process_response(data)
if not responses:
return
data.parsed_response_message = responses
data.response_message = raw_response
data_dump = data.model_dump()
json_string = json.dumps(data_dump, default=str)
async with aiofiles.open(filename, "a") as f:
await f.write(json_string + "\n")

raw_response = data.response_message
responses = self._process_response(data)
if not responses:
    return
...
async with aiofiles.open(filename, "a") as f:
    await f.write(json_string + "\n")

The request was sent and paid for, but the response isn't saved. Two things follow:

  1. If parse_func raises for some rows, fixing it and re-running the same job sends those requests again. The run directory is the same (the cache fingerprint doesn't include parse_func), but the responses were never written. Changing parse func should not invalidate the cache #40 and Prevent .arrow file getting in an invalid state and generate different .arrow based on parse_func #61 set up the cache so you can fix parse_func without re-running LLM calls; this path skips that.
  2. If parse_func returns [] to drop rows, the default require_all_responses=True makes every run fail with Some requests do not have responses and require_all_responses is True., and each re-run sends the dropped rows again.

Since 74f0463 ("ref: skip parse func failure"), a parse_func exception skips the response instead of stopping the run. 32f94c7 ("ref: add processor response method in base") then moved the parsing into _process_response and added if not responses: return in append_generic_response, which also drops [] results.

Repro (no API key: a small OpenAI-compatible stub on 127.0.0.1)

stub_server.py:

"""Minimal OpenAI-compatible stub on 127.0.0.1 (no API key, no network)."""
import json
import threading
from collections import Counter
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

calls = Counter()


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_POST(self):
        body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        if not body["messages"]:  # curator's rate-limit probe
            self.send_response(400)
            self.end_headers()
            return
        prompt = body["messages"][-1]["content"]
        calls[prompt] += 1
        reply = {
            "id": "x", "object": "chat.completion", "created": 0, "model": body["model"],
            "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": f"answer to {prompt}"}}],
            "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8},
        }
        data = json.dumps(reply).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
BASE_URL = f"http://127.0.0.1:{server.server_address[1]}/v1"

repro_parse_func.py:

import os
import tempfile

from stub_server import BASE_URL, calls

from bespokelabs import curator

work = tempfile.mkdtemp()
rows = [{"q": f"q{i}"} for i in range(10)]


def make(parse):
    class Answer(curator.LLM):
        def prompt(self, row):
            return row["q"]

    Answer.parse = parse
    return Answer(model_name="gpt-4o-mini", backend="openai", backend_params={"base_url": BASE_URL, "api_key": "sk-local", "max_retries": 0})


def buggy_parse(self, row, response):
    if row["q"] in ("q3", "q7"):
        raise KeyError("typo")
    return {"q": row["q"], "a": response}


def fixed_parse(self, row, response):
    return {"q": row["q"], "a": response}


try:
    make(buggy_parse)(rows, working_dir=work)
except ValueError as e:
    print("run 1:", e)
run_dir = next(d for d in os.listdir(work) if d != "metadata.db")
print("responses saved after run 1:", sum(1 for _ in open(os.path.join(work, run_dir, "responses_0.jsonl"))), "of", sum(calls.values()), "requests sent")
sent = sum(calls.values())
make(fixed_parse)(rows, working_dir=work)
print("run 2 (parse fixed): requests sent:", sum(calls.values()) - sent)

repro_filter.py:

import tempfile

from stub_server import BASE_URL, calls

from bespokelabs import curator


class Answer(curator.LLM):
    def prompt(self, row):
        return row["q"]

    def parse(self, row, response):
        if row["q"] in ("q3", "q7"):
            return []  # drop these rows
        return {"q": row["q"], "a": response}


work = tempfile.mkdtemp()
for run in (1, 2, 3):
    sent = sum(calls.values())
    try:
        Answer(model_name="gpt-4o-mini", backend="openai", backend_params={"base_url": BASE_URL, "api_key": "sk-local", "max_retries": 0})(
            [{"q": f"q{i}"} for i in range(10)], working_dir=work
        )
        print(f"run {run}: ok, requests sent: {sum(calls.values()) - sent}")
    except ValueError as e:
        print(f"run {run}: {e} (requests sent: {sum(calls.values()) - sent})")

Output on main (31abdb7):

$ python repro_parse_func.py
run 1: Some requests do not have responses and require_all_responses is True.
responses saved after run 1: 8 of 10 requests sent
run 2 (parse fixed): requests sent: 2
$ python repro_filter.py
run 1: Some requests do not have responses and require_all_responses is True. (requests sent: 10)
run 2: Some requests do not have responses and require_all_responses is True. (requests sent: 2)
run 3: Some requests do not have responses and require_all_responses is True. (requests sent: 2)

Expected: every response is saved whatever parse_func returns; the re-run with the fixed parse_func sends 0 requests; rows dropped with [] don't count as missing.

The batch path has a similar early return for parse_func errors (base_batch_request_processor.py L606-L610), but there None also covers responses that don't match response_format, so I've kept this issue to online mode. (Batch mode does save [] results, since it checks is None.)

Environment: macOS 26.6.2, Python 3.11.17, curator main 31abdb7.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions