Files
mlflow--mlflow/tests/langchain/test_langchain_model_export.py
2026-07-13 13:22:34 +08:00

2307 lines
82 KiB
Python

import inspect
import json
import os
import shutil
from operator import itemgetter
from typing import Any, Iterator
from unittest import mock
import langchain
import pytest
import yaml
from langchain_community.document_loaders import TextLoader
from langchain_community.embeddings.fake import FakeEmbeddings
from langchain_community.llms import OpenAI
from langchain_community.utilities import TextRequestsWrapper
from langchain_community.vectorstores import FAISS
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.callbacks.manager import CallbackManagerForLLMRun
from langchain_core.language_models import SimpleChatModel
from langchain_core.messages import (
AIMessage,
AIMessageChunk,
BaseMessage,
HumanMessage,
)
from langchain_core.output_parsers import StrOutputParser
from langchain_core.outputs import ChatGenerationChunk
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, PromptTemplate
from langchain_core.runnables import (
RunnableBinding,
RunnableBranch,
RunnableLambda,
RunnableParallel,
RunnablePassthrough,
RunnableSequence,
)
from langchain_core.tools import Tool
from langchain_openai import ChatOpenAI
from langchain_text_splitters.character import CharacterTextSplitter
from packaging import version
from pydantic import BaseModel
from pyspark.sql import SparkSession
import mlflow
import mlflow.models.model
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
from mlflow.deployments import PredictionsResponse
from mlflow.environment_variables import MLFLOW_CONVERT_MESSAGES_DICT_FOR_LANGCHAIN
from mlflow.exceptions import MlflowException
from mlflow.langchain.langchain_tracer import MlflowLangchainTracer
from mlflow.langchain.utils.chat import (
try_transform_response_to_chat_format,
)
from mlflow.langchain.utils.logging import (
IS_PICKLE_SERIALIZATION_RESTRICTED,
lc_runnables_types,
)
from mlflow.models import Model
from mlflow.models.dependencies_schemas import DependenciesSchemasType
from mlflow.models.resources import (
DatabricksFunction,
DatabricksServingEndpoint,
DatabricksSQLWarehouse,
DatabricksVectorSearchIndex,
)
from mlflow.models.signature import Schema, infer_signature
from mlflow.models.utils import load_serving_example
from mlflow.pyfunc.context import Context
from mlflow.tracing.constant import TraceMetadataKey
from mlflow.tracing.export.inference_table import pop_trace
from mlflow.tracking.artifact_utils import _download_artifact_from_uri
from mlflow.types.schema import Array, ColSpec, DataType, Object, Property
from tests.helper_functions import _compare_logged_code_paths, pyfunc_serve_and_score_model
from tests.langchain.conftest import DeterministicDummyEmbeddings
from tests.tracing.helper import get_traces
# this kwarg was added in langchain_community 0.0.27, and
# prevents the use of pickled objects if not provided.
VECTORSTORE_KWARGS = (
{"allow_dangerous_deserialization": True} if IS_PICKLE_SERIALIZATION_RESTRICTED else {}
)
IS_LANGCHAIN_03 = version.parse(langchain.__version__) >= version.parse("0.3.0")
IS_LANGCHAIN_v1 = version.parse(langchain.__version__).major >= 1
LANGCHAIN_V1_SKIP_REASON = "Pickle serialization is not supported for LangChain v1"
# Reusable decorator for skipping tests on LangChain v1
skip_if_v1 = pytest.mark.skipif(IS_LANGCHAIN_v1, reason=LANGCHAIN_V1_SKIP_REASON)
# langchain 0.3.30 removed legacy `.save()` from VectorStoreRetriever and chain classes,
# so the object-based `mlflow.langchain.log_model(chain, ...)` path raises NotImplementedError.
# Users on these versions should migrate to models-from-code.
skip_if_legacy_save_removed = pytest.mark.skipif(
version.parse(langchain.__version__) >= version.parse("0.3.30"),
reason="VectorStoreRetriever.save() removed in langchain 0.3.30+; use models-from-code",
)
# The mock OAI completion endpoint returns payload as it is
TEST_CONTENT = [{"role": "user", "content": "What is MLflow?"}]
SIMPLE_MODEL_CODE_PATH = "tests/langchain/sample_code/simple_runnable.py"
@pytest.fixture
def model_path(tmp_path):
return tmp_path / "model"
@pytest.fixture(scope="module")
def spark():
with SparkSession.builder.master("local[*]").getOrCreate() as s:
yield s
def create_openai_runnable():
from langchain_core.output_parsers import StrOutputParser
prompt = PromptTemplate(
input_variables=["product"],
template="What is {product}?",
)
return prompt | ChatOpenAI(temperature=0.9) | StrOutputParser()
@pytest.fixture
def fake_chat_model():
class FakeChatModel(SimpleChatModel):
"""Fake Chat Model wrapper for testing purposes."""
endpoint_name: str = "fake-endpoint"
def _call(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> str:
return "Databricks"
@property
def _llm_type(self) -> str:
return "fake chat model"
return FakeChatModel(endpoint_name="fake-endpoint")
@pytest.fixture
def fake_classifier_chat_model():
class FakeMlflowClassifier(SimpleChatModel):
"""Fake Chat Model wrapper for testing purposes."""
def _call(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> str:
if "MLflow" in messages[0].content.split(":")[1]:
return "yes"
if "cat" in messages[0].content.split(":")[1]:
return "no"
return "unknown"
@property
def _llm_type(self) -> str:
return "fake mlflow classifier"
return FakeMlflowClassifier()
@skip_if_v1
def test_langchain_native_log_and_load_model():
model = create_openai_runnable()
with mlflow.start_run():
logged_model = mlflow.langchain.log_model(
model, name="langchain_model", input_example={"product": "MLflow"}
)
loaded_model = mlflow.langchain.load_model(logged_model.model_uri)
assert "langchain" in logged_model.flavors
assert str(logged_model.signature.inputs) == "['product': string (required)]"
assert str(logged_model.signature.outputs) == "[string (required)]"
assert type(loaded_model) == RunnableSequence
assert loaded_model.steps[0].template == "What is {product}?"
assert type(loaded_model.steps[1]).__name__ == "ChatOpenAI"
# Predict
loaded_model = mlflow.pyfunc.load_model(logged_model.model_uri)
result = loaded_model.predict([{"product": "MLflow"}])
assert result == [json.dumps(TEST_CONTENT)]
# Predict stream
result = loaded_model.predict_stream([{"product": "MLflow"}])
assert inspect.isgenerator(result)
assert list(result) == ["Hello", " world"]
@skip_if_v1
def test_pyfunc_spark_udf_with_langchain_model(spark):
model = create_openai_runnable()
with mlflow.start_run():
logged_model = mlflow.langchain.log_model(
model, name="langchain_model", input_example={"product": "MLflow"}
)
loaded_model = mlflow.pyfunc.spark_udf(spark, logged_model.model_uri, result_type="string")
df = spark.createDataFrame([("MLflow",), ("Spark",)], ["product"])
df = df.withColumn("answer", loaded_model())
pdf = df.toPandas()
assert pdf["answer"].tolist() == [
'[{"role": "user", "content": "What is MLflow?"}]',
'[{"role": "user", "content": "What is Spark?"}]',
]
@pytest.mark.skipif(not IS_LANGCHAIN_v1, reason="create_agent is not supported in LangChain v0")
def test_langchain_agent_model_predict(monkeypatch):
input_example = {"input": "What is 2 * 3?"}
with mlflow.start_run():
logged_model = mlflow.langchain.log_model(
# OpenAI Client since 1.0 contains thread lock object that cannot be
# pickled. Therefore, AgentExecutor cannot be saved with the legacy
# object-based logging and we need to use Model-from-Code logging.
"tests/langchain/sample_code/openai_agent.py",
name="langchain_model",
input_example=input_example,
)
loaded_model = mlflow.pyfunc.load_model(logged_model.model_uri)
# Basic prediction
response = loaded_model.predict([input_example])
expected_output = "The result of 2 * 3 is 6."
assert response[0]["messages"][-1]["content"] == expected_output
# Stream prediction
response = loaded_model.predict_stream([input_example])
assert inspect.isgenerator(response)
assert list(response) == [
{"model": {"messages": [mock.ANY]}},
{"tools": {"messages": [mock.ANY]}},
{"model": {"messages": [mock.ANY]}},
]
# Model serving
inference_payload = load_serving_example(logged_model.model_uri)
response = pyfunc_serve_and_score_model(
logged_model.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
# TODO: The response is not wrapped by the "predictions" key. This is a bug in
# output handling. Often the user input contains a key "input" because it is
# used in popular agent prompts in the hub. However, this confuses the scoring
# server to treat it as a llm/v1/completion request.
response = json.loads(response.content.decode("utf-8"))
assert response[0]["messages"][-1]["content"] == expected_output
def assert_equal_retrievers(retriever, expected_retriever):
from langchain.schema.retriever import BaseRetriever
assert isinstance(retriever, BaseRetriever)
assert isinstance(retriever, type(expected_retriever))
assert isinstance(retriever.vectorstore, type(expected_retriever.vectorstore))
assert retriever.tags == expected_retriever.tags
assert retriever.metadata == expected_retriever.metadata
assert retriever.search_type == expected_retriever.search_type
assert retriever.search_kwargs == expected_retriever.search_kwargs
@skip_if_v1
@skip_if_legacy_save_removed
def test_log_and_load_retriever_chain(tmp_path):
# Create the vector db, persist the db to a local fs folder
loader = TextLoader("tests/langchain/state_of_the_union.txt")
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=256, chunk_overlap=0)
docs = text_splitter.split_documents(documents)
embeddings = DeterministicDummyEmbeddings(size=5)
db = FAISS.from_documents(docs, embeddings)
persist_dir = str(tmp_path / "faiss_index")
db.save_local(persist_dir)
# Define the loader_fn
def load_retriever(persist_directory):
import numpy as np
from langchain.embeddings.base import Embeddings
class DeterministicDummyEmbeddings(Embeddings, BaseModel):
size: int
def _get_embedding(self, text: str) -> list[float]:
if isinstance(text, np.ndarray):
text = text.item()
seed = abs(hash(text)) % (10**8)
np.random.seed(seed)
return list(np.random.normal(size=self.size))
def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [self._get_embedding(t) for t in texts]
def embed_query(self, text: str) -> list[float]:
return self._get_embedding(text)
embeddings = DeterministicDummyEmbeddings(size=5)
vectorstore = FAISS.load_local(
persist_directory,
embeddings,
**VECTORSTORE_KWARGS,
)
return vectorstore.as_retriever()
query = "What did the president say about Ketanji Brown Jackson"
langchain_input = {"query": query}
# Log the retriever
with mlflow.start_run():
logged_model = mlflow.langchain.log_model(
db.as_retriever(),
name="retriever",
loader_fn=load_retriever,
persist_dir=persist_dir,
input_example=langchain_input,
)
# Remove the persist_dir
shutil.rmtree(persist_dir)
# Load the retriever
loaded_model = mlflow.langchain.load_model(logged_model.model_uri)
assert_equal_retrievers(loaded_model, db.as_retriever())
loaded_pyfunc_model = mlflow.pyfunc.load_model(logged_model.model_uri)
result = loaded_pyfunc_model.predict([langchain_input])
expected_result = [
{
"page_content": doc.page_content,
"metadata": doc.metadata,
"type": "Document",
"id": mock.ANY,
}
for doc in db.as_retriever().get_relevant_documents(query)
]
assert result == [expected_result]
# Serve the retriever
inference_payload = load_serving_example(logged_model.model_uri)
response = pyfunc_serve_and_score_model(
logged_model.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
pred = PredictionsResponse.from_json(response.content.decode("utf-8"))["predictions"]
assert type(pred) == list
assert len(pred) == 1
docs_list = pred[0]
assert type(docs_list) == list
assert len(docs_list) == 4
# The returned docs are non-deterministic when used with dummy embeddings,
# so we cannot assert pred == {"predictions": [expected_result]}
def load_requests_wrapper(_):
return TextRequestsWrapper(headers=None, aiosession=None)
@skip_if_v1
@skip_if_legacy_save_removed
def test_agent_with_unpicklable_tools(tmp_path):
from langchain.agents import AgentType, initialize_agent
tmp_file = tmp_path / "temp_file.txt"
with open(tmp_file, mode="w") as temp_file:
# files that aren't opened for reading cannot be pickled
tools = [
Tool.from_function(
func=lambda: temp_file,
name="Write 0",
description="If you need to write 0 to a file",
)
]
agent = initialize_agent(
llm=OpenAI(temperature=0),
tools=tools,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
)
with pytest.raises(
MlflowException,
match=(
"Error when attempting to pickle the AgentExecutor tools. "
"This model likely does not support serialization."
),
):
with mlflow.start_run():
mlflow.langchain.log_model(agent, name="unpicklable_tools")
@skip_if_v1
def test_save_load_runnable_passthrough():
runnable = RunnablePassthrough()
assert runnable.invoke("hello") == "hello"
input_example = "hello"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
runnable, name="model_path", input_example=input_example
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke(input_example) == "hello"
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict(["hello"]) == ["hello"]
@skip_if_v1
def test_save_load_runnable_lambda(spark):
def add_one(x: int) -> int:
return x + 1
runnable = RunnableLambda(add_one)
assert runnable.invoke(1) == 2
assert runnable.batch([1, 2, 3]) == [2, 3, 4]
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
runnable, name="runnable_lambda", input_example=[1, 2, 3]
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke(1) == 2
assert loaded_model.batch([1, 2, 3]) == [2, 3, 4]
loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert loaded_model.predict(1) == [2]
assert loaded_model.predict([1, 2, 3]) == [2, 3, 4]
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri, result_type="long")
df = spark.createDataFrame([(1,), (2,), (3,)], ["data"])
df = df.withColumn("answer", udf("data"))
pdf = df.toPandas()
assert pdf["answer"].tolist() == [2, 3, 4]
@skip_if_v1
def test_save_load_runnable_lambda_in_sequence():
def add_one(x):
return x + 1
def mul_two(x):
return x * 2
runnable_1 = RunnableLambda(add_one)
runnable_2 = RunnableLambda(mul_two)
sequence = runnable_1 | runnable_2
assert sequence.invoke(1) == 4
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
sequence, name="model_path", input_example=[1, 2, 3]
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke(1) == 4
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict(1) == [4]
assert pyfunc_loaded_model.predict([1, 2, 3]) == [4, 6, 8]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": [4, 6, 8]
}
@skip_if_v1
def test_predict_with_callbacks(fake_chat_model):
class TestCallbackHandler(BaseCallbackHandler):
def __init__(self):
super().__init__()
self.num_llm_start_calls = 0
def on_llm_start(
self,
serialized: dict[str, Any],
prompts: list[str],
**kwargs: Any,
) -> Any:
self.num_llm_start_calls += 1
prompt = ChatPromptTemplate.from_template("What's your favorite {industry} company?")
chain = prompt | fake_chat_model | StrOutputParser()
# Test the basic functionality of the chain
assert chain.invoke({"industry": "tech"}) == "Databricks"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example={"industry": "tech"}
)
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
callback_handler1 = TestCallbackHandler()
callback_handler2 = TestCallbackHandler()
# Ensure handlers have not been called yet
assert callback_handler1.num_llm_start_calls == 0
assert callback_handler2.num_llm_start_calls == 0
assert (
pyfunc_loaded_model._model_impl._predict_with_callbacks(
{"industry": "tech"},
callback_handlers=[callback_handler1, callback_handler2],
)
== "Databricks"
)
# Test that the callback handlers were called
assert callback_handler1.num_llm_start_calls == 1
assert callback_handler2.num_llm_start_calls == 1
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": ["Databricks"]
}
@skip_if_v1
def test_predict_with_callbacks_supports_chat_response_conversion(fake_chat_model):
prompt = ChatPromptTemplate.from_template("What's your favorite {industry} company?")
chain = prompt | fake_chat_model | StrOutputParser()
# Test the basic functionality of the chain
assert chain.invoke({"industry": "tech"}) == "Databricks"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example={"industry": "tech"}
)
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
expected_chat_response = {
"id": None,
"object": "chat.completion",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Databricks",
},
"finish_reason": None,
}
],
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
}
with mock.patch("time.time", return_value=1677858242):
assert (
pyfunc_loaded_model._model_impl._predict_with_callbacks(
{"industry": "tech"},
convert_chat_responses=True,
)
== expected_chat_response
)
assert (
pyfunc_loaded_model._model_impl._predict_with_callbacks(
{"industry": "tech"},
convert_chat_responses=False,
)
== "Databricks"
)
@skip_if_v1
def test_save_load_runnable_parallel():
runnable = RunnableParallel({"llm": create_openai_runnable()})
expected_result = {"llm": json.dumps(TEST_CONTENT)}
assert runnable.invoke({"product": "MLflow"}) == expected_result
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
runnable, name="model_path", input_example=[{"product": "MLflow"}]
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke({"product": "MLflow"}) == expected_result
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict([{"product": "MLflow"}]) == [expected_result]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": [expected_result]
}
@skip_if_v1
def test_save_load_chain_with_model_paths():
prompt1 = PromptTemplate.from_template("what is the city {person} is from?")
llm = ChatOpenAI(temperature=0.9)
model = prompt1 | llm | StrOutputParser()
with mlflow.start_run():
model_info = mlflow.langchain.log_model(model, name="model_path")
artifact_path = "model_path"
with (
mlflow.start_run(),
mock.patch("mlflow.langchain.model._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.langchain.log_model(model, name=artifact_path, code_paths=[__file__])
mlflow.langchain.load_model(model_info.model_uri)
model_uri = model_info.model_uri
_compare_logged_code_paths(__file__, model_uri, mlflow.langchain.FLAVOR_NAME)
add_mock.assert_called()
@skip_if_v1
@skip_if_legacy_save_removed
def test_save_load_rag(tmp_path, spark, fake_chat_model):
# TODO: Migrate to models-from-code
# Create the vector db, persist the db to a local fs folder
loader = TextLoader("tests/langchain/state_of_the_union.txt")
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=10, chunk_overlap=0)
docs = text_splitter.split_documents(documents)
embeddings = DeterministicDummyEmbeddings(size=5)
db = FAISS.from_documents(docs, embeddings)
persist_dir = str(tmp_path / "faiss_index")
db.save_local(persist_dir)
retriever = db.as_retriever()
def load_retriever(persist_directory):
embeddings = FakeEmbeddings(size=5)
vectorstore = FAISS.load_local(
persist_directory,
embeddings,
**VECTORSTORE_KWARGS,
)
return vectorstore.as_retriever()
prompt = ChatPromptTemplate.from_template(
"Answer the following question based on the context: {context}\nQuestion: {question}"
)
retrieval_chain = (
{
"context": retriever,
"question": RunnablePassthrough(),
}
| prompt
| fake_chat_model
| StrOutputParser()
)
question = "What is a good name for a company that makes MLflow?"
answer = "Databricks"
assert retrieval_chain.invoke(question) == answer
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
retrieval_chain,
name="model_path",
loader_fn=load_retriever,
persist_dir=persist_dir,
input_example=question,
)
# Remove the persist_dir
shutil.rmtree(persist_dir)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke(question) == answer
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict(question) == [answer]
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri, result_type="string")
df = spark.createDataFrame([(question,), (question,)], ["question"])
df = df.withColumn("answer", udf("question"))
pdf = df.toPandas()
assert pdf["answer"].tolist() == [answer, answer]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": [answer]
}
@skip_if_v1
def test_runnable_branch_save_load():
branch = RunnableBranch(
(lambda x: isinstance(x, str), lambda x: x.upper()),
(lambda x: isinstance(x, int), lambda x: x + 1),
(lambda x: isinstance(x, float), lambda x: x * 2),
lambda x: "goodbye",
)
assert branch.invoke("hello") == "HELLO"
assert branch.invoke({}) == "goodbye"
with mlflow.start_run():
# We only support single input format for now, so we should
# not save signature for runnable branch which accepts multiple
# input types
model_info = mlflow.langchain.log_model(branch, name="model_path")
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke("hello") == "HELLO"
assert loaded_model.invoke({}) == "goodbye"
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict("hello") == "HELLO"
assert pyfunc_loaded_model.predict({}) == "goodbye"
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=json.dumps({"inputs": "hello"}),
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": "HELLO"
}
@skip_if_v1
def test_complex_runnable_branch_save_load(fake_chat_model, fake_classifier_chat_model):
prompt = ChatPromptTemplate.from_template("{question_is_relevant}\n{query}")
# Need to add prompt here as the chat model doesn't accept dict input
answer_model = prompt | fake_chat_model
decline_to_answer = RunnableLambda(
lambda x: "I cannot answer questions that are not about MLflow."
)
something_went_wrong = RunnableLambda(lambda x: "Something went wrong.")
is_question_about_mlflow_prompt = ChatPromptTemplate.from_template(
"You are classifying documents to know if this question "
"is related with MLflow. Only answer with yes or no. The question is: {query}"
)
branch_node = RunnableBranch(
(lambda x: x["question_is_relevant"].lower() == "yes", answer_model),
(lambda x: x["question_is_relevant"].lower() == "no", decline_to_answer),
something_went_wrong,
)
chain = (
{
"question_is_relevant": is_question_about_mlflow_prompt
| fake_classifier_chat_model
| StrOutputParser(),
"query": itemgetter("query"),
}
| branch_node
| StrOutputParser()
)
assert chain.invoke({"query": "Who owns MLflow?"}) == "Databricks"
assert (
chain.invoke({"query": "Do you like cat?"})
== "I cannot answer questions that are not about MLflow."
)
assert chain.invoke({"query": "Are you happy today?"}) == "Something went wrong."
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example={"query": "Who owns MLflow?"}
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke({"query": "Who owns MLflow?"}) == "Databricks"
assert (
loaded_model.invoke({"query": "Do you like cat?"})
== "I cannot answer questions that are not about MLflow."
)
assert loaded_model.invoke({"query": "Are you happy today?"}) == "Something went wrong."
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict({"query": "Who owns MLflow?"}) == ["Databricks"]
assert pyfunc_loaded_model.predict({"query": "Do you like cat?"}) == [
"I cannot answer questions that are not about MLflow."
]
assert pyfunc_loaded_model.predict({"query": "Are you happy today?"}) == [
"Something went wrong."
]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": ["Databricks"]
}
@skip_if_v1
def test_chat_with_history(spark, fake_chat_model):
prompt_with_history_str = """
Here is a history between you and a human: {chat_history}
Now, please answer this question: {question}
"""
prompt_with_history = PromptTemplate(
input_variables=["chat_history", "question"], template=prompt_with_history_str
)
def extract_question(input):
return input[-1]["content"]
def extract_history(input):
return input[:-1]
chain_with_history = (
{
"question": itemgetter("messages") | RunnableLambda(extract_question),
"chat_history": itemgetter("messages") | RunnableLambda(extract_history),
}
| prompt_with_history
| fake_chat_model
| StrOutputParser()
)
input_example = {"messages": [{"role": "user", "content": "Who owns MLflow?"}]}
assert chain_with_history.invoke(input_example) == "Databricks"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_with_history, name="model_path", input_example=input_example
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke(input_example) == "Databricks"
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
input_schema = pyfunc_loaded_model.metadata.get_input_schema()
assert input_schema == Schema([
ColSpec(
Array(
Object([
Property("role", DataType.string),
Property("content", DataType.string),
])
),
"messages",
)
])
assert pyfunc_loaded_model.predict(input_example) == ["Databricks"]
udf = mlflow.pyfunc.spark_udf(spark, model_info.model_uri, result_type="string")
df = spark.createDataFrame([(input_example["messages"],)], ["messages"])
df = df.withColumn("answer", udf("messages"))
pdf = df.toPandas()
assert pdf["answer"].tolist() == ["Databricks"]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert json.loads(response.content.decode("utf-8")) == ["Databricks"]
class ChatModel(SimpleChatModel):
def _call(self, messages, stop, run_manager, **kwargs):
return "\n".join([f"{message.type}: {message.content}" for message in messages])
@property
def _llm_type(self) -> str:
return "chat model"
@skip_if_v1
def test_predict_with_builtin_pyfunc_chat_conversion(spark):
# TODO: Migrate to models-from-code
input_example = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "assistant", "content": "What would you like to ask?"},
{"role": "user", "content": "Who owns MLflow?"},
]
}
content = (
"system: You are a helpful assistant.\n"
"ai: What would you like to ask?\n"
"human: Who owns MLflow?"
)
chain = ChatModel() | StrOutputParser()
assert chain.invoke([HumanMessage(content="Who owns MLflow?")]) == "human: Who owns MLflow?"
with pytest.raises(ValueError, match="Invalid input type"):
chain.invoke(input_example)
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example=input_example
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert (
loaded_model.invoke([HumanMessage(content="Who owns MLflow?")]) == "human: Who owns MLflow?"
)
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
expected_chat_response = {
"id": None,
"object": "chat.completion",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
},
"finish_reason": None,
}
],
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
}
with mock.patch("time.time", return_value=1677858242):
result1 = pyfunc_loaded_model.predict(input_example)
result1[0]["id"] = None
assert result1 == [expected_chat_response]
result2 = pyfunc_loaded_model.predict([input_example, input_example])
result2[0]["id"] = None
result2[1]["id"] = None
assert result2 == [
expected_chat_response,
expected_chat_response,
]
with pytest.raises(MlflowException, match="Unrecognized chat message role"):
pyfunc_loaded_model.predict({"messages": [{"role": "foobar", "content": "test content"}]})
@skip_if_v1
def test_predict_with_builtin_pyfunc_chat_conversion_for_aimessage_response():
class ChatModel(SimpleChatModel):
def _call(self, messages, stop, run_manager, **kwargs):
return "You own MLflow"
@property
def _llm_type(self) -> str:
return "chat model"
input_example = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "assistant", "content": "What would you like to ask?"},
{"role": "user", "content": "Who owns MLflow?"},
]
}
chain = ChatModel()
result = chain.invoke([HumanMessage(content="Who owns MLflow?")])
assert isinstance(result, AIMessage)
assert result.content == "You own MLflow"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example=input_example
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
result = loaded_model.invoke([HumanMessage(content="Who owns MLflow?")])
assert isinstance(result, AIMessage)
assert result.content == "You own MLflow"
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
with mock.patch("time.time", return_value=1677858242):
result = pyfunc_loaded_model.predict(input_example)
assert "id" in result[0], "Response message id is lost."
result[0]["id"] = None
assert result == [
{
"id": None,
"object": "chat.completion",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "You own MLflow",
},
"finish_reason": None,
}
],
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
}
]
@skip_if_v1
def test_pyfunc_builtin_chat_request_conversion_fails_gracefully():
chain = RunnablePassthrough() | itemgetter("messages")
# Ensure we're going to test that "messages" remains intact & unchanged even if it
# doesn't appear explicitly in the chain's input schema
assert "messages" not in chain.input_schema().model_fields
with mlflow.start_run():
model_info = mlflow.langchain.log_model(chain, name="model_path")
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict({"messages": "not an array"}) == "not an array"
# Verify that messages aren't converted to LangChain format if extra keys are present,
# under the assumption that additional keys can't be specified when calling LangChain invoke()
# / batch() with chat messages
assert pyfunc_loaded_model.predict({
"messages": [{"role": "user", "content": "blah"}],
"extrakey": "extra",
}) == [
{"role": "user", "content": "blah"},
]
# Verify that messages aren't converted to LangChain format if role / content are missing
# or extra keys are present in the message
assert pyfunc_loaded_model.predict({
"messages": [{"content": "blah"}],
}) == [
{"content": "blah"},
]
assert pyfunc_loaded_model.predict({
"messages": [{"role": "user", "content": "blah"}, {}],
}) == [
{"role": "user", "content": "blah"},
{},
]
assert pyfunc_loaded_model.predict({
"messages": [{"role": "user", "content": 123}],
}) == [
{"role": "user", "content": 123},
]
# Verify behavior for batches of message histories
assert pyfunc_loaded_model.predict([
{
"messages": "not an array",
},
{
"messages": [{"role": "user", "content": "content"}],
},
]) == [
"not an array",
[{"role": "user", "content": "content"}],
]
assert pyfunc_loaded_model.predict([
{
"messages": [{"role": "user", "content": "content"}],
},
{"messages": [{"role": "user", "content": "content"}], "extrakey": "extra"},
]) == [
[{"role": "user", "content": "content"}],
[{"role": "user", "content": "content"}],
]
assert pyfunc_loaded_model.predict([
{
"messages": [{"role": "user", "content": "content"}],
},
{
"messages": [
{"role": "user", "content": "content"},
{"role": "user", "content": 123},
],
},
]) == [
[{"role": "user", "content": "content"}],
[{"role": "user", "content": "content"}, {"role": "user", "content": 123}],
]
@skip_if_v1
def test_save_load_chain_that_relies_on_pickle_serialization(monkeypatch, model_path):
from langchain_community.llms.databricks import Databricks
monkeypatch.setattr(
"langchain_community.llms.databricks._DatabricksServingEndpointClient",
mock.MagicMock(),
)
monkeypatch.setenv("DATABRICKS_HOST", "test-host")
monkeypatch.setenv("DATABRICKS_TOKEN", "test-token")
llm_kwargs = {"endpoint_name": "test-endpoint", "temperature": 0.9}
if IS_PICKLE_SERIALIZATION_RESTRICTED:
llm_kwargs["allow_dangerous_deserialization"] = True
llm = Databricks(**llm_kwargs)
prompt = PromptTemplate(input_variables=["question"], template="I have a question: {question}")
chain = prompt | llm | StrOutputParser()
# Not passing an input_example to avoid triggering prediction
mlflow.langchain.save_model(chain, model_path)
loaded_model = mlflow.langchain.load_model(model_path)
# Check if the deserialized model has the same endpoint and temperature
loaded_databricks_llm = loaded_model.middle[0]
assert loaded_databricks_llm.endpoint_name == "test-endpoint"
assert loaded_databricks_llm.temperature == 0.9
def _get_message_content(predictions):
return predictions[0]["choices"][0]["message"]["content"]
@pytest.mark.parametrize(
("chain_path", "model_config"),
[
(
os.path.abspath("tests/langchain/sample_code/chain.py"),
os.path.abspath("tests/langchain/sample_code/config.yml"),
),
(
"tests/langchain/../langchain/sample_code/chain.py",
"tests/langchain/../langchain/sample_code/config.yml",
),
],
)
def test_save_load_chain_as_code(chain_path, model_config, monkeypatch):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
artifact_path = "model_path"
with mlflow.start_run() as run:
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path,
input_example=input_example,
model_config=model_config,
)
client = mlflow.tracking.MlflowClient()
run_id = run.info.run_id
assert client.get_run(run_id).data.params == {
"llm_prompt_template": "Answer the following question based on "
"the context: {context}\nQuestion: {question}",
"embedding_size": "5",
"not_used_array": "[1, 2, 3]",
"response": "Databricks",
}
assert mlflow.models.model_config.__mlflow_model_config__ is None
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
# During the loading process, MLflow executes the chain.py file to
# load the model class. It should not generate any traces even if
# the code enables autologging and invoke chain.
assert len(get_traces()) == 0
assert mlflow.models.model_config.__mlflow_model_config__ is None
answer = "Databricks"
assert loaded_model.invoke(input_example) == answer
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert answer == _get_message_content(pyfunc_loaded_model.predict(input_example))
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
predictions = json.loads(response.content.decode("utf-8"))
# Mock out the `created` timestamp as it is not deterministic
expected = [{**try_transform_response_to_chat_format(answer), "created": mock.ANY}]
assert expected == predictions
pyfunc_model_path = _download_artifact_from_uri(model_info.model_uri)
reloaded_model = Model.load(os.path.join(pyfunc_model_path, "MLmodel"))
assert reloaded_model.resources["databricks"] == {
"serving_endpoint": [{"name": "fake-endpoint"}]
}
assert reloaded_model.metadata["dependencies_schemas"] == {
DependenciesSchemasType.RETRIEVERS.value: [
{
"doc_uri": "doc-uri",
"name": "retriever",
"other_columns": ["column1", "column2"],
"primary_key": "primary-key",
"text_column": "text-column",
}
]
}
# Emulate the model serving environment
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
monkeypatch.setenv("ENABLE_MLFLOW_TRACING", "true")
mlflow.tracing.reset()
request_id = "mock_request_id"
tracer = MlflowLangchainTracer(prediction_context=Context(request_id))
input_example = {"messages": [{"role": "user", "content": json.dumps(TEST_CONTENT)}]}
response = pyfunc_loaded_model._model_impl._predict_with_callbacks(
data=input_example, callback_handlers=[tracer]
)
assert response["choices"][0]["message"]["content"] == "Databricks"
trace = pop_trace(request_id)
assert trace["info"]["tags"][DependenciesSchemasType.RETRIEVERS.value] == json.dumps([
{
"doc_uri": "doc-uri",
"name": "retriever",
"other_columns": ["column1", "column2"],
"primary_key": "primary-key",
"text_column": "text-column",
}
])
@pytest.mark.parametrize(
"chain_path",
[
os.path.abspath("tests/langchain/sample_code/chain.py"),
"tests/langchain/../langchain/sample_code/chain.py",
],
)
def test_save_load_chain_as_code_model_config_dict(chain_path):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name="model_path",
input_example=input_example,
model_config={
"response": "modified response",
"embedding_size": 5,
"llm_prompt_template": "answer the question",
},
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
answer = "modified response"
assert loaded_model.invoke(input_example) == answer
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert answer == _get_message_content(pyfunc_loaded_model.predict(input_example))
@pytest.mark.parametrize(
"model_config",
[
os.path.abspath("tests/langchain/sample_code/config.yml"),
"tests/langchain/../langchain/sample_code/config.yml",
],
)
def test_save_load_chain_as_code_with_different_names(tmp_path, model_config):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
# Read the contents of the original chain file
with open("tests/langchain/sample_code/chain.py") as chain_file:
chain_file_content = chain_file.read()
temp_file = tmp_path / "model.py"
temp_file.write_text(chain_file_content)
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
str(temp_file),
name="model_path",
input_example=input_example,
model_config=model_config,
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
answer = "Databricks"
assert loaded_model.invoke(input_example) == answer
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert answer == _get_message_content(pyfunc_loaded_model.predict(input_example))
@pytest.mark.parametrize(
"chain_path",
[
os.path.abspath("tests/langchain/sample_code/chain.py"),
"tests/langchain/../langchain/sample_code/chain.py",
],
)
@pytest.mark.parametrize(
"model_config",
[
os.path.abspath("tests/langchain/sample_code/config.yml"),
"tests/langchain/../langchain/sample_code/config.yml",
],
)
def test_save_load_chain_as_code_multiple_times(tmp_path, chain_path, model_config):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name="model_path",
input_example=input_example,
model_config=model_config,
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
with open(model_config) as f:
base_config = yaml.safe_load(f)
assert loaded_model.middle[0].messages[0].prompt.template == base_config["llm_prompt_template"]
file_name = "config_updated.yml"
new_config_file = str(tmp_path.joinpath(file_name))
new_config = base_config.copy()
new_config["llm_prompt_template"] = "new_template"
with open(new_config_file, "w") as f:
yaml.dump(new_config, f)
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name="model_path",
input_example=input_example,
model_config=new_config_file,
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.middle[0].messages[0].prompt.template == new_config["llm_prompt_template"]
@pytest.mark.parametrize(
"chain_path",
[
os.path.abspath("tests/langchain/sample_code/chain.py"),
"tests/langchain/../langchain/sample_code/chain.py",
],
)
def test_save_load_chain_as_code_with_model_paths(chain_path):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
artifact_path = "model_path"
with (
mlflow.start_run(),
mock.patch("mlflow.langchain.model._add_code_from_conf_to_system_path") as add_mock,
):
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path,
input_example=input_example,
code_paths=[__file__],
model_config={
"response": "modified response",
"embedding_size": 5,
"llm_prompt_template": "answer the question",
},
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
answer = "modified response"
_compare_logged_code_paths(__file__, model_info.model_uri, mlflow.langchain.FLAVOR_NAME)
assert loaded_model.invoke(input_example) == answer
add_mock.assert_called()
@pytest.mark.parametrize("chain_path", [os.path.abspath("tests/langchain1/sample_code/chain.py")])
def test_save_load_chain_errors(chain_path):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
with mlflow.start_run():
with pytest.raises(
MlflowException,
match=f"The provided model path '{chain_path}' does not exist. "
"Ensure the file path is valid and try again.",
):
mlflow.langchain.log_model(
chain_path,
name="model_path",
input_example=input_example,
model_config="tests/langchain/state_of_the_union.txt",
)
@pytest.mark.parametrize(
"chain_path",
[
os.path.abspath("tests/langchain/sample_code/no_config/chain.py"),
"tests/langchain/../langchain/sample_code/no_config/chain.py",
],
)
def test_save_load_chain_as_code_optional_code_path(chain_path):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
artifact_path = "new_model_path"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path,
input_example=input_example,
)
assert mlflow.models.model_config.__mlflow_model_config__ is None
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert mlflow.models.model_config.__mlflow_model_config__ is None
answer = "Databricks"
assert loaded_model.invoke(input_example) == answer
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert (
pyfunc_loaded_model
.predict(input_example)[0]
.get("choices")[0]
.get("message")
.get("content")
== answer
)
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
# avoid minor diff of created time in the response
prediction_result = json.loads(response.content.decode("utf-8"))
prediction_result[0]["created"] = 123
expected_prediction = try_transform_response_to_chat_format(answer)
expected_prediction["created"] = 123
assert prediction_result == [expected_prediction]
pyfunc_model_path = _download_artifact_from_uri(model_info.model_uri)
reloaded_model = Model.load(os.path.join(pyfunc_model_path, "MLmodel"))
assert reloaded_model.resources["databricks"] == {
"serving_endpoint": [{"name": "fake-endpoint"}]
}
assert reloaded_model.metadata is None
@pytest.fixture
def fake_chat_stream_model():
class FakeChatStreamModel(SimpleChatModel):
"""Fake Chat Stream Model wrapper for testing purposes."""
endpoint_name: str = "fake-stream-endpoint"
def _call(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> str:
return "Databricks"
def _stream(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> Iterator[ChatGenerationChunk]:
for chunk_content, finish_reason in [
("Da", None),
("tab", None),
("ricks", "stop"),
]:
chunk = ChatGenerationChunk(
message=AIMessageChunk(content=chunk_content),
generation_info={"finish_reason": finish_reason},
)
if run_manager:
run_manager.on_llm_new_token(chunk.text, chunk=chunk)
yield chunk
@property
def _llm_type(self) -> str:
return "fake chat model"
return FakeChatStreamModel(endpoint_name="fake-stream-endpoint")
@skip_if_v1
@pytest.mark.parametrize("provide_signature", [True, False])
def test_simple_chat_model_stream_inference(fake_chat_stream_model, provide_signature):
# TODO: Migrate to models-from-code
input_example = {
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "assistant", "content": "What would you like to ask?"},
{"role": "user", "content": "Who owns MLflow?"},
]
}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
fake_chat_stream_model,
name="model",
)
if provide_signature:
signature = infer_signature(model_input=input_example)
with mlflow.start_run():
model_with_siginature_info = mlflow.langchain.log_model(
fake_chat_stream_model, name="model", signature=signature
)
else:
with mlflow.start_run():
model_with_siginature_info = mlflow.langchain.log_model(
fake_chat_stream_model, name="model", input_example=input_example
)
for model_uri in [model_info.model_uri, model_with_siginature_info.model_uri]:
loaded_model = mlflow.pyfunc.load_model(model_uri)
chunk_iter = loaded_model.predict_stream(input_example)
finish_reason = "stop"
with mock.patch("time.time", return_value=1677858242):
chunks = list(chunk_iter)
for chunk in chunks:
assert "id" in chunk, "chunk id is lost."
chunk["id"] = None
assert chunks == [
{
"id": None,
"object": "chat.completion.chunk",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"finish_reason": None,
"delta": {"role": "assistant", "content": "Da"},
}
],
},
{
"id": None,
"object": "chat.completion.chunk",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"finish_reason": None,
"delta": {"role": "assistant", "content": "tab"},
}
],
},
{
"id": None,
"object": "chat.completion.chunk",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"finish_reason": finish_reason,
"delta": {"role": "assistant", "content": "ricks"},
}
],
},
]
@skip_if_v1
def test_simple_chat_model_stream_with_callbacks(fake_chat_stream_model):
# TODO: Migrate to models-from-code
class TestCallbackHandler(BaseCallbackHandler):
def __init__(self):
super().__init__()
self.num_llm_start_calls = 0
def on_llm_start(
self,
serialized: dict[str, Any],
prompts: list[str],
**kwargs: Any,
) -> Any:
self.num_llm_start_calls += 1
prompt = ChatPromptTemplate.from_template("What's your favorite {industry} company?")
chain = prompt | fake_chat_stream_model | StrOutputParser()
# Test the basic functionality of the chain
assert chain.invoke({"industry": "tech"}) == "Databricks"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example={"industry": "tech"}
)
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
callback_handler1 = TestCallbackHandler()
callback_handler2 = TestCallbackHandler()
# Ensure handlers have not been called yet
assert callback_handler1.num_llm_start_calls == 0
assert callback_handler2.num_llm_start_calls == 0
stream = pyfunc_loaded_model._model_impl._predict_stream_with_callbacks(
{"industry": "tech"},
callback_handlers=[callback_handler1, callback_handler2],
)
assert list(stream) == ["Da", "tab", "ricks"]
# Test that the callback handlers were called
assert callback_handler1.num_llm_start_calls == 1
assert callback_handler2.num_llm_start_calls == 1
@skip_if_v1
def test_langchain_model_save_load_with_listeners(fake_chat_model):
# Migrate this to models-from-code
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
MessagesPlaceholder(variable_name="history"),
("human", "{question}"),
])
def retrieve_history(input):
return {"history": [], "question": input["question"], "name": input["name"]}
chain = (
{"question": itemgetter("question"), "name": itemgetter("name")}
| (RunnableLambda(retrieve_history) | prompt | fake_chat_model).with_listeners()
| StrOutputParser()
| RunnablePassthrough()
)
input_example = {"question": "Who owns MLflow?", "name": ""}
assert chain.invoke(input_example) == "Databricks"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain, name="model_path", input_example=input_example
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke(input_example) == "Databricks"
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict(input_example) == ["Databricks"]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": ["Databricks"]
}
@pytest.mark.parametrize("env_var", ["MLFLOW_ENABLE_TRACE_IN_SERVING", "ENABLE_MLFLOW_TRACING"])
def test_langchain_model_not_inject_callback_when_disabled(monkeypatch, model_path, env_var):
# Emulate the model serving environment
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
# Disable tracing
monkeypatch.setenv(env_var, "false")
mlflow.langchain.save_model(SIMPLE_MODEL_CODE_PATH, model_path)
loaded_model = mlflow.pyfunc.load_model(model_path)
loaded_model.predict({"product": "shoe"})
# Trace should be logged to the inference table
from mlflow.tracing.export.inference_table import _TRACE_BUFFER
assert _TRACE_BUFFER == {}
@pytest.mark.parametrize(
"chain_path",
[
os.path.abspath("tests/langchain/sample_code/no_config/chain.py"),
"tests/langchain/../langchain/sample_code/no_config/chain.py",
],
)
def test_save_model_as_code_correct_streamable(chain_path):
input_example = {"messages": [{"role": "user", "content": "Who owns MLflow?"}]}
answer = "Databricks"
artifact_path = "model_path"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path,
input_example=input_example,
)
assert model_info.flavors["langchain"]["streamable"] is True
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
with mock.patch("time.time", return_value=1677858242):
assert pyfunc_loaded_model._model_impl._predict_with_callbacks(input_example) == {
"id": None,
"object": "chat.completion",
"created": 1677858242,
"model": "",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Databricks",
},
"finish_reason": None,
}
],
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
}
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
# avoid minor diff of created time in the response
prediction_result = json.loads(response.content.decode("utf-8"))
prediction_result[0]["created"] = 123
expected_prediction = try_transform_response_to_chat_format(answer)
expected_prediction["created"] = 123
assert prediction_result == [expected_prediction]
pyfunc_model_path = _download_artifact_from_uri(model_info.model_uri)
reloaded_model = Model.load(os.path.join(pyfunc_model_path, "MLmodel"))
assert reloaded_model.resources["databricks"] == {
"serving_endpoint": [{"name": "fake-endpoint"}]
}
@skip_if_v1
def test_save_load_langchain_binding(fake_chat_model):
runnable_binding = RunnableBinding(bound=fake_chat_model, kwargs={"stop": ["-"]})
model = runnable_binding | StrOutputParser()
assert model.invoke("Say something") == "Databricks"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
model, name="model_path", input_example="Say something"
)
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.first.kwargs == {"stop": ["-"]}
assert loaded_model.invoke("hello") == "Databricks"
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict("hello") == ["Databricks"]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": ["Databricks"]
}
@skip_if_v1
def test_save_load_langchain_binding_llm_with_tool():
from langchain_core.tools import tool
# We need to use ChatOpenAI from langchain_openai as community one does not support bind_tools
from langchain_openai import ChatOpenAI
@tool
def add(a: int, b: int) -> int:
"""Adds a and b.
Args:
a: first int
b: second int
"""
return a + b
runnable_binding = ChatOpenAI(temperature=0.9).bind_tools([add])
model = runnable_binding | StrOutputParser()
expected_output = '[{"role": "user", "content": "hello"}]'
assert model.invoke("hello") == expected_output
with mlflow.start_run():
model_info = mlflow.langchain.log_model(model, name="model_path", input_example="hello")
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.invoke("hello") == expected_output
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict("hello") == [expected_output]
@skip_if_v1
def test_langchain_bindings_save_load_with_config_and_types(fake_chat_model):
class CustomCallbackHandler(BaseCallbackHandler):
def __init__(self):
self.count = 0
def on_chain_start(
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
) -> None:
self.count += 1
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
self.count += 1
model = fake_chat_model | StrOutputParser()
callback = CustomCallbackHandler()
model = model.with_config(run_name="test_run", callbacks=[callback]).with_types(
input_type=str, output_type=str
)
assert model.invoke("Say something") == "Databricks"
assert callback.count == 4
with mlflow.start_run():
model_info = mlflow.langchain.log_model(model, name="model_path", input_example="hello")
loaded_model = mlflow.langchain.load_model(model_info.model_uri)
assert loaded_model.config["run_name"] == "test_run"
assert loaded_model.custom_input_type == str
assert loaded_model.custom_output_type == str
callback = loaded_model.config["callbacks"][0]
assert loaded_model.invoke("hello") == "Databricks"
assert callback.count > 8 # accumulated count (inside model logging we also call the callbacks)
pyfunc_loaded_model = mlflow.pyfunc.load_model(model_info.model_uri)
assert pyfunc_loaded_model.predict("hello") == ["Databricks"]
inference_payload = load_serving_example(model_info.model_uri)
response = pyfunc_serve_and_score_model(
model_info.model_uri,
data=inference_payload,
content_type=pyfunc_scoring_server.CONTENT_TYPE_JSON,
extra_args=["--env-manager", "local"],
)
assert PredictionsResponse.from_json(response.content.decode("utf-8")) == {
"predictions": ["Databricks"]
}
@pytest.mark.parametrize(
"chain_path",
[
os.path.abspath("tests/langchain/sample_code/chain.py"),
"tests/langchain/../langchain/sample_code/chain.py",
],
)
@pytest.mark.parametrize(
"model_config",
[
os.path.abspath("tests/langchain/sample_code/config.yml"),
"tests/langchain/../langchain/sample_code/config.yml",
],
)
def test_load_chain_with_model_config_overrides_saved_config(chain_path, model_config):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
artifact_path = "model_path"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path,
input_example=input_example,
model_config=model_config,
)
with mock.patch("mlflow.langchain.model._load_model_code_path") as load_model_code_path_mock:
mlflow.pyfunc.load_model(model_info.model_uri, model_config={"embedding_size": 2})
args, kwargs = load_model_code_path_mock.call_args
assert args[1] == {
"embedding_size": 2,
"llm_prompt_template": "Answer the following question based on the "
"context: {context}\nQuestion: {question}",
"not_used_array": [
1,
2,
3,
],
"response": "Databricks",
}
@skip_if_v1
@pytest.mark.parametrize("streamable", [True, False, None])
def test_langchain_model_streamable_param_in_log_model(streamable, fake_chat_model):
# TODO: Migrate to models-from-code
prompt = ChatPromptTemplate.from_template("What's your favorite {industry} company?")
chain = prompt | fake_chat_model | StrOutputParser()
runnable = RunnableParallel({"llm": lambda _: "completion"})
for model in [chain, runnable]:
with mock.patch("mlflow.langchain.model._save_model"), mlflow.start_run():
model_info = mlflow.langchain.log_model(
model,
name="model",
streamable=streamable,
pip_requirements=[],
)
expected = (streamable is None) or streamable
assert model_info.flavors["langchain"]["streamable"] is expected
@pytest.fixture
def model_type(request):
return lc_runnables_types()[request.param]
@skip_if_v1
@pytest.mark.parametrize("streamable", [True, False, None])
@pytest.mark.parametrize("model_type", range(len(lc_runnables_types())), indirect=True)
def test_langchain_model_streamable_param_in_log_model_for_lc_runnable_types(
streamable, model_type
):
with mock.patch("mlflow.langchain.model._save_model"), mlflow.start_run():
model = mock.MagicMock(spec=model_type)
assert hasattr(model, "stream") is True
model_info = mlflow.langchain.log_model(
model,
name="model",
streamable=streamable,
pip_requirements=[],
)
expected = (streamable is None) or streamable
assert model_info.flavors["langchain"]["streamable"] is expected
del model.stream
assert hasattr(model, "stream") is False
model_info = mlflow.langchain.log_model(
model,
name="model",
streamable=streamable,
pip_requirements=[],
)
assert model_info.flavors["langchain"]["streamable"] is bool(streamable)
@skip_if_v1
def test_agent_executor_model_with_messages_input():
question = {"messages": [{"role": "user", "content": "Who owns MLflow?"}]}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
os.path.abspath("tests/langchain/agent_executor/chain.py"),
name="model_path",
input_example=question,
model_config=os.path.abspath("tests/langchain/agent_executor/config.yml"),
)
native_model = mlflow.langchain.load_model(model_info.model_uri)
assert native_model.invoke(question)["output"] == "Databricks"
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
# TODO: in the future we should fix this and output shouldn't be wrapped
# The result is wrapped in a list because during signature enforcement we convert
# input data to pandas dataframe, then inside _convert_llm_input_data
# we convert pandas dataframe back to records, and a single row will be
# wrapped inside a list.
assert pyfunc_model.predict(question) == ["Databricks"]
# Test stream output
response = pyfunc_model.predict_stream(question)
assert inspect.isgenerator(response)
expected_response = [
{
"output": "Databricks",
"messages": [
{
"additional_kwargs": {},
"content": "Databricks",
"example": False,
"id": None,
"invalid_tool_calls": [],
"name": None,
"response_metadata": {},
"tool_calls": [],
"type": "ai",
"usage_metadata": None,
}
],
}
]
assert list(response) == expected_response
def test_invoking_model_with_params():
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
os.path.abspath("tests/langchain/sample_code/model_with_config.py"),
name="model",
)
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
data = {"x": 0}
pyfunc_model.predict(data)
params = {"config": {"temperature": 3.0}}
with mock.patch("mlflow.pyfunc._validate_prediction_input", return_value=(data, params)):
# This proves the temperature is passed to the model
with pytest.raises(MlflowException, match=r"Input should be less than or equal to 2"):
pyfunc_model.predict(data=data, params=params)
def test_custom_resources(tmp_path):
input_example = {
"messages": [
{
"role": "user",
"content": "What is a good name for a company that makes MLflow?",
}
]
}
expected_resources = {
"api_version": "1",
"databricks": {
"serving_endpoint": [
{"name": "databricks-mixtral-8x7b-instruct"},
{"name": "databricks-bge-large-en"},
{"name": "azure-eastus-model-serving-2_vs_endpoint"},
],
"vector_search_index": [{"name": "rag.studio_bugbash.databricks_docs_index"}],
"sql_warehouse": [{"name": "testid"}],
"function": [
{"name": "rag.studio.test_function_a"},
{"name": "rag.studio.test_function_b"},
],
},
}
artifact_path = "model_path"
chain_path = "tests/langchain/sample_code/chain.py"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path,
input_example=input_example,
model_config="tests/langchain/sample_code/config.yml",
resources=[
DatabricksServingEndpoint(endpoint_name="databricks-mixtral-8x7b-instruct"),
DatabricksServingEndpoint(endpoint_name="databricks-bge-large-en"),
DatabricksServingEndpoint(endpoint_name="azure-eastus-model-serving-2_vs_endpoint"),
DatabricksVectorSearchIndex(index_name="rag.studio_bugbash.databricks_docs_index"),
DatabricksSQLWarehouse(warehouse_id="testid"),
DatabricksFunction(function_name="rag.studio.test_function_a"),
DatabricksFunction(function_name="rag.studio.test_function_b"),
],
)
model_path = _download_artifact_from_uri(model_info.model_uri)
reloaded_model = Model.load(os.path.join(model_path, "MLmodel"))
assert reloaded_model.resources == expected_resources
yaml_file = tmp_path.joinpath("resources.yaml")
with open(yaml_file, "w") as f:
f.write(
"""
api_version: "1"
databricks:
vector_search_index:
- name: rag.studio_bugbash.databricks_docs_index
serving_endpoint:
- name: databricks-mixtral-8x7b-instruct
- name: databricks-bge-large-en
- name: azure-eastus-model-serving-2_vs_endpoint
sql_warehouse:
- name: testid
function:
- name: rag.studio.test_function_a
- name: rag.studio.test_function_b
"""
)
artifact_path_2 = "model_path_2"
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
chain_path,
name=artifact_path_2,
input_example=input_example,
model_config="tests/langchain/sample_code/config.yml",
resources=yaml_file,
)
model_path = _download_artifact_from_uri(model_info.model_uri)
reloaded_model = Model.load(os.path.join(model_path, "MLmodel"))
assert reloaded_model.resources == expected_resources
def test_pyfunc_converts_chat_request_for_non_chat_model():
input_example = {"messages": [{"role": "user", "content": "Hello"}]}
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
lc_model=SIMPLE_MODEL_CODE_PATH,
input_example=input_example,
)
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
result = pyfunc_model.predict(input_example)
# output are converted to chatResponse format
assert isinstance(result[0]["choices"][0]["message"]["content"], str)
response = pyfunc_model.predict_stream(input_example)
assert inspect.isgenerator(response)
assert isinstance(list(response)[0]["choices"][0]["delta"]["content"], str)
@skip_if_v1
def test_pyfunc_should_not_convert_chat_request_if_env_var_is_set_to_false(monkeypatch):
monkeypatch.setenv(MLFLOW_CONVERT_MESSAGES_DICT_FOR_LANGCHAIN.name, "false")
# This model is an example when the model expects a chat request
# format input, but the input should not be converted to List[BaseMessage]
model = RunnablePassthrough.assign(problem=lambda x: x["messages"][-1]["content"]) | itemgetter(
"problem"
)
input_example = {"messages": [{"role": "user", "content": "Databricks"}]}
assert model.invoke(input_example) == "Databricks"
# pyfunc model can accepts chat request format even the chain
# itself does not accept it, but we need to use the correct
# input example to infer model signature
with mlflow.start_run():
model_info = mlflow.langchain.log_model(model, input_example=input_example)
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
result = pyfunc_model.predict(input_example)
assert result == ["Databricks"]
# Test stream output
response = pyfunc_model.predict_stream(input_example)
assert inspect.isgenerator(response)
assert list(response) == ["Databricks"], list(response)
def test_log_langchain_model_with_prompt():
mlflow.register_prompt(
name="qa_prompt",
template="What is a good name for a company that makes {{product}}?",
commit_message="Prompt for generating company names",
)
mlflow.set_prompt_alias("qa_prompt", alias="production", version=1)
mlflow.register_prompt(name="another_prompt", template="Hi")
# If the model code involves `mlflow.load_prompt()` call, the prompt version
# should be automatically logged to the Run
with mlflow.start_run():
model_info = mlflow.langchain.log_model(
os.path.abspath("tests/langchain/sample_code/chain_with_mlflow_prompt.py"),
name="model",
# Manually associate another prompt
prompts=["prompts:/another_prompt/1"],
)
# Check that prompts were linked to the run via the linkedPrompts tag
from mlflow.tracing.constant import TraceTagKey
run = mlflow.MlflowClient().get_run(model_info.run_id)
linked_prompts_tag = run.data.tags.get(TraceTagKey.LINKED_PROMPTS)
assert linked_prompts_tag is not None
linked_prompts = json.loads(linked_prompts_tag)
assert len(linked_prompts) == 2
assert {p["name"] for p in linked_prompts} == {"qa_prompt", "another_prompt"}
prompt = mlflow.load_prompt("qa_prompt", 1)
assert prompt.aliases == ["production"]
prompt = mlflow.load_prompt("another_prompt", 1)
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
response = pyfunc_model.predict({"product": "shoe"})
# Fake OpenAI server echo the input
assert (
response
== '[{"role": "user", "content": "What is a good name for a company that makes shoe?"}]'
)
def test_predict_with_callbacks_with_tracing(monkeypatch):
# Simulate the model serving environment
monkeypatch.setenv("IS_IN_DB_MODEL_SERVING_ENV", "true")
monkeypatch.setenv("ENABLE_MLFLOW_TRACING", "true")
mlflow.tracing.reset()
model_info = mlflow.langchain.log_model(
os.path.abspath("tests/langchain/sample_code/workflow.py"),
name="model_path",
input_example={"messages": [{"role": "user", "content": "What is MLflow?"}]},
)
# serving environment only reads from this environment variable
monkeypatch.setenv("MLFLOW_EXPERIMENT_ID", mlflow.last_logged_model().experiment_id)
pyfunc_model = mlflow.pyfunc.load_model(model_info.model_uri)
request_id = "mock_request_id"
tracer = MlflowLangchainTracer(prediction_context=Context(request_id))
input_example = {"messages": [{"role": "user", "content": TEST_CONTENT}]}
with mock.patch("mlflow.tracing.client.TracingClient.start_trace") as mock_start_trace:
pyfunc_model._model_impl._predict_with_callbacks(
data=input_example, callback_handlers=[tracer]
)
mlflow.flush_trace_async_logging()
mock_start_trace.assert_called_once()
trace_info = mock_start_trace.call_args[0][0]
assert trace_info.client_request_id == request_id
assert trace_info.request_metadata[TraceMetadataKey.MODEL_ID] == model_info.model_id
@pytest.mark.skipif(not IS_LANGCHAIN_v1, reason="The test is only for langchain>=1 versions")
def test_langchain_v1_save_model_as_pickle_error():
model = create_openai_runnable()
with mlflow.start_run():
with pytest.raises(
MlflowException,
match="LangChain v1 onward only supports models-from-code",
):
mlflow.langchain.log_model(
model, name="langchain_model", input_example={"product": "MLflow"}
)