项目文件夹

文件
2026-07-13 13:22:34 +08:00

320 行
11 KiB
Python

from unittest import mock
import pydantic
import pytest
from aiohttp import ClientTimeout
from fastapi.encoders import jsonable_encoder
from mlflow.environment_variables import MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS
from mlflow.gateway.config import EndpointConfig, MlflowModelServingConfig
from mlflow.gateway.constants import MLFLOW_SERVING_RESPONSE_KEY
from mlflow.gateway.exceptions import AIGatewayException
from mlflow.gateway.providers.mlflow import MlflowModelServingProvider
from mlflow.gateway.schemas import chat, completions, embeddings
from tests.gateway.tools import MockAsyncResponse, mock_http_client
def completions_config():
return {
"name": "completions",
"endpoint_type": "llm/v1/completions",
"model": {
"provider": "mlflow-model-serving",
"name": "text2text",
"config": {
"model_server_url": "http://127.0.0.1:5000",
},
},
}
@pytest.mark.asyncio
async def test_completions():
resp = {
"predictions": ["This is a test!"],
"headers": {"Content-Type": "application/json"},
}
config = completions_config()
mock_client = mock_http_client(MockAsyncResponse(resp))
with (
mock.patch("time.time", return_value=1677858242),
mock.patch("aiohttp.ClientSession", return_value=mock_client) as mock_build_client,
):
provider = MlflowModelServingProvider(EndpointConfig(**config))
payload = {
"prompt": "Is this a test?",
"temperature": 0.0,
}
response = await provider.completions(completions.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"id": None,
"object": "text_completion",
"created": 1677858242,
"model": "text2text",
"choices": [
{
"text": "This is a test!",
"index": 0,
"finish_reason": None,
}
],
"usage": {"prompt_tokens": None, "completion_tokens": None, "total_tokens": None},
}
mock_build_client.assert_called_once()
mock_client.post.assert_called_once_with(
"http://127.0.0.1:5000/invocations",
json={
"inputs": ["Is this a test?"],
"params": {
"temperature": 0.0,
"n": 1,
},
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
@pytest.mark.parametrize(
("input_data", "expected_output"),
[
(
{"predictions": ["string1", "string2"]},
[
completions.Choice(index=0, text="string1", finish_reason=None),
completions.Choice(index=1, text="string2", finish_reason=None),
],
),
(
{"predictions": {"choices": ["string1", "string2"]}},
[
completions.Choice(index=0, text="string1", finish_reason=None),
completions.Choice(index=1, text="string2", finish_reason=None),
],
),
(
{"predictions": {"choices": ["string1", "string2"], "ignored": ["a", "b"]}},
[
completions.Choice(index=0, text="string1", finish_reason=None),
completions.Choice(index=1, text="string2", finish_reason=None),
],
),
(
{"predictions": {"arbitrary_key": ["string1", "string2", "string3"]}},
[
completions.Choice(index=0, text="string1", finish_reason=None),
completions.Choice(index=1, text="string2", finish_reason=None),
completions.Choice(index=2, text="string3", finish_reason=None),
],
),
],
)
def test_valid_completions_input_parsing(input_data, expected_output):
config = completions_config()
provider = MlflowModelServingProvider(EndpointConfig(**config))
parsed = provider._process_completions_response_for_mlflow_serving(input_data)
assert parsed == expected_output
@pytest.mark.parametrize(
"invalid_data",
[
{"predictions": [1, 2, 3]}, # List of integers
{"predictions": {"choices": [1, 2, 3]}}, # Dict with list of integers
{"predictions": {"arbitrary_key": [1, 2, 3]}}, # Dict with list of integers
{"predictions": {"key1": ["string1"], "key2": ["string2"]}}, # Multiple keys in dict
{"predictions": []}, # Empty list
{"predictions": {"choices": []}}, # Dict with empty list
],
)
def test_validation_errors(invalid_data):
config = completions_config()
provider = MlflowModelServingProvider(EndpointConfig(**config))
with pytest.raises(AIGatewayException, match=r".*") as e:
provider._process_completions_response_for_mlflow_serving(invalid_data)
assert e.value.status_code == 502
assert "ServingTextResponse\npredictions" in e.value.detail
def test_invalid_return_key_from_mlflow_serving():
config = completions_config()
provider = MlflowModelServingProvider(EndpointConfig(**config))
with pytest.raises(AIGatewayException, match=r".*") as e:
provider._process_completions_response_for_mlflow_serving({
"invalid_return_key": ["invalid", "response"]
})
assert "1 validation error for ServingTextResponse\npredictions" in e.value.detail
assert e.value.status_code == 502
def embedding_config():
return {
"name": "embeddings",
"endpoint_type": "llm/v1/embeddings",
"model": {
"provider": "mlflow-model-serving",
"name": "sentence-piece",
"config": {
"model_server_url": "http://127.0.0.1:2000",
},
},
}
@pytest.mark.asyncio
async def test_embeddings():
resp = {
"predictions": [[0.01, -0.1], [0.03, -0.03]],
"headers": {"Content-Type": "application/json"},
}
config = embedding_config()
mock_client = mock_http_client(MockAsyncResponse(resp))
with mock.patch("aiohttp.ClientSession", return_value=mock_client) as mock_build_client:
provider = MlflowModelServingProvider(EndpointConfig(**config))
payload = {"input": ["test1", "test2"]}
response = await provider.embeddings(embeddings.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"object": "list",
"data": [
{
"object": "embedding",
"embedding": [
0.01,
-0.1,
],
"index": 0,
},
{
"object": "embedding",
"embedding": [
0.03,
-0.03,
],
"index": 1,
},
],
"model": "sentence-piece",
"usage": {"prompt_tokens": None, "total_tokens": None},
}
mock_build_client.assert_called_once()
mock_client.post.assert_called_once_with(
"http://127.0.0.1:2000/invocations",
json={"inputs": ["test1", "test2"]},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
@pytest.mark.parametrize(
"response",
[
{MLFLOW_SERVING_RESPONSE_KEY: "string_value"},
{MLFLOW_SERVING_RESPONSE_KEY: ["string", "values"]},
{MLFLOW_SERVING_RESPONSE_KEY: [[1.0, 2.3], ["string", "values"]]},
{MLFLOW_SERVING_RESPONSE_KEY: [[1.0, 2.3], [1.2, "string"]]},
{MLFLOW_SERVING_RESPONSE_KEY: [[], []]},
{MLFLOW_SERVING_RESPONSE_KEY: []},
],
)
def test_invalid_embeddings_response(response):
config = embedding_config()
provider = MlflowModelServingProvider(EndpointConfig(**config))
with pytest.raises(AIGatewayException, match=r".*") as e:
provider._process_embeddings_response_for_mlflow_serving(response)
assert "EmbeddingsResponse\npredictions" in e.value.detail
assert e.value.status_code == 502
def chat_config():
return {
"name": "chat",
"endpoint_type": "llm/v1/chat",
"model": {
"provider": "mlflow-model-serving",
"name": "chat-bot-9000",
"config": {
"model_server_url": "http://127.0.0.1:4000",
},
},
}
@pytest.mark.asyncio
async def test_chat():
resp = {
"predictions": ["It is a test"],
"headers": {"Content-Type": "application/json"},
}
config = chat_config()
mock_client = mock_http_client(MockAsyncResponse(resp))
with (
mock.patch("time.time", return_value=1700242674),
mock.patch("aiohttp.ClientSession", return_value=mock_client) as mock_build_client,
):
provider = MlflowModelServingProvider(EndpointConfig(**config))
payload = {"messages": [{"role": "user", "content": "Is this a test?"}]}
response = await provider.chat(chat.RequestPayload(**payload))
assert jsonable_encoder(response) == {
"id": None,
"created": 1700242674,
"object": "chat.completion",
"model": "chat-bot-9000",
"provider": "mlflow-model-serving",
"choices": [
{
"message": {
"role": "assistant",
"content": "It is a test",
"tool_calls": None,
"refusal": None,
},
"finish_reason": None,
"index": 0,
}
],
"usage": {
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
},
}
mock_build_client.assert_called_once()
mock_client.post.assert_called_once_with(
"http://127.0.0.1:4000/invocations",
json={
"inputs": ["Is this a test?"],
"params": {"n": 1},
},
timeout=ClientTimeout(total=MLFLOW_GATEWAY_ROUTE_TIMEOUT_SECONDS.get()),
)
@pytest.mark.asyncio
async def test_chat_exception_raised_for_multiple_elements_in_query():
resp = {"predictions": "It is a test"}
config = chat_config()
mock_client = mock_http_client(MockAsyncResponse(resp))
with mock.patch("aiohttp.ClientSession", return_value=mock_client):
provider = MlflowModelServingProvider(EndpointConfig(**config))
payload = {
"messages": [
{"role": "user", "content": "Is this a test?"},
{"role": "user", "content": "This is a second message."},
]
}
with pytest.raises(AIGatewayException, match=r".*") as e:
await provider.chat(chat.RequestPayload(**payload))
assert "MLflow chat models are only capable of processing" in e.value.detail
def test_route_construction_fails_with_invalid_config():
with pytest.raises(pydantic.ValidationError, match="model_server_url"):
MlflowModelServingConfig(model_server_url=None)