From 7bd9d06bc7926e079ca198f03307755160bf3ac9 Mon Sep 17 00:00:00 2001 From: "Gareth.Y" <91742523+linjiaye929@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:15:19 -0700 Subject: [PATCH] fix: isolate JSON parameter containers during validation --- py/src/braintrust/parameters.py | 23 ++++++++- py/src/braintrust/test_parameters.py | 76 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/py/src/braintrust/parameters.py b/py/src/braintrust/parameters.py index 180cf34af..9fc7431f6 100644 --- a/py/src/braintrust/parameters.py +++ b/py/src/braintrust/parameters.py @@ -253,6 +253,25 @@ def _create_prompt(name: str, prompt_data: dict[str, Any]) -> "Prompt": return Prompt.from_prompt_data(name, PromptData.from_dict_deep(prompt_data)) +def _copy_parameter_containers(value: Any, memo: dict[int, Any] | None = None) -> Any: + """Isolate JSON containers without deep-copying opaque parameter values.""" + if not isinstance(value, (dict, list)): + return value + if memo is None: + memo = {} + if id(value) in memo: + return memo[id(value)] + if isinstance(value, dict): + copied_dict: dict[str, Any] = {} + memo[id(value)] = copied_dict + copied_dict.update({key: _copy_parameter_containers(item, memo) for key, item in value.items()}) + return copied_dict + copied_list: list[Any] = [] + memo[id(value)] = copied_list + copied_list.extend(_copy_parameter_containers(item, memo) for item in value) + return copied_list + + def _apply_defaults_to_json_schema_instance(instance: Any, schema: Mapping[str, Any]) -> Any: if not isinstance(instance, dict): return instance @@ -268,7 +287,7 @@ def _apply_defaults_to_json_schema_instance(instance: Any, schema: Mapping[str, continue if name not in instance and "default" in property_schema: - instance[name] = property_schema["default"] + instance[name] = _copy_parameter_containers(property_schema["default"]) if name in instance: value = instance[name] @@ -284,7 +303,7 @@ def _apply_defaults_to_json_schema_instance(instance: Any, schema: Mapping[str, def validate_json_schema(parameters: dict[str, Any], schema: ParametersSchema) -> dict[str, Any]: - candidate = dict(parameters) + candidate = _copy_parameter_containers(dict(parameters)) _apply_defaults_to_json_schema_instance(candidate, schema) validator = Draft7Validator(schema) diff --git a/py/src/braintrust/test_parameters.py b/py/src/braintrust/test_parameters.py index d96d45aea..d16fc0e6c 100644 --- a/py/src/braintrust/test_parameters.py +++ b/py/src/braintrust/test_parameters.py @@ -674,3 +674,79 @@ def schema(cls): assert serialized["judge_model"]["type"] == "model" for name in ("template", "main", "judge_model"): assert "description" not in serialized[name] + + +def _remote_parameters_for_isolation(schema, data=None): + return RemoteEvalParameters( + id="params-123", + project_id="project-123", + name="Saved parameters", + slug="saved-parameters", + version="v1", + schema=schema, + data={} if data is None else data, + ) + + +def test_remote_parameter_defaults_are_isolated_between_validations(): + parameters = _remote_parameters_for_isolation( + {"type": "object", "properties": {"labels": {"type": "array", "items": {"type": "string"}, "default": []}}} + ) + first = validate_parameters({}, parameters) + first["labels"].append("first-run-only") + assert validate_parameters({}, parameters) == {"labels": []} + assert parameters.schema["properties"]["labels"]["default"] == [] + + +def test_remote_parameter_nested_defaults_do_not_mutate_inputs(): + parameters = _remote_parameters_for_isolation( + { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": {"type": "object", "properties": {"limit": {"type": "integer", "default": 3}}}, + } + }, + } + ) + supplied = {"configs": [{}]} + assert validate_parameters(supplied, parameters) == {"configs": [{"limit": 3}]} + assert supplied == {"configs": [{}]} + + +def test_remote_parameter_results_do_not_mutate_saved_data(): + parameters = _remote_parameters_for_isolation( + {"type": "object", "properties": {"config": {"type": "object"}}}, {"config": {"limit": 3}} + ) + first = validate_parameters({}, parameters) + first["config"]["limit"] = 9 + assert validate_parameters({}, parameters) == {"config": {"limit": 3}} + + +def test_remote_parameter_validation_preserves_opaque_values(): + class Opaque: + def __deepcopy__(self, memo): + raise AssertionError("Opaque parameter values must not be deep-copied") + + value = Opaque() + parameters = _remote_parameters_for_isolation({"type": "object"}) + result = validate_parameters({"config": {"value": value}}, parameters) + assert result["config"]["value"] is value + + +def test_invalid_remote_parameters_do_not_apply_defaults_to_input(): + parameters = _remote_parameters_for_isolation( + { + "type": "object", + "properties": { + "config": {"type": "object", "properties": {"limit": {"type": "integer", "default": 3}}}, + "required_value": {"type": "integer"}, + }, + "required": ["required_value"], + } + ) + supplied = {"config": {}} + with pytest.raises(ValueError, match="Invalid parameters"): + validate_parameters(supplied, parameters) + assert supplied == {"config": {}}