473 lines
16 KiB
Python
473 lines
16 KiB
Python
from unittest import mock
|
|
|
|
import pydantic
|
|
import pytest
|
|
|
|
from mlflow.genai.utils.gateway_utils import GatewayLiteLLMConfig
|
|
from mlflow.genai.utils.llm_utils import (
|
|
_call_llm,
|
|
_call_llm_via_gateway,
|
|
_fetch_model_cost,
|
|
_lookup_model_cost,
|
|
_ModelCost,
|
|
_resolve_model_for_gateway,
|
|
_TokenCounter,
|
|
)
|
|
from mlflow.types.chat import ChatChoice, ChatCompletionResponse, ChatMessage, ChatUsage
|
|
|
|
|
|
def test_token_counter_tracks_usage():
|
|
counter = _TokenCounter(model="openai:/gpt-5-mini")
|
|
assert counter.input_tokens == 0
|
|
assert counter.output_tokens == 0
|
|
assert counter.cost_usd is None
|
|
|
|
mock_response = mock.MagicMock()
|
|
mock_response.usage = mock.MagicMock()
|
|
mock_response.usage.prompt_tokens = 100
|
|
mock_response.usage.completion_tokens = 50
|
|
mock_response._hidden_params = {"response_cost": 0.005}
|
|
|
|
counter.track(mock_response)
|
|
|
|
assert counter.input_tokens == 100
|
|
assert counter.output_tokens == 50
|
|
assert counter.cost_usd == 0.005
|
|
|
|
|
|
def test_token_counter_tracks_gateway_response_without_hidden_params():
|
|
counter = _TokenCounter(model="openai:/gpt-5-mini")
|
|
response = ChatCompletionResponse(
|
|
created=0,
|
|
model="gpt-5-mini",
|
|
choices=[ChatChoice(index=0, message=ChatMessage(role="assistant", content="hi"))],
|
|
usage=ChatUsage(prompt_tokens=200, completion_tokens=80, total_tokens=280),
|
|
)
|
|
|
|
counter.track(response)
|
|
|
|
assert counter.input_tokens == 200
|
|
assert counter.output_tokens == 80
|
|
assert counter._cost_usd == 0.0
|
|
assert counter._model == "openai:/gpt-5-mini"
|
|
|
|
|
|
def test_token_counter_to_dict_looks_up_cost_when_zero():
|
|
counter = _TokenCounter(input_tokens=100, output_tokens=50, model="openai:/gpt-5-mini")
|
|
|
|
with mock.patch(
|
|
"mlflow.genai.utils.llm_utils._lookup_model_cost",
|
|
return_value=0.0042,
|
|
) as mock_lookup:
|
|
result = counter.to_dict()
|
|
|
|
mock_lookup.assert_called_once()
|
|
assert result["cost_usd"] == 0.0042
|
|
assert result["total_tokens"] == 150
|
|
|
|
|
|
def test_call_llm_uses_gateway_when_litellm_unavailable():
|
|
with (
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._is_litellm_available", return_value=False
|
|
) as mock_avail,
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._call_llm_via_gateway",
|
|
) as mock_gw,
|
|
):
|
|
_call_llm("openai:/gpt-5-mini", [{"role": "user", "content": "hi"}])
|
|
|
|
mock_avail.assert_called_once()
|
|
mock_gw.assert_called_once()
|
|
|
|
|
|
def test_call_llm_uses_litellm_when_available():
|
|
with (
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True
|
|
) as mock_avail,
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._call_llm_via_litellm",
|
|
) as mock_ll,
|
|
):
|
|
_call_llm("openai:/gpt-5-mini", [{"role": "user", "content": "hi"}])
|
|
|
|
mock_avail.assert_called_once()
|
|
mock_ll.assert_called_once()
|
|
|
|
|
|
def test_lookup_model_cost_returns_calculated_cost():
|
|
cost_info = _ModelCost(input_cost_per_token=0.00001, output_cost_per_token=0.00003)
|
|
with mock.patch(
|
|
"mlflow.genai.utils.llm_utils._fetch_model_cost", return_value=cost_info
|
|
) as mock_fetch:
|
|
cost = _lookup_model_cost("openai:/gpt-5-mini", 1000, 500)
|
|
|
|
mock_fetch.assert_called_once()
|
|
assert cost == pytest.approx(1000 * 0.00001 + 500 * 0.00003)
|
|
|
|
|
|
def test_lookup_model_cost_returns_none_on_missing_model():
|
|
with mock.patch(
|
|
"mlflow.genai.utils.llm_utils._fetch_model_cost", return_value=None
|
|
) as mock_fetch:
|
|
assert _lookup_model_cost("openai:/gpt-5-mini", 100, 50) is None
|
|
|
|
mock_fetch.assert_called_once()
|
|
|
|
|
|
def test_call_llm_handles_gateway_models():
|
|
mock_response = mock.MagicMock()
|
|
mock_response.usage = mock.MagicMock()
|
|
mock_response.usage.prompt_tokens = 10
|
|
mock_response.usage.completion_tokens = 20
|
|
|
|
gateway_config = GatewayLiteLLMConfig(
|
|
model="openai/test-endpoint",
|
|
api_base="http://localhost:5000/gateway",
|
|
api_key="test-key",
|
|
extra_headers={"X-Custom": "header"},
|
|
)
|
|
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True),
|
|
mock.patch(
|
|
"mlflow.genai.utils.gateway_utils.get_gateway_litellm_config",
|
|
return_value=gateway_config,
|
|
) as mock_get_config,
|
|
mock.patch(
|
|
"mlflow.genai.judges.adapters.litellm_adapter._invoke_litellm",
|
|
return_value=mock_response,
|
|
) as mock_invoke,
|
|
):
|
|
messages = [{"role": "user", "content": "test"}]
|
|
result = _call_llm("gateway:/test-endpoint", messages)
|
|
|
|
mock_get_config.assert_called_once_with("test-endpoint")
|
|
mock_invoke.assert_called_once()
|
|
|
|
call_kwargs = mock_invoke.call_args[1]
|
|
assert call_kwargs["litellm_model"] == "openai/test-endpoint"
|
|
assert call_kwargs["api_base"] == "http://localhost:5000/gateway"
|
|
assert call_kwargs["api_key"] == "test-key"
|
|
assert call_kwargs["extra_headers"] == {"X-Custom": "header"}
|
|
assert call_kwargs["messages"] == messages
|
|
assert result == mock_response
|
|
|
|
|
|
def test_call_llm_handles_non_gateway_models():
|
|
mock_response = mock.MagicMock()
|
|
mock_response.usage = mock.MagicMock()
|
|
mock_response.usage.prompt_tokens = 10
|
|
mock_response.usage.completion_tokens = 20
|
|
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True),
|
|
mock.patch(
|
|
"mlflow.metrics.genai.model_utils.convert_mlflow_uri_to_litellm",
|
|
return_value="openai/gpt-4",
|
|
) as mock_convert,
|
|
mock.patch(
|
|
"mlflow.genai.judges.adapters.litellm_adapter._invoke_litellm",
|
|
return_value=mock_response,
|
|
) as mock_invoke,
|
|
):
|
|
messages = [{"role": "user", "content": "test"}]
|
|
result = _call_llm("openai:/gpt-4", messages)
|
|
|
|
mock_convert.assert_called_once_with("openai:/gpt-4")
|
|
mock_invoke.assert_called_once()
|
|
|
|
call_kwargs = mock_invoke.call_args[1]
|
|
assert call_kwargs["litellm_model"] == "openai/gpt-4"
|
|
assert call_kwargs["api_base"] is None
|
|
assert call_kwargs["api_key"] is None
|
|
assert call_kwargs["extra_headers"] is None
|
|
assert call_kwargs["messages"] == messages
|
|
assert result == mock_response
|
|
|
|
|
|
def test_call_llm_with_json_mode():
|
|
mock_response = mock.MagicMock()
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True),
|
|
mock.patch(
|
|
"mlflow.genai.judges.adapters.litellm_adapter._invoke_litellm",
|
|
return_value=mock_response,
|
|
) as mock_invoke,
|
|
):
|
|
messages = [{"role": "user", "content": "test"}]
|
|
_call_llm("openai:/gpt-4", messages, json_mode=True)
|
|
|
|
call_kwargs = mock_invoke.call_args[1]
|
|
assert call_kwargs["response_format"] == {"type": "json_object"}
|
|
assert call_kwargs["include_response_format"] is True
|
|
|
|
|
|
def test_call_llm_with_response_format():
|
|
class TestModel(pydantic.BaseModel):
|
|
field: str
|
|
|
|
mock_response = mock.MagicMock()
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True),
|
|
mock.patch(
|
|
"mlflow.genai.judges.adapters.litellm_adapter._invoke_litellm",
|
|
return_value=mock_response,
|
|
) as mock_invoke,
|
|
):
|
|
messages = [{"role": "user", "content": "test"}]
|
|
_call_llm("openai:/gpt-4", messages, response_format=TestModel)
|
|
|
|
call_kwargs = mock_invoke.call_args[1]
|
|
assert call_kwargs["response_format"] == TestModel
|
|
assert call_kwargs["include_response_format"] is True
|
|
|
|
|
|
def test_call_llm_tracks_tokens():
|
|
mock_response = mock.MagicMock()
|
|
mock_response.usage = mock.MagicMock()
|
|
mock_response.usage.prompt_tokens = 100
|
|
mock_response.usage.completion_tokens = 50
|
|
mock_response._hidden_params = {"response_cost": 0.01}
|
|
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True),
|
|
mock.patch(
|
|
"mlflow.genai.judges.adapters.litellm_adapter._invoke_litellm",
|
|
return_value=mock_response,
|
|
),
|
|
):
|
|
counter = _TokenCounter(model="openai:/gpt-5-mini")
|
|
messages = [{"role": "user", "content": "test"}]
|
|
_call_llm("openai:/gpt-4", messages, token_counter=counter)
|
|
|
|
assert counter.input_tokens == 100
|
|
assert counter.output_tokens == 50
|
|
assert counter.cost_usd == 0.01
|
|
|
|
|
|
def test_call_llm_inference_params_forwarded_to_litellm():
|
|
mock_response = mock.MagicMock()
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=True),
|
|
mock.patch(
|
|
"mlflow.genai.judges.adapters.litellm_adapter._invoke_litellm",
|
|
return_value=mock_response,
|
|
) as mock_invoke,
|
|
):
|
|
messages = [{"role": "user", "content": "test"}]
|
|
_call_llm(
|
|
"openai:/gpt-4",
|
|
messages,
|
|
inference_params={"temperature": 0.5, "max_completion_tokens": 1024},
|
|
)
|
|
|
|
call_kwargs = mock_invoke.call_args[1]
|
|
# inference_params should override the default max_completion_tokens
|
|
assert call_kwargs["inference_params"]["temperature"] == 0.5
|
|
assert call_kwargs["inference_params"]["max_completion_tokens"] == 1024
|
|
|
|
|
|
def test_call_llm_inference_params_forwarded_to_gateway():
|
|
mock_provider = mock.MagicMock()
|
|
captured_payload = {}
|
|
mock_provider.adapter_class.chat_to_model.side_effect = lambda payload, config: (
|
|
captured_payload.update(payload) or payload
|
|
)
|
|
mock_provider.get_endpoint_url.return_value = "http://localhost:5000/v1/chat/completions"
|
|
mock_provider.headers = {}
|
|
|
|
mock_chat_response = mock.MagicMock()
|
|
mock_chat_response.usage = mock.MagicMock(prompt_tokens=10, completion_tokens=5)
|
|
mock_provider.adapter_class.model_to_chat.return_value = mock_chat_response
|
|
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=False),
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._get_provider_instance",
|
|
return_value=mock_provider,
|
|
),
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._send_request",
|
|
return_value={},
|
|
),
|
|
):
|
|
_call_llm(
|
|
"openai:/gpt-4",
|
|
[{"role": "user", "content": "test"}],
|
|
inference_params={"temperature": 0.7, "max_completion_tokens": 512},
|
|
)
|
|
|
|
assert captured_payload["temperature"] == 0.7
|
|
# inference_params should override the default max_completion_tokens
|
|
assert captured_payload["max_completion_tokens"] == 512
|
|
|
|
|
|
# ---- gateway:/ URI via _get_provider_instance ----
|
|
|
|
|
|
def test_call_llm_via_gateway_dispatches_gateway_uri_without_litellm():
|
|
mock_provider = mock.MagicMock()
|
|
mock_provider.adapter_class.chat_to_model.side_effect = lambda payload, config: payload
|
|
mock_provider.get_endpoint_url.return_value = (
|
|
"http://localhost:5000/gateway/mlflow/v1/chat/completions"
|
|
)
|
|
mock_provider.headers = {"Authorization": "Bearer token"}
|
|
|
|
raw_response = {
|
|
"id": "chatcmpl-gw",
|
|
"object": "chat.completion",
|
|
"created": 1234567890,
|
|
"model": "my-endpoint",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "gateway response"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
}
|
|
|
|
mock_chat_response = mock.MagicMock()
|
|
mock_chat_response.choices = [
|
|
mock.MagicMock(message=mock.MagicMock(content="gateway response"))
|
|
]
|
|
mock_chat_response.usage = mock.MagicMock(prompt_tokens=10, completion_tokens=5)
|
|
mock_provider.adapter_class.model_to_chat.return_value = mock_chat_response
|
|
|
|
with (
|
|
mock.patch("mlflow.genai.utils.llm_utils._is_litellm_available", return_value=False),
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._get_provider_instance",
|
|
return_value=mock_provider,
|
|
) as mock_get_provider,
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._send_request",
|
|
return_value=raw_response,
|
|
) as mock_send,
|
|
):
|
|
messages = [{"role": "user", "content": "test"}]
|
|
result = _call_llm("gateway:/my-endpoint", messages)
|
|
|
|
mock_get_provider.assert_called_once_with("gateway", "my-endpoint")
|
|
mock_send.assert_called_once()
|
|
assert result.choices[0].message.content == "gateway response"
|
|
|
|
|
|
def _make_mapping(provider=None, model_name=None):
|
|
mapping = mock.MagicMock()
|
|
if provider:
|
|
mapping.model_definition.provider = provider
|
|
mapping.model_definition.model_name = model_name
|
|
else:
|
|
mapping.model_definition = None
|
|
return mapping
|
|
|
|
|
|
def _make_store(model_mappings=(), raises=False):
|
|
store = mock.MagicMock()
|
|
if raises:
|
|
store.get_gateway_endpoint.side_effect = Exception()
|
|
else:
|
|
endpoint = mock.MagicMock()
|
|
endpoint.model_mappings = list(model_mappings)
|
|
store.get_gateway_endpoint.return_value = endpoint
|
|
return store
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mock_store", "expected"),
|
|
[
|
|
(_make_store([_make_mapping("openai", "gpt-4o")]), "openai:/gpt-4o"),
|
|
(_make_store(), None),
|
|
(_make_store([_make_mapping()]), None),
|
|
(_make_store(raises=True), None),
|
|
],
|
|
ids=["success", "no_mappings", "no_model_def", "exception"],
|
|
)
|
|
def test_resolve_model_for_gateway(mock_store, expected):
|
|
with mock.patch("mlflow.genai.utils.llm_utils._get_store", return_value=mock_store):
|
|
assert _resolve_model_for_gateway("my-endpoint") == expected
|
|
|
|
|
|
def test_token_counter_resolves_gateway_model_on_init():
|
|
with mock.patch(
|
|
"mlflow.genai.utils.llm_utils._resolve_model_for_gateway",
|
|
return_value="openai:/gpt-4o",
|
|
) as mock_resolve:
|
|
counter = _TokenCounter(model="gateway:/my-endpoint")
|
|
|
|
mock_resolve.assert_called_once_with("my-endpoint")
|
|
assert counter._model == "openai:/gpt-4o"
|
|
|
|
|
|
def test_token_counter_does_not_resolve_non_gateway_model():
|
|
with mock.patch("mlflow.genai.utils.llm_utils._resolve_model_for_gateway") as mock_resolve:
|
|
counter = _TokenCounter(model="openai:/gpt-4o")
|
|
|
|
mock_resolve.assert_not_called()
|
|
assert counter._model == "openai:/gpt-4o"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_info", "provider", "model_name", "expected_cost"),
|
|
[
|
|
(
|
|
{"input_cost_per_token": 0.00001, "output_cost_per_token": 0.00003},
|
|
"openai",
|
|
"gpt-4o",
|
|
_ModelCost(input_cost_per_token=0.00001, output_cost_per_token=0.00003),
|
|
),
|
|
(None, "openai", "unknown-model", None),
|
|
],
|
|
)
|
|
def test_fetch_model_cost(model_info, provider, model_name, expected_cost):
|
|
with mock.patch(
|
|
"mlflow.utils.providers._lookup_model_info", return_value=model_info
|
|
) as mock_lookup:
|
|
_fetch_model_cost.cache_clear()
|
|
result = _fetch_model_cost(provider, model_name)
|
|
|
|
mock_lookup.assert_called_once_with(model_name, custom_llm_provider=provider)
|
|
assert result == expected_cost
|
|
|
|
|
|
def test_lookup_model_cost_passes_provider_and_model():
|
|
cost_info = _ModelCost(input_cost_per_token=1, output_cost_per_token=3)
|
|
with mock.patch(
|
|
"mlflow.genai.utils.llm_utils._fetch_model_cost", return_value=cost_info
|
|
) as mock_fetch:
|
|
cost = _lookup_model_cost("anthropic:/claude-3-5-sonnet", 1000, 500)
|
|
|
|
mock_fetch.assert_called_once_with("anthropic", "claude-3-5-sonnet")
|
|
assert cost == 1000 * 1 + 500 * 3
|
|
|
|
|
|
def test_call_llm_via_gateway_uses_resolved_model_from_token_counter():
|
|
mock_provider = mock.MagicMock()
|
|
mock_provider.adapter_class.model_to_chat.return_value.usage.prompt_tokens = 10
|
|
mock_provider.adapter_class.model_to_chat.return_value.usage.completion_tokens = 5
|
|
|
|
with mock.patch(
|
|
"mlflow.genai.utils.llm_utils._resolve_model_for_gateway",
|
|
return_value="openai:/gpt-4o",
|
|
):
|
|
counter = _TokenCounter(model="gateway:/my-endpoint")
|
|
|
|
assert counter._model == "openai:/gpt-4o"
|
|
|
|
with (
|
|
mock.patch(
|
|
"mlflow.genai.utils.llm_utils._get_provider_instance",
|
|
return_value=mock_provider,
|
|
),
|
|
mock.patch("mlflow.genai.utils.llm_utils._send_request", return_value={}),
|
|
):
|
|
_call_llm_via_gateway(
|
|
"gateway:/my-endpoint", [{"role": "user", "content": "hi"}], token_counter=counter
|
|
)
|
|
|
|
assert counter._model == "openai:/gpt-4o"
|