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

707 lines
21 KiB
Python

import importlib
import json
from unittest import mock
import numpy as np
import openai
import pandas as pd
import pytest
import yaml
from pyspark.sql import SparkSession
import mlflow
import mlflow.pyfunc.scoring_server as pyfunc_scoring_server
from mlflow.models.signature import ModelSignature
from mlflow.models.utils import load_serving_example
from mlflow.types.schema import ColSpec, ParamSchema, ParamSpec, Schema, TensorSpec
from tests.helper_functions import pyfunc_serve_and_score_model
from tests.openai.conftest import is_v1
@pytest.fixture(scope="module")
def spark():
with SparkSession.builder.master("local[*]").getOrCreate() as s:
yield s
def chat_completions():
return openai.chat.completions if is_v1 else openai.ChatCompletion
def completions():
return openai.completions if is_v1 else openai.Completion
def embeddings():
return openai.embeddings if is_v1 else openai.Embedding
@pytest.fixture(autouse=True)
def set_envs(monkeypatch, mock_openai):
monkeypatch.setenv("MLFLOW_TESTING", "true")
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPENAI_API_BASE", mock_openai)
if is_v1:
openai.base_url = mock_openai
else:
importlib.reload(openai)
def test_log_model():
with mlflow.start_run():
model_info = mlflow.openai.log_model(
"gpt-4o-mini",
"chat.completions",
name="model",
temperature=0.9,
messages=[{"role": "system", "content": "You are an MLflow expert."}],
)
loaded_model = mlflow.openai.load_model(model_info.model_uri)
assert loaded_model["model"] == "gpt-4o-mini"
assert loaded_model["task"] == "chat.completions"
assert loaded_model["temperature"] == 0.9
assert loaded_model["messages"] == [{"role": "system", "content": "You are an MLflow expert."}]
def test_chat_single_variable(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[{"role": "user", "content": "{x}"}],
)
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"x": [
"a",
"b",
]
})
expected_output = [
[{"content": "a", "role": "user"}],
[{"content": "b", "role": "user"}],
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
{"x": "a"},
{"x": "b"},
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
"a",
"b",
]
assert list(map(json.loads, model.predict(data))) == expected_output
def test_completion_single_variable(tmp_path):
mlflow.openai.save_model(
model="text-davinci-003",
task=completions(),
path=tmp_path,
prompt="Say {text}",
)
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"x": [
"this is a test",
"this is another test",
]
})
expected_output = ["Say this is a test", "Say this is another test"]
assert model.predict(data) == expected_output
data = [
{"x": "this is a test"},
{"x": "this is another test"},
]
assert model.predict(data) == expected_output
data = [
"this is a test",
"this is another test",
]
assert model.predict(data) == expected_output
def test_chat_multiple_variables(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[{"role": "user", "content": "{x} {y}"}],
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"name": "x", "type": "string", "required": True},
{"name": "y", "type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"x": [
"a",
"b",
],
"y": [
"c",
"d",
],
})
expected_output = [
[{"content": "a c", "role": "user"}],
[{"content": "b d", "role": "user"}],
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
{"x": "a", "y": "c"},
{"x": "b", "y": "d"},
]
assert list(map(json.loads, model.predict(data))) == expected_output
def test_chat_role_content(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[{"role": "{role}", "content": "{content}"}],
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"name": "content", "type": "string", "required": True},
{"name": "role", "type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"role": [
"system",
"user",
],
"content": [
"c",
"d",
],
})
expected_output = [
[{"content": "c", "role": "system"}],
[{"content": "d", "role": "user"}],
]
assert list(map(json.loads, model.predict(data))) == expected_output
def test_completion_multiple_variables(tmp_path):
mlflow.openai.save_model(
model="text-davinci-003",
task=completions(),
path=tmp_path,
prompt="Say {x} and {y}",
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"name": "x", "type": "string", "required": True},
{"name": "y", "type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"x": [
"a",
"b",
],
"y": [
"c",
"d",
],
})
expected_output = ["Say a and c", "Say b and d"]
assert model.predict(data) == expected_output
data = [
{"x": "a", "y": "c"},
{"x": "b", "y": "d"},
]
assert model.predict(data) == expected_output
def test_chat_multiple_messages(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[
{"role": "user", "content": "{x}"},
{"role": "user", "content": "{y}"},
],
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"name": "x", "type": "string", "required": True},
{"name": "y", "type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"x": [
"a",
"b",
],
"y": [
"c",
"d",
],
})
expected_output = [
[{"content": "a", "role": "user"}, {"content": "c", "role": "user"}],
[{"content": "b", "role": "user"}, {"content": "d", "role": "user"}],
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
{"x": "a", "y": "c"},
{"x": "b", "y": "d"},
]
assert list(map(json.loads, model.predict(data))) == expected_output
def test_chat_no_variables(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[{"role": "user", "content": "a"}],
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"content": ["b", "c"],
})
expected_output = [
[{"content": "a", "role": "user"}, {"content": "b", "role": "user"}],
[{"content": "a", "role": "user"}, {"content": "c", "role": "user"}],
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
{"content": "b"},
{"content": "c"},
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
"b",
"c",
]
assert list(map(json.loads, model.predict(data))) == expected_output
def test_completion_no_variable(tmp_path):
mlflow.openai.save_model(
model="text-davinci-003",
task=completions(),
path=tmp_path,
)
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"x": [
"this is a test",
"this is another test",
]
})
expected_output = ["this is a test", "this is another test"]
assert model.predict(data) == expected_output
data = [
{"x": "this is a test"},
{"x": "this is another test"},
]
assert model.predict(data) == expected_output
data = [
"this is a test",
"this is another test",
]
assert model.predict(data) == expected_output
def test_chat_no_messages(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"content": ["b", "c"],
})
expected_output = [
[{"content": "b", "role": "user"}],
[{"content": "c", "role": "user"}],
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
{"content": "b"},
{"content": "c"},
]
assert list(map(json.loads, model.predict(data))) == expected_output
data = [
"b",
"c",
]
assert list(map(json.loads, model.predict(data))) == expected_output
@pytest.mark.parametrize(
"messages",
[
["a", "b"],
[{"k": "v"}],
],
)
def test_invalid_messages(tmp_path, messages):
with pytest.raises(
mlflow.MlflowException,
match="it must be a list of dictionaries with keys 'role' and 'content'",
):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=messages,
)
def test_task_argument_accepts_class(tmp_path):
mlflow.openai.save_model(model="gpt-4o-mini", task=chat_completions(), path=tmp_path)
loaded_model = mlflow.openai.load_model(tmp_path)
assert loaded_model["task"] == "chat.completions"
@pytest.mark.skipif(is_v1, reason="Requires OpenAI SDK v0")
def test_model_argument_accepts_retrieved_model(tmp_path):
model = openai.Model.retrieve("gpt-4o-mini")
mlflow.openai.save_model(model=model, task=chat_completions(), path=tmp_path)
loaded_model = mlflow.openai.load_model(tmp_path)
assert loaded_model["model"] == "gpt-4o-mini"
def test_save_model_with_secret_scope(tmp_path, monkeypatch):
scope = "test"
monkeypatch.setenv("MLFLOW_OPENAI_SECRET_SCOPE", scope)
with (
mock.patch("mlflow.openai.model.is_in_databricks_runtime", return_value=True),
mock.patch("mlflow.openai.model.check_databricks_secret_scope_access"),
):
with pytest.warns(FutureWarning, match="MLFLOW_OPENAI_SECRET_SCOPE.+deprecated"):
mlflow.openai.save_model(model="gpt-4o-mini", task="chat.completions", path=tmp_path)
with tmp_path.joinpath("openai.yaml").open() as f:
creds = yaml.safe_load(f)
assert creds == {
"OPENAI_API_TYPE": f"{scope}:openai_api_type",
"OPENAI_API_KEY": f"{scope}:openai_api_key",
"OPENAI_API_KEY_PATH": f"{scope}:openai_api_key_path",
"OPENAI_API_BASE": f"{scope}:openai_api_base",
"OPENAI_BASE_URL": f"{scope}:openai_base_url",
"OPENAI_ORGANIZATION": f"{scope}:openai_organization",
"OPENAI_API_VERSION": f"{scope}:openai_api_version",
"OPENAI_DEPLOYMENT_NAME": f"{scope}:openai_deployment_name",
"OPENAI_ENGINE": f"{scope}:openai_engine",
}
def test_spark_udf_chat(tmp_path, spark):
mlflow.openai.save_model(
model="gpt-4o-mini",
task="chat.completions",
path=tmp_path,
messages=[
{"role": "user", "content": "{x} {y}"},
],
)
udf = mlflow.pyfunc.spark_udf(spark, tmp_path, result_type="string")
df = spark.createDataFrame(
[
("a", "b"),
("c", "d"),
],
["x", "y"],
)
df = df.withColumn("z", udf())
pdf = df.toPandas()
assert list(map(json.loads, pdf["z"])) == [
[{"content": "a b", "role": "user"}],
[{"content": "c d", "role": "user"}],
]
class ChatCompletionModel(mlflow.pyfunc.PythonModel):
def predict(self, context, model_input, params=None):
completion = chat_completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is MLflow?"}],
)
return completion.choices[0].message.content
def test_embeddings(tmp_path):
mlflow.openai.save_model(
model="text-embedding-ada-002",
task=embeddings(),
path=tmp_path,
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [{"type": "string", "required": True}]
assert model.signature.outputs.to_dict() == [
{"type": "tensor", "tensor-spec": {"dtype": "float64", "shape": (-1,)}}
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({"text": ["a", "b"]})
preds = model.predict(data)
assert list(map(len, preds)) == [1536, 1536]
data = pd.DataFrame({"text": ["a"] * 100})
preds = model.predict(data)
assert list(map(len, preds)) == [1536] * 100
def test_embeddings_batch_size_azure(tmp_path, monkeypatch):
monkeypatch.setenv("OPENAI_API_TYPE", "azure")
monkeypatch.setenv("OPENAI_ENGINE", "test_engine")
mlflow.openai.save_model(
model="text-embedding-ada-002",
task=embeddings(),
path=tmp_path,
)
model = mlflow.pyfunc.load_model(tmp_path)
assert model._model_impl.api_config.batch_size == 16
def test_embeddings_pyfunc_server_and_score():
df = pd.DataFrame({"text": ["a", "b"]})
with mlflow.start_run():
model_info = mlflow.openai.log_model(
"text-embedding-ada-002",
embeddings(),
name="model",
input_example=df,
)
inference_payload = load_serving_example(model_info.model_uri)
resp = 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"],
)
expected = mlflow.pyfunc.load_model(model_info.model_uri).predict(df)
actual = pd.DataFrame(data=json.loads(resp.content.decode("utf-8")))
pd.testing.assert_frame_equal(actual, pd.DataFrame({"predictions": expected}))
def test_spark_udf_embeddings(tmp_path, spark):
mlflow.openai.save_model(
model="text-embedding-ada-002",
task=embeddings(),
path=tmp_path,
)
udf = mlflow.pyfunc.spark_udf(spark, tmp_path, result_type="array<double>")
df = spark.createDataFrame(
[
("a",),
("b",),
],
["x"],
)
df = df.withColumn("z", udf("x")).toPandas()
assert list(map(len, df["z"])) == [1536, 1536]
def test_inference_params(tmp_path):
mlflow.openai.save_model(
model="text-embedding-ada-002",
task=embeddings(),
path=tmp_path,
signature=ModelSignature(
inputs=Schema([ColSpec(type="string", name=None)]),
outputs=Schema([TensorSpec(type=np.dtype("float64"), shape=(-1,))]),
params=ParamSchema([ParamSpec(name="batch_size", dtype="long", default=16)]),
),
)
model_info = mlflow.models.Model.load(tmp_path)
assert (
len([p for p in model_info.signature.params if p.name == "batch_size" and p.default == 16])
== 1
)
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({"text": ["a", "b"]})
preds = model.predict(data, params={"batch_size": 5})
assert list(map(len, preds)) == [1536, 1536]
def test_inference_params_overlap(tmp_path):
with pytest.raises(mlflow.MlflowException, match=r"any of \['prefix'\] as parameters"):
mlflow.openai.save_model(
model="text-davinci-003",
task=completions(),
path=tmp_path,
prefix="Classify the following text's sentiment:",
signature=ModelSignature(
inputs=Schema([ColSpec(type="string", name=None)]),
outputs=Schema([ColSpec(type="string", name=None)]),
params=ParamSchema([ParamSpec(name="prefix", default=None, dtype="string")]),
),
)
def test_multimodal_messages(tmp_path):
# Test multimodal content with variable placeholders
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "{system_prompt}"},
{
"type": "image_url",
"image_url": {
"url": "data:image/jpeg;base64,{image_base64}",
"detail": "low",
},
},
],
}
],
)
model = mlflow.models.Model.load(tmp_path)
assert model.signature.inputs.to_dict() == [
{"name": "image_base64", "type": "string", "required": True},
{"name": "system_prompt", "type": "string", "required": True},
]
assert model.signature.outputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({
"system_prompt": ["Analyze this image"],
"image_base64": [
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
],
})
expected_output = [
[
{
"content": [
{"type": "text", "text": "Analyze this image"},
{
"type": "image_url",
"image_url": {
"url": (
"data:image/jpeg;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="
),
"detail": "low",
},
},
],
"role": "user",
}
]
]
assert list(map(json.loads, model.predict(data))) == expected_output
def test_multimodal_messages_no_variables(tmp_path):
mlflow.openai.save_model(
model="gpt-4o-mini",
task=chat_completions(),
path=tmp_path,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abc123", "detail": "low"},
},
],
}
],
)
model = mlflow.models.Model.load(tmp_path)
# Should add default content variable since no variables found
assert model.signature.inputs.to_dict() == [
{"type": "string", "required": True},
]
model = mlflow.pyfunc.load_model(tmp_path)
data = pd.DataFrame({"content": ["Additional context"]})
expected_output = [
[
{
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abc123", "detail": "low"},
},
],
"role": "user",
},
{"content": "Additional context", "role": "user"},
]
]
assert list(map(json.loads, model.predict(data))) == expected_output