diff --git a/py/src/braintrust/parameters.py b/py/src/braintrust/parameters.py index c8b71ddc..31dea194 100644 --- a/py/src/braintrust/parameters.py +++ b/py/src/braintrust/parameters.py @@ -215,14 +215,32 @@ def is_eval_parameter_schema(schema: Any) -> bool: return True +def _strip_none_values(value: Any) -> Any: + """Recursively drop dict entries whose value is ``None``. + + Prompt parameter defaults are serialized from Python prompt dataclasses, + which preserve explicit ``None`` values for optional fields (e.g. a message's + ``name``/``function_call``/``tool_calls`` or a chat block's ``tools``). The + JS/UI evaluator manifest schema treats those fields as optional-when-absent + rather than nullable, so an explicit ``null`` fails validation and the remote + eval never appears in the playground. Stripping ``None`` keeps the emitted + default aligned with what the UI schema expects. + """ + if isinstance(value, dict): + return {key: _strip_none_values(item) for key, item in value.items() if item is not None} + if isinstance(value, list): + return [_strip_none_values(item) for item in value] + return value + + def _prompt_data_to_dict( prompt_data: PromptDataDict | PromptData | None, ) -> dict[str, Any] | None: if prompt_data is None: return None if isinstance(prompt_data, PromptData): - return prompt_data.as_dict() - return dict(prompt_data) + return _strip_none_values(prompt_data.as_dict()) + return _strip_none_values(dict(prompt_data)) def _create_prompt(name: str, prompt_data: dict[str, Any]) -> "Prompt": diff --git a/py/src/braintrust/test_parameters.py b/py/src/braintrust/test_parameters.py index 601e00c1..b5ff3bca 100644 --- a/py/src/braintrust/test_parameters.py +++ b/py/src/braintrust/test_parameters.py @@ -5,8 +5,10 @@ RemoteEvalParameters, parameters_to_json_schema, serialize_eval_parameters, + serialize_remote_eval_parameters_container, validate_parameters, ) +from braintrust.prompt import PromptChatBlock, PromptData, PromptMessage HAS_PYDANTIC = importlib.util.find_spec("pydantic") is not None @@ -563,3 +565,73 @@ def test_parameters_to_json_schema_does_not_mark_passthrough_values_required(): ) assert "required" not in schema + + +def _assert_no_none_values(node): + if isinstance(node, dict): + for key, value in node.items(): + assert value is not None, f"unexpected None at key {key!r}" + _assert_no_none_values(value) + elif isinstance(node, list): + for item in node: + _assert_no_none_values(item) + + +def test_prompt_parameter_defaults_omit_none_values(): + prompt_data = PromptData( + prompt=PromptChatBlock(messages=[PromptMessage(role="user", content="{{input}}")]), + options={"model": "gpt-5-mini"}, + ) + + serialized = serialize_remote_eval_parameters_container( + { + "grouping_prompt": { + "type": "prompt", + "description": "Grouping prompt", + "default": prompt_data, + } + } + ) + + default = serialized["schema"]["grouping_prompt"]["default"] + assert "tools" not in default["prompt"] + assert "name" not in default["prompt"]["messages"][0] + assert "function_call" not in default["prompt"]["messages"][0] + assert "tool_calls" not in default["prompt"]["messages"][0] + _assert_no_none_values(default) + + +def test_prompt_parameter_defaults_omit_none_values_from_dict(): + prompt_default = { + "prompt": { + "type": "chat", + "messages": [ + { + "role": "user", + "content": "{{input}}", + "name": None, + "function_call": None, + "tool_calls": None, + } + ], + "tools": None, + }, + "options": {"model": "gpt-5-mini"}, + } + + serialized = serialize_eval_parameters( + { + "grouping_prompt": { + "type": "prompt", + "description": "Grouping prompt", + "default": prompt_default, + } + } + ) + + default = serialized["grouping_prompt"]["default"] + assert "tools" not in default["prompt"] + assert "name" not in default["prompt"]["messages"][0] + assert "function_call" not in default["prompt"]["messages"][0] + assert "tool_calls" not in default["prompt"]["messages"][0] + _assert_no_none_values(default)