Files
2026-07-13 13:22:34 +08:00

624 lines
20 KiB
Python

import importlib
import json
from unittest import mock
import dspy
import dspy.teleprompt
import pytest
from dspy.utils.dummies import DummyLM, dummy_rm
from packaging.version import Version
import mlflow
from mlflow.exceptions import MlflowException
from mlflow.models import Model, ModelSignature
from mlflow.types.schema import ColSpec, Schema
from tests.helper_functions import (
_assert_pip_requirements,
_compare_logged_code_paths,
_mlflow_major_version_string,
expect_status_code,
pyfunc_serve_and_score_model,
)
_DSPY_VERSION = Version(importlib.metadata.version("dspy"))
_DSPY_UNDER_2_6 = _DSPY_VERSION < Version("2.6.0")
_DSPY_2_6_23_OR_OLDER = _DSPY_VERSION <= Version("2.6.23")
skip_if_2_6_23_or_older = pytest.mark.skipif(
_DSPY_2_6_23_OR_OLDER,
reason="Streaming API is only supported in dspy 2.6.24 or later.",
)
_REASONING_KEYWORD = "rationale" if _DSPY_UNDER_2_6 else "reasoning"
@pytest.fixture
def dummy_model():
return DummyLM([
{"answer": answer, _REASONING_KEYWORD: "reason"} for answer in ["4", "6", "8", "10"]
])
class CoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought("question -> answer")
def forward(self, question):
return self.prog(question=question)
class NumericalCoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought("question -> answer: int")
def forward(self, question):
return self.prog(question=question).answer
@pytest.fixture(autouse=True)
def reset_dspy_settings():
yield
dspy.settings.configure(lm=None, rm=None)
use_dspy_model_save_param = pytest.param(
True,
marks=pytest.mark.skipif(
Version(dspy.__version__) <= Version("3.1.0"),
reason="dspy<=3.1.0 does not support 'use_dspy_model_save' param.",
),
)
@pytest.mark.parametrize("use_dspy_model_save", [use_dspy_model_save_param, False])
def test_basic_save(use_dspy_model_save):
if use_dspy_model_save and _DSPY_VERSION <= Version("2.6.0"):
pytest.skip("'use_dspy_model_save' = True does not support dspy <= 2.6.0")
dspy_model = CoT()
dspy.settings.configure(lm=dspy.LM(model="openai/gpt-4o-mini", max_tokens=250))
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name="model",
use_dspy_model_save=use_dspy_model_save,
)
# Clear the lm setting to test the loading logic.
dspy.settings.configure(lm=None)
loaded_model = mlflow.dspy.load_model(model_info.model_uri)
# Check that the global settings is popped back.
assert dspy.settings.lm.model == "openai/gpt-4o-mini"
assert isinstance(loaded_model, CoT)
@pytest.mark.parametrize("use_dspy_model_save", [use_dspy_model_save_param, False])
def test_save_compiled_model(dummy_model, use_dspy_model_save):
train_data = [
"What is 2 + 2?",
"What is 3 + 3?",
"What is 4 + 4?",
"What is 5 + 5?",
]
train_label = ["4", "6", "8", "10"]
trainset = [
dspy.Example(question=q, answer=a).with_inputs("question")
for q, a in zip(train_data, train_label)
]
def dummy_metric(program):
return 1.0
dspy.settings.configure(lm=dummy_model)
dspy_model = CoT()
optimizer = dspy.teleprompt.BootstrapFewShot(metric=dummy_metric)
optimized_cot = optimizer.compile(dspy_model, trainset=trainset)
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
optimized_cot, name="model", use_dspy_model_save=use_dspy_model_save
)
# Clear the lm setting to test the loading logic.
dspy.settings.configure(lm=None)
loaded_model = mlflow.dspy.load_model(model_info.model_uri)
assert isinstance(loaded_model, CoT)
assert loaded_model.prog.predictors()[0].demos == optimized_cot.prog.predictors()[0].demos
@pytest.mark.parametrize("use_dspy_model_save", [use_dspy_model_save_param, False])
def test_dspy_save_preserves_object_state(use_dspy_model_save):
class GenerateAnswer(dspy.Signature):
"""Answer questions with short factoid answers."""
context = dspy.InputField(desc="may contain relevant facts")
question = dspy.InputField()
answer = dspy.OutputField(desc="often between 1 and 5 words")
class RAG(dspy.Module):
def __init__(self, num_passages=3):
super().__init__()
self.retrieve = dspy.Retrieve(k=num_passages)
self.generate_answer = dspy.ChainOfThought(GenerateAnswer)
def forward(self, question):
assert question == "What is 2 + 2?"
context = self.retrieve(question).passages
prediction = self.generate_answer(context=context, question=question)
return dspy.Prediction(context=context, answer=prediction.answer)
def dummy_metric(*args, **kwargs):
return 1.0
model = DummyLM([{"answer": answer, "reasoning": "reason"} for answer in ["4", "6", "8", "10"]])
rm = dummy_rm(passages=["dummy1", "dummy2", "dummy3"])
dspy.settings.configure(lm=model, rm=rm)
train_data = [
"What is 2 + 2?",
"What is 3 + 3?",
"What is 4 + 4?",
"What is 5 + 5?",
]
train_label = ["4", "6", "8", "10"]
trainset = [
dspy.Example(question=q, answer=a).with_inputs("question").with_inputs("reasoning")
for q, a in zip(train_data, train_label)
]
dspy_model = RAG()
optimizer = dspy.teleprompt.BootstrapFewShot(metric=dummy_metric)
optimized_cot = optimizer.compile(dspy_model, trainset=trainset)
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
optimized_cot, name="model", use_dspy_model_save=use_dspy_model_save
)
original_settings = dict(dspy.settings.config)
original_settings["traces"] = None
# Clear the lm setting to test the loading logic.
dspy.settings.configure(lm=None)
model_url = model_info.model_uri
input_examples = {"inputs": ["What is 2 + 2?"]}
# test that the model can be served
response = pyfunc_serve_and_score_model(
model_uri=model_url,
data=json.dumps(input_examples),
content_type="application/json",
extra_args=["--env-manager", "local"],
)
expect_status_code(response, 200)
loaded_model = mlflow.dspy.load_model(model_url)
assert isinstance(loaded_model, RAG)
assert loaded_model.retrieve is not None
assert (
loaded_model.generate_answer.predictors()[0].demos
== optimized_cot.generate_answer.predictors()[0].demos
)
loaded_settings = dict(dspy.settings.config)
loaded_settings["traces"] = None
assert loaded_settings["lm"].model == original_settings["lm"].model
assert loaded_settings["lm"].model_type == original_settings["lm"].model_type
assert loaded_settings["rm"].__dict__ == original_settings["rm"].__dict__
del (
loaded_settings["lm"],
original_settings["lm"],
loaded_settings["rm"],
original_settings["rm"],
)
assert original_settings == loaded_settings
@pytest.mark.parametrize("use_dspy_model_save", [use_dspy_model_save_param, False])
def test_load_logged_model_in_native_dspy(dummy_model, use_dspy_model_save):
dspy_model = CoT()
# Arbitrary set the demo to test saving/loading has no data loss.
dspy_model.prog.predictors()[0].demos = [
"What is 2 + 2?",
"What is 3 + 3?",
"What is 4 + 4?",
"What is 5 + 5?",
]
dspy.settings.configure(lm=dummy_model)
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model, name="model", use_dspy_model_save=use_dspy_model_save
)
loaded_dspy_model = mlflow.dspy.load_model(model_info.model_uri)
assert isinstance(loaded_dspy_model, CoT)
assert loaded_dspy_model.prog.predictors()[0].demos == dspy_model.prog.predictors()[0].demos
def test_serving_logged_model(dummy_model):
class CoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought("question -> answer")
def forward(self, question):
assert question == "What is 2 + 2?"
return self.prog(question=question)
dspy_model = CoT()
dspy.settings.configure(lm=dummy_model)
input_examples = {"inputs": ["What is 2 + 2?"]}
input_schema = Schema([ColSpec("string")])
output_schema = Schema([ColSpec("string")])
signature = ModelSignature(inputs=input_schema, outputs=output_schema)
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name=artifact_path,
signature=signature,
input_example=["What is 2 + 2?"],
)
model_uri = model_info.model_uri
dspy.settings.configure(lm=None)
response = pyfunc_serve_and_score_model(
model_uri=model_uri,
data=json.dumps(input_examples),
content_type="application/json",
extra_args=["--env-manager", "local"],
)
expect_status_code(response, 200)
json_response = json.loads(response.content)
assert _REASONING_KEYWORD in json_response["predictions"]
assert "answer" in json_response["predictions"]
def test_log_model_multi_inputs(dummy_model):
class MultiInputCoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought("question, hint -> answer")
def forward(self, question, hint):
assert question == "What is 2 + 2?"
assert hint == "Hint: 2 + 2 = ?"
return self.prog(question=question, hint=hint)
dspy_model = MultiInputCoT()
dspy.settings.configure(lm=dummy_model)
input_example = {"question": "What is 2 + 2?", "hint": "Hint: 2 + 2 = ?"}
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name="model",
input_example=input_example,
)
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert loaded_model.predict(input_example) == {"answer": "6", _REASONING_KEYWORD: "reason"}
response = pyfunc_serve_and_score_model(
model_uri=model_info.model_uri,
data=json.dumps({"inputs": [input_example]}),
content_type="application/json",
extra_args=["--env-manager", "local"],
)
expect_status_code(response, 200)
json_response = json.loads(response.content)
assert _REASONING_KEYWORD in json_response["predictions"]
assert "answer" in json_response["predictions"]
def test_save_chat_model_with_string_output(dummy_model):
class CoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought("question -> answer")
def forward(self, inputs):
# DSPy chat model's inputs is a list of dict with keys roles (optional) and content.
# And here we output a single string.
return self.prog(question=inputs[0]["content"]).answer
dspy_model = CoT()
dspy.settings.configure(lm=dummy_model)
input_examples = {"messages": [{"role": "user", "content": "What is 2 + 2?"}]}
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name=artifact_path,
task="llm/v1/chat",
input_example=input_examples,
)
loaded_pyfunc = mlflow.pyfunc.load_model(model_info.model_uri)
response = loaded_pyfunc.predict(input_examples)
assert "choices" in response
assert len(response["choices"]) == 1
assert "message" in response["choices"][0]
# The content should just be a string.
assert response["choices"][0]["message"]["content"] == "4"
def test_serve_chat_model(dummy_model):
class CoT(dspy.Module):
def __init__(self):
super().__init__()
self.prog = dspy.ChainOfThought("question -> answer")
def forward(self, inputs):
return self.prog(question=inputs[0]["content"])
dspy_model = CoT()
dspy.settings.configure(lm=dummy_model)
input_examples = {"messages": [{"role": "user", "content": "What is 2 + 2?"}]}
artifact_path = "model"
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name=artifact_path,
task="llm/v1/chat",
input_example=input_examples,
)
dspy.settings.configure(lm=None)
response = pyfunc_serve_and_score_model(
model_uri=model_info.model_uri,
data=json.dumps(input_examples),
content_type="application/json",
extra_args=["--env-manager", "local"],
)
expect_status_code(response, 200)
json_response = json.loads(response.content)
assert "choices" in json_response
assert len(json_response["choices"]) == 1
assert "message" in json_response["choices"][0]
assert _REASONING_KEYWORD in json_response["choices"][0]["message"]["content"]
assert "answer" in json_response["choices"][0]["message"]["content"]
def test_code_paths_is_used():
artifact_path = "model"
dspy_model = CoT()
with (
mlflow.start_run(),
mock.patch("mlflow.dspy.load._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.dspy.log_model(dspy_model, name=artifact_path, code_paths=[__file__])
_compare_logged_code_paths(__file__, model_info.model_uri, "dspy")
mlflow.dspy.load_model(model_info.model_uri)
add_mock.assert_called()
def test_additional_pip_requirements():
expected_mlflow_version = _mlflow_major_version_string()
artifact_path = "model"
dspy_model = CoT()
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model, name=artifact_path, extra_pip_requirements=["dummy"]
)
_assert_pip_requirements(model_info.model_uri, [expected_mlflow_version, "dummy"])
def test_infer_signature_from_input_examples(dummy_model):
artifact_path = "model"
dspy_model = CoT()
dspy.settings.configure(lm=dummy_model)
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model, name=artifact_path, input_example="what is 2 + 2?"
)
loaded_model = Model.load(model_info.model_uri)
assert loaded_model.signature.inputs == Schema([ColSpec("string")])
assert loaded_model.signature.outputs == Schema([
ColSpec(name="answer", type="string"),
ColSpec(name=_REASONING_KEYWORD, type="string"),
])
@skip_if_2_6_23_or_older
def test_predict_stream_unsupported_schema(dummy_model):
dspy_model = NumericalCoT()
dspy.settings.configure(lm=dummy_model)
model_info = mlflow.dspy.log_model(dspy_model, name="model")
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert not loaded_model._model_meta.flavors["python_function"]["streamable"]
output = loaded_model.predict_stream({"question": "What is 2 + 2?"})
with pytest.raises(
mlflow.exceptions.MlflowException,
match="This model does not support predict_stream method.",
):
next(output)
@skip_if_2_6_23_or_older
def test_predict_stream_success(dummy_model):
dspy_model = CoT()
dspy.settings.configure(lm=dummy_model)
model_info = mlflow.dspy.log_model(
dspy_model, name="model", input_example={"question": "what is 2 + 2?"}
)
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert loaded_model._model_meta.flavors["python_function"]["streamable"]
results = []
def dummy_streamify(*args, **kwargs):
# In dspy>=3, `StreamResponse` requires `is_last_chunk` argument.
# https://github.com/stanfordnlp/dspy/pull/8587
extra_kwargs = {"is_last_chunk": False} if _DSPY_VERSION.major >= 3 else {}
yield dspy.streaming.StreamResponse(
predict_name="prog.predict",
signature_field_name="answer",
chunk="2",
**extra_kwargs,
)
extra_kwargs = {"is_last_chunk": True} if _DSPY_VERSION.major >= 3 else {}
yield dspy.streaming.StreamResponse(
predict_name="prog.predict",
signature_field_name=_REASONING_KEYWORD,
chunk="reason",
**extra_kwargs,
)
with mock.patch("dspy.streamify", return_value=dummy_streamify):
output = loaded_model.predict_stream({"question": "What is 2 + 2?"})
for o in output:
results.append(o)
assert len(results) == 2
extra_kwargs = {"is_last_chunk": False} if _DSPY_VERSION.major >= 3 else {}
assert results[0] == {
"predict_name": "prog.predict",
"signature_field_name": "answer",
"chunk": "2",
**extra_kwargs,
}
extra_kwargs = {"is_last_chunk": True} if _DSPY_VERSION.major >= 3 else {}
assert results[1] == {
"predict_name": "prog.predict",
"signature_field_name": _REASONING_KEYWORD,
"chunk": "reason",
**extra_kwargs,
}
def test_predict_output(dummy_model):
class MockModelReturningNonPrediction(dspy.Module):
def forward(self, question):
# Return a plain dict instead of dspy.Prediction
return {"answer": "4", "custom_field": "custom_value"}
class MockModelReturningPrediction(dspy.Module):
def forward(self, question):
# Return a dspy.Prediction
prediction = dspy.Prediction()
prediction.answer = "4"
prediction.custom_field = "custom_value"
return prediction
dspy.settings.configure(lm=dummy_model)
non_prediction_model = MockModelReturningNonPrediction()
model_info = mlflow.dspy.log_model(non_prediction_model, name="non_prediction_model")
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
result = loaded_model.predict("What is 2 + 2?")
assert isinstance(result, dict)
assert result == {"answer": "4", "custom_field": "custom_value"}
prediction_model = MockModelReturningPrediction()
model_info = mlflow.dspy.log_model(prediction_model, name="prediction_model")
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
result = loaded_model.predict("What is 2 + 2?")
assert isinstance(result, dict)
assert result == {"answer": "4", "custom_field": "custom_value"}
def test_load_model_disallows_pickle_deserialization_legacy_pkl(monkeypatch):
dspy_model = CoT()
dspy.settings.configure(lm=dspy.LM(model="openai/gpt-4o-mini", max_tokens=250))
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name="model",
use_dspy_model_save=False,
)
monkeypatch.setenv("MLFLOW_ALLOW_PICKLE_DESERIALIZATION", "false")
with pytest.raises(MlflowException, match="MLFLOW_ALLOW_PICKLE_DESERIALIZATION"):
mlflow.dspy.load_model(model_info.model_uri)
@pytest.mark.skipif(
_DSPY_VERSION <= Version("3.1.0"),
reason="'use_dspy_model_save' = True does not support dspy <= 3.1.0",
)
@pytest.mark.parametrize(("env_value", "expected_allow_pickle"), [("false", False), ("true", True)])
def test_load_model_forwards_allow_pickle_to_dspy(env_value, expected_allow_pickle, monkeypatch):
dspy_model = CoT()
dspy.settings.configure(lm=dspy.LM(model="openai/gpt-4o-mini", max_tokens=250))
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name="model",
use_dspy_model_save=True,
)
monkeypatch.setenv("MLFLOW_ALLOW_PICKLE_DESERIALIZATION", env_value)
with mock.patch("dspy.load") as mock_load:
try:
mlflow.dspy.load_model(model_info.model_uri)
except Exception:
pass # downstream code may fail on the MagicMock; only the kwarg matters here
mock_load.assert_called_once()
assert mock_load.call_args.kwargs["allow_pickle"] is expected_allow_pickle
@pytest.mark.skipif(
_DSPY_VERSION <= Version("3.1.0"),
reason="'use_dspy_model_save' = True does not support dspy <= 3.1.0",
)
def test_load_model_wraps_dspy_error_when_pickle_disabled(monkeypatch):
dspy_model = CoT()
dspy.settings.configure(lm=dspy.LM(model="openai/gpt-4o-mini", max_tokens=250))
with mlflow.start_run():
model_info = mlflow.dspy.log_model(
dspy_model,
name="model",
use_dspy_model_save=True,
)
monkeypatch.setenv("MLFLOW_ALLOW_PICKLE_DESERIALIZATION", "false")
with mock.patch("dspy.load", side_effect=ValueError("pickle disallowed by dspy")):
with pytest.raises(MlflowException, match="MLFLOW_ALLOW_PICKLE_DESERIALIZATION"):
mlflow.dspy.load_model(model_info.model_uri)