Skip to content

Commit 3f5b3d4

Browse files
paultancre-btPaul Tancreclaude
authored
fix(devserver): honor request max_concurrency (#1072)
Follow-up to braintrustdata/braintrust#24075, which made the playground and "+ Experiment" dialog send `max_concurrency` to remote eval dev servers. This PR makes the dev server honor it. Companion JS SDK PR: braintrustdata/braintrust-sdk-javascript#2730 ### Context The playground **Max concurrency** slider and the "+ Experiment" **Max concurrency** field had no effect on remote eval tasks. braintrustdata/braintrust#24075 (merged) made the UI send `max_concurrency` in the `POST /eval` request to the dev server, but `parse_eval_body` never read the field and `run_eval` built the `EvalAsync` kwargs only from the registered evaluator, so it was ignored. The value set on the Eval in code always won. A customer lowered the slider to avoid Azure 429s and saw no change (Pylon 20534, COR-288). ### Description Parse `max_concurrency` from the eval request and pass it to `EvalAsync`. **The request value can lower the evaluator's limit but never raise it** (`min(request, evaluator)`; if the evaluator sets no limit, the request value applies). This is deliberate: the playground always sends a value and the slider defaults to 10, so letting the request override outright would silently raise every code-set limit below 10 (e.g. `max_concurrency=5` → 10) as soon as users upgrade the SDK. The trade-off is that the UI can't push concurrency *above* what's in code. Other details: - `null` or a missing value keeps the evaluator's limit, which also covers older UIs that don't send the field. - Fractional values are rounded down (the "+ Experiment" field is a free number input validated only as `>= 1`). Values below 1 or non-numbers are rejected with a 400. ### Testing - New tests cover: request lowers the limit, request can't raise it, request applies when the evaluator has no limit, null or omitted request keeps the evaluator's limit, rounding down, and invalid values. They fail without the fix and pass with it. - `nox -s test_cli` passes (43 passed, 2 skipped); `make lint` is clean. - End to end: ran the real dev server (`eval --dev`) from this branch against a Braintrust org, with a 12-row eval whose task records how many calls are in flight, and sent `POST /eval` requests shaped like the playground's. Same run against the released Python 0.49.2 (PyPI) for comparison: | Evaluator limit in code | Request `max_concurrency` | Expected peak | This branch | Released | |---|---|---|---|---| | 5 | 2 | 2 | **2** | 5 | | 5 | 10 | 5 | 5 | 5 | | 5 | `null` | 5 | 5 | 5 | | 5 | omitted | 5 | 5 | 5 | | 5 | 2.5 | 2 | **2** | 5 | | none | 3 | 3 | **3** | 12 | | none | omitted | 12 | 12 | 12 | Refs: COR-288 Co-authored-by: Paul Tancre <paultancre@p-tancre.home> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent baf8d7a commit 3f5b3d4

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

‎py/src/braintrust/devserver/schemas.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class ParsedEvalBody(TypedDict, total=False):
4242
project_id: str
4343
parent: str | ParsedParent
4444
stream: bool
45+
max_concurrency: int
4546

4647

4748
def validate_typed_dict(data: Any, typed_dict_class: type, path: str = "") -> dict[str, Any]:
@@ -260,4 +261,10 @@ def parse_eval_body(request_data: str | bytes | dict) -> ParsedEvalBody:
260261
raise ValidationError("stream must be a boolean")
261262
parsed["stream"] = data["stream"]
262263

264+
if data.get("max_concurrency") is not None:
265+
max_concurrency = data["max_concurrency"]
266+
if not isinstance(max_concurrency, (int, float)) or isinstance(max_concurrency, bool) or max_concurrency < 1:
267+
raise ValidationError("max_concurrency must be a number greater than or equal to 1")
268+
parsed["max_concurrency"] = int(max_concurrency)
269+
263270
return parsed

‎py/src/braintrust/devserver/server.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,16 @@ def stream_fn(event: SSEProgressEvent):
234234
# registered against.
235235
project_id = eval_data.get("project_id") or evaluator.project_id
236236

237+
# The request can lower the evaluator's max_concurrency but never raise it,
238+
# so the playground's default slider value can't exceed a limit set in code.
239+
request_max_concurrency = eval_data.get("max_concurrency")
240+
if request_max_concurrency is not None:
241+
eval_kwargs["max_concurrency"] = (
242+
request_max_concurrency
243+
if evaluator.max_concurrency is None
244+
else min(request_max_concurrency, evaluator.max_concurrency)
245+
)
246+
237247
try:
238248
eval_task = asyncio.create_task(
239249
EvalAsync(

‎py/src/braintrust/devserver/test_server_integration.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,93 @@ async def fake_eval_async(*, project_id, **_kwargs):
480480

481481
assert response.status_code == 200
482482
assert captured["project_id"] == "request-explicit-project-id"
483+
484+
485+
_OMIT = object()
486+
487+
488+
@pytest.mark.parametrize(
489+
"evaluator_max_concurrency,request_max_concurrency,expected",
490+
[
491+
pytest.param(5, 2, 2, id="request-lowers-evaluator"),
492+
pytest.param(5, 10, 5, id="request-cannot-raise-evaluator"),
493+
pytest.param(None, 3, 3, id="request-applies-when-evaluator-unset"),
494+
pytest.param(5, None, 5, id="null-request-keeps-evaluator"),
495+
pytest.param(5, _OMIT, 5, id="omitted-request-keeps-evaluator"),
496+
pytest.param(None, _OMIT, None, id="both-unset"),
497+
],
498+
)
499+
def test_eval_request_max_concurrency_caps_evaluator(
500+
api_key, org_name, monkeypatch, evaluator_max_concurrency, request_max_concurrency, expected
501+
):
502+
"""The playground's max concurrency can lower, but never raise, the evaluator's own limit."""
503+
from braintrust import Evaluator
504+
from braintrust.devserver import server as devserver_module
505+
from braintrust.devserver.server import create_app
506+
from braintrust.logger import BraintrustState
507+
from starlette.testclient import TestClient
508+
509+
evaluator = Evaluator(
510+
project_name="test-max-concurrency",
511+
eval_name="max-concurrency-eval",
512+
data=lambda: [{"input": "ping", "expected": "pong"}],
513+
task=lambda input, _hooks: "pong",
514+
scores=[],
515+
experiment_name=None,
516+
metadata=None,
517+
max_concurrency=evaluator_max_concurrency,
518+
)
519+
520+
captured: dict[str, Any] = {}
521+
522+
async def fake_cached_login(**_kwargs):
523+
return BraintrustState()
524+
525+
class FakeSummary:
526+
def as_dict(self):
527+
return {"experiment_name": evaluator.eval_name, "project_name": "", "scores": {}}
528+
529+
class FakeResult:
530+
summary = FakeSummary()
531+
532+
async def fake_eval_async(*, max_concurrency, **_kwargs):
533+
captured["max_concurrency"] = max_concurrency
534+
return FakeResult()
535+
536+
monkeypatch.setattr(devserver_module, "cached_login", fake_cached_login)
537+
monkeypatch.setattr(devserver_module, "EvalAsync", fake_eval_async)
538+
539+
eval_request: dict[str, Any] = {
540+
"name": "max-concurrency-eval",
541+
"stream": False,
542+
"data": [{"input": "ping", "expected": "pong"}],
543+
}
544+
if request_max_concurrency is not _OMIT:
545+
eval_request["max_concurrency"] = request_max_concurrency
546+
547+
response = TestClient(create_app([evaluator])).post(
548+
"/eval",
549+
headers={
550+
"x-bt-auth-token": api_key,
551+
"x-bt-org-name": org_name,
552+
"Content-Type": "application/json",
553+
},
554+
json=eval_request,
555+
)
556+
557+
assert response.status_code == 200
558+
assert captured["max_concurrency"] == expected
559+
560+
561+
def test_parse_eval_body_rounds_down_fractional_max_concurrency():
562+
from braintrust.devserver.schemas import parse_eval_body
563+
564+
assert parse_eval_body({"name": "eval", "max_concurrency": 2.5})["max_concurrency"] == 2
565+
566+
567+
@pytest.mark.parametrize("max_concurrency", [0, -1, 0.5, "2", True])
568+
def test_parse_eval_body_rejects_invalid_max_concurrency(max_concurrency):
569+
from braintrust.devserver.schemas import ValidationError, parse_eval_body
570+
571+
with pytest.raises(ValidationError, match="max_concurrency must be a number greater than or equal to 1"):
572+
parse_eval_body({"name": "eval", "max_concurrency": max_concurrency})

0 commit comments

Comments
 (0)