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

555 lines
19 KiB
Python

import json
from unittest import mock
import pytest
from mlflow.exceptions import MlflowException
from mlflow.utils.providers import (
_fetch_remote_provider,
_flatten_catalog_entry,
_get_remote_cache,
_list_provider_names,
_load_bundled_provider,
_load_provider,
_normalize_provider,
cost_per_token,
get_all_providers,
get_models,
get_provider_config_response,
)
def test_normalize_provider_normalizes_vertex_ai_variants():
assert _normalize_provider("vertex_ai") == "vertex_ai"
assert _normalize_provider("vertex_ai-anthropic") == "vertex_ai"
assert _normalize_provider("vertex_ai-llama_models") == "vertex_ai"
assert _normalize_provider("vertex_ai-mistral") == "vertex_ai"
def test_normalize_provider_does_not_normalize_other_providers():
assert _normalize_provider("openai") == "openai"
assert _normalize_provider("anthropic") == "anthropic"
assert _normalize_provider("bedrock") == "bedrock"
assert _normalize_provider("gemini") == "gemini"
def test_list_provider_names_returns_bundled_providers():
_list_provider_names.cache_clear()
providers = _list_provider_names()
assert len(providers) > 0
assert "openai" in providers
assert "anthropic" in providers
assert "bedrock" in providers
def test_list_provider_names_excludes_non_json():
_list_provider_names.cache_clear()
providers = _list_provider_names()
# __init__.py should not appear
assert "__init__" not in providers
for p in providers:
assert not p.endswith(".py")
def test_load_provider_returns_models(monkeypatch):
monkeypatch.setenv("MLFLOW_MODEL_CATALOG_URI", "")
_load_bundled_provider.cache_clear()
models = _load_provider("openai")
assert len(models) > 0
assert "gpt-4o" in models
info = models["gpt-4o"]
assert info["mode"] == "chat"
assert "input_cost_per_token" in info
assert info["input_cost_per_token"] > 0
def test_load_provider_returns_empty_for_unknown(monkeypatch):
monkeypatch.setenv("MLFLOW_MODEL_CATALOG_URI", "")
_load_bundled_provider.cache_clear()
assert _load_provider("nonexistent_provider_xyz") == {}
def test_load_provider_flattens_pricing(monkeypatch):
monkeypatch.setenv("MLFLOW_MODEL_CATALOG_URI", "")
_load_bundled_provider.cache_clear()
models = _load_provider("anthropic")
model = next(iter(models.values()))
# Should have flat ModelInfo keys, not nested pricing/capabilities
assert "input_cost_per_token" in model or "mode" in model
assert "pricing" not in model
assert "context_window" not in model
def _mock_catalog(provider_data):
"""Context manager that mocks the per-provider catalog with the given data.
``provider_data`` is a dict mapping provider names to ``{model_name: info}`` dicts.
"""
return (
mock.patch(
"mlflow.utils.providers._load_provider",
side_effect=lambda p: provider_data.get(p, {}),
),
mock.patch(
"mlflow.utils.providers._list_provider_names",
return_value=list(provider_data.keys()),
),
)
def test_get_all_providers_consolidates_vertex_ai_variants():
data = {
"openai": {"gpt-4o": {"mode": "chat"}},
"anthropic": {"claude-3-5-sonnet": {"mode": "chat"}},
"vertex_ai": {"gemini-1.5-pro": {"mode": "chat"}},
"vertex_ai-llama_models": {"meta/llama-4-scout": {"mode": "chat"}},
"vertex_ai-anthropic": {"claude-3-5-sonnet": {"mode": "chat"}},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
providers = get_all_providers()
assert "vertex_ai" in providers
assert "vertex_ai-llama_models" not in providers
assert "vertex_ai-anthropic" not in providers
assert "openai" in providers
assert "anthropic" in providers
def test_get_models_normalizes_vertex_ai_provider_and_strips_prefix():
data = {
"vertex_ai-llama_models": {
"meta/llama-4-scout-17b-16e-instruct-maas": {
"mode": "chat",
"supports_function_calling": True,
}
},
"vertex_ai-anthropic": {
"claude-3-5-sonnet": {"mode": "chat", "supports_function_calling": True}
},
"vertex_ai": {"gemini-1.5-pro": {"mode": "chat", "supports_function_calling": True}},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
models = get_models(provider="vertex_ai")
assert len(models) == 3
for model in models:
assert model["provider"] == "vertex_ai"
model_names = [m["model"] for m in models]
assert "meta/llama-4-scout-17b-16e-instruct-maas" in model_names
assert "claude-3-5-sonnet" in model_names
assert "gemini-1.5-pro" in model_names
def test_get_models_filters_by_consolidated_provider():
data = {
"openai": {"gpt-4o": {"mode": "chat"}},
"vertex_ai-llama_models": {"meta/llama-4-scout": {"mode": "chat"}},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
vertex_models = get_models(provider="vertex_ai")
assert len(vertex_models) == 1
assert vertex_models[0]["model"] == "meta/llama-4-scout"
openai_models = get_models(provider="openai")
assert len(openai_models) == 1
assert openai_models[0]["model"] == "gpt-4o"
def test_get_models_does_not_modify_other_providers():
data = {
"openai": {"gpt-4o": {"mode": "chat", "supports_function_calling": True}},
"anthropic": {"claude-3-5-sonnet": {"mode": "chat", "supports_function_calling": True}},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
models = get_models()
openai_model = next(m for m in models if m["provider"] == "openai")
assert openai_model["model"] == "gpt-4o"
anthropic_model = next(m for m in models if m["provider"] == "anthropic")
assert anthropic_model["model"] == "claude-3-5-sonnet"
def test_get_models_dedupes_models_after_normalization():
data = {
"vertex_ai": {
"gemini-3-flash-preview": {"mode": "chat", "supports_function_calling": True}
},
"vertex_ai-chat-models": {
"gemini-3-flash-preview": {"mode": "chat", "supports_function_calling": True}
},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
models = get_models(provider="vertex_ai")
model_names = [m["model"] for m in models]
assert model_names.count("gemini-3-flash-preview") == 1
assert len(models) == 1
def test_get_all_providers_with_allowed_filter(monkeypatch):
data = {
"openai": {"gpt-4o": {"mode": "chat"}},
"anthropic": {"claude-3-5-sonnet": {"mode": "chat"}},
"gemini": {"gemini-1.5-pro": {"mode": "chat"}},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
monkeypatch.setenv("MLFLOW_GATEWAY_ALLOWED_PROVIDERS", "openai,anthropic")
providers = get_all_providers()
assert "openai" in providers
assert "anthropic" in providers
assert "gemini" not in providers
def test_get_models_filters_with_allowed_providers(monkeypatch):
data = {
"openai": {"gpt-4o": {"mode": "chat", "supports_function_calling": True}},
"anthropic": {"claude-3-5-sonnet": {"mode": "chat", "supports_function_calling": True}},
"gemini": {"gemini-1.5-pro": {"mode": "chat", "supports_function_calling": True}},
}
with _mock_catalog(data)[0], _mock_catalog(data)[1]:
monkeypatch.setenv("MLFLOW_GATEWAY_ALLOWED_PROVIDERS", "openai")
models = get_models()
providers_in_result = {m["provider"] for m in models}
assert providers_in_result == {"openai"}
def test_get_provider_config_rejects_provider_not_in_allowed_list(monkeypatch):
monkeypatch.setenv("MLFLOW_GATEWAY_ALLOWED_PROVIDERS", "anthropic")
with pytest.raises(MlflowException, match="not allowed"):
get_provider_config_response("openai")
def test_get_provider_config_bedrock_has_default_chain():
config = get_provider_config_response("bedrock")
modes = {m["mode"] for m in config["auth_modes"]}
assert "default_chain" in modes
default_chain = next(m for m in config["auth_modes"] if m["mode"] == "default_chain")
assert default_chain["display_name"] == "Default Credential Chain"
assert default_chain["secret_fields"] == []
assert all(not f["required"] for f in default_chain["config_fields"])
def test_get_provider_config_sagemaker_has_default_chain():
config = get_provider_config_response("sagemaker")
modes = {m["mode"] for m in config["auth_modes"]}
assert "default_chain" in modes
def test_get_provider_config_vertex_ai_has_default_chain():
config = get_provider_config_response("vertex_ai")
modes = {m["mode"] for m in config["auth_modes"]}
assert "default_chain" in modes
default_chain = next(m for m in config["auth_modes"] if m["mode"] == "default_chain")
assert default_chain["display_name"] == "Application Default Credentials"
assert default_chain["secret_fields"] == []
project_field = next(f for f in default_chain["config_fields"] if f["name"] == "vertex_project")
assert project_field["required"] is True
_MOCK_PROVIDER_DATA = {
"test_provider": {
"test-model": {
"input_cost_per_token": 1e-6,
"output_cost_per_token": 2e-6,
"cache_read_input_token_cost": 5e-7,
"cache_creation_input_token_cost": 3e-6,
},
},
"openai": {
"test-provider-model": {
"input_cost_per_token": 1e-6,
"output_cost_per_token": 2e-6,
},
},
}
def _mock_load_provider(provider):
return _MOCK_PROVIDER_DATA.get(provider, {})
@pytest.fixture
def mock_model_cost():
with (
mock.patch(
"mlflow.utils.providers._load_provider", side_effect=_mock_load_provider
) as m_load,
mock.patch(
"mlflow.utils.providers._load_bundled_provider", side_effect=_mock_load_provider
),
mock.patch(
"mlflow.utils.providers._list_provider_names",
return_value=list(_MOCK_PROVIDER_DATA.keys()),
),
):
yield m_load
def test_cost_per_token_basic(mock_model_cost):
input_cost, output_cost = cost_per_token(
model="test-model", prompt_tokens=1000, completion_tokens=500
)
# input: 1000 * 1e-6 = 0.001, output: 500 * 2e-6 = 0.001
assert input_cost == pytest.approx(0.001)
assert output_cost == pytest.approx(0.001)
def test_cost_per_token_with_provider_prefix(mock_model_cost):
# "test-provider-model" only exists under "openai/" prefix, so provider lookup is exercised
input_cost, output_cost = cost_per_token(
model="test-provider-model",
prompt_tokens=1000,
completion_tokens=500,
custom_llm_provider="openai",
)
assert input_cost == pytest.approx(0.001)
assert output_cost == pytest.approx(0.001)
def test_cost_per_token_strips_provider_prefix(mock_model_cost):
# "openai/test-model" should resolve to "test-model" by stripping the prefix
input_cost, output_cost = cost_per_token(
model="openai/test-model", prompt_tokens=1000, completion_tokens=500
)
assert input_cost == pytest.approx(0.001)
assert output_cost == pytest.approx(0.001)
def test_cost_per_token_cache_read_tokens(mock_model_cost):
input_cost, output_cost = cost_per_token(
model="test-model",
prompt_tokens=1000,
completion_tokens=500,
cache_read_input_tokens=200,
)
# regular: (1000-200) * 1e-6 = 0.0008
# cache_read: 200 * 5e-7 = 0.0001
assert input_cost == pytest.approx(0.0009)
assert output_cost == pytest.approx(0.001)
def test_cost_per_token_cache_creation_tokens(mock_model_cost):
input_cost, output_cost = cost_per_token(
model="test-model",
prompt_tokens=1000,
completion_tokens=500,
cache_creation_input_tokens=300,
)
# regular: (1000-300) * 1e-6 = 0.0007
# cache_creation: 300 * 3e-6 = 0.0009
assert input_cost == pytest.approx(0.0016)
assert output_cost == pytest.approx(0.001)
def test_cost_per_token_zero_tokens(mock_model_cost):
input_cost, output_cost = cost_per_token(
model="test-model", prompt_tokens=0, completion_tokens=0
)
assert input_cost == 0.0
assert output_cost == 0.0
def test_cost_per_token_unknown_model_returns_none(mock_model_cost):
assert cost_per_token(model="totally-unknown-model", prompt_tokens=100) is None
def test_cost_per_token_unknown_model_with_provider_returns_none(mock_model_cost):
assert (
cost_per_token(
model="totally-unknown-model",
prompt_tokens=100,
custom_llm_provider="unknown-provider",
)
is None
)
def test_cost_per_token_no_cache_cost_falls_back_to_input_rate():
no_cache_data = {
"nocache_provider": {
"test-model": {
"input_cost_per_token": 1e-6,
"output_cost_per_token": 2e-6,
}
}
}
with (
mock.patch(
"mlflow.utils.providers._load_provider",
side_effect=lambda p: no_cache_data.get(p, {}),
),
mock.patch(
"mlflow.utils.providers._load_bundled_provider",
side_effect=lambda p: no_cache_data.get(p, {}),
),
mock.patch(
"mlflow.utils.providers._list_provider_names",
return_value=list(no_cache_data.keys()),
),
):
input_cost, output_cost = cost_per_token(
model="test-model",
prompt_tokens=1000,
completion_tokens=500,
cache_read_input_tokens=200,
)
# No cache_read_input_token_cost, falls back to input_cost_per_token
# regular: 800 * 1e-6 = 0.0008
# cache_read: 200 * 1e-6 = 0.0002 (same rate as regular)
assert input_cost == pytest.approx(0.001)
assert output_cost == pytest.approx(0.001)
def test_flatten_catalog_entry():
entry = {
"mode": "chat",
"context_window": {"max_input": 128000, "max_output": 16384},
"pricing": {
"input_per_million_tokens": 2.5,
"output_per_million_tokens": 10.0,
"cache_read_per_million_tokens": 1.25,
"cache_write_per_million_tokens": 5.0,
},
"capabilities": {
"function_calling": True,
"vision": True,
"reasoning": False,
"prompt_caching": True,
"response_schema": True,
},
"deprecation_date": "2026-01-01",
}
info = _flatten_catalog_entry(entry)
assert info["mode"] == "chat"
assert info["max_input_tokens"] == 128000
assert info["max_output_tokens"] == 16384
assert info["input_cost_per_token"] == pytest.approx(2.5e-6)
assert info["output_cost_per_token"] == pytest.approx(1e-5)
assert info["cache_read_input_token_cost"] == pytest.approx(1.25e-6)
assert info["cache_creation_input_token_cost"] == pytest.approx(5e-6)
assert info["supports_function_calling"] is True
assert info["supports_vision"] is True
assert info["supports_reasoning"] is False
assert info["deprecation_date"] == "2026-01-01"
def test_flatten_catalog_entry_with_last_updated_at():
entry = {
"mode": "chat",
"capabilities": {
"function_calling": False,
"vision": False,
"reasoning": False,
"prompt_caching": False,
"response_schema": False,
},
"last_updated_at": "2025-01-15",
}
info = _flatten_catalog_entry(entry)
assert info["last_updated_at"] == "2025-01-15"
def test_flatten_catalog_entry_without_last_updated_at():
entry = {
"mode": "chat",
"capabilities": {
"function_calling": False,
"vision": False,
"reasoning": False,
"prompt_caching": False,
"response_schema": False,
},
}
info = _flatten_catalog_entry(entry)
assert "last_updated_at" not in info
def test_flatten_catalog_entry_with_modality_pricing():
entry = {
"mode": "embedding",
"context_window": {"max_input": 8172, "max_tokens": 8172},
"pricing": {
"input_per_million_tokens": 0.135,
"output_per_million_tokens": 0.0,
"modality": {
"image": {"input_per_million_tokens": 60.0},
"video": {"input_per_second": 0.0007},
"audio": {"input_per_second": 0.00014},
},
},
"capabilities": {
"function_calling": False,
"vision": True,
"reasoning": False,
"prompt_caching": False,
"response_schema": False,
},
}
info = _flatten_catalog_entry(entry)
assert info["mode"] == "embedding"
assert info["input_cost_per_token"] == pytest.approx(0.135e-6)
assert info["modality"] == {
"image": {"input_per_million_tokens": 60.0},
"video": {"input_per_second": 0.0007},
"audio": {"input_per_second": 0.00014},
}
assert info["supports_vision"] is True
def test_flatten_catalog_entry_without_modality_pricing():
entry = {
"mode": "chat",
"pricing": {"input_per_million_tokens": 1.0, "output_per_million_tokens": 2.0},
"capabilities": {
"function_calling": False,
"vision": False,
"reasoning": False,
"prompt_caching": False,
"response_schema": False,
},
}
info = _flatten_catalog_entry(entry)
assert "modality" not in info
def test_load_provider_uses_remote_when_available():
remote_data = {"test-model": {"mode": "chat", "input_cost_per_token": 1e-6}}
with mock.patch(
"mlflow.utils.providers._fetch_remote_provider", return_value=remote_data
) as mock_remote:
result = _load_provider("openai")
mock_remote.assert_called_once_with("openai")
assert result is remote_data
def test_load_provider_falls_back_to_bundled_when_remote_fails():
with mock.patch(
"mlflow.utils.providers._fetch_remote_provider", return_value=None
) as mock_remote:
result = _load_provider("openai")
mock_remote.assert_called_once_with("openai")
assert len(result) > 0
assert "gpt-4o" in result
def test_fetch_remote_provider_disabled_when_url_empty(monkeypatch):
monkeypatch.setenv("MLFLOW_MODEL_CATALOG_URI", "")
assert _fetch_remote_provider("openai") is None
def test_fetch_remote_provider_supports_file_url(tmp_path, monkeypatch):
catalog = {
"schema_version": "1.0",
"models": {"test-model": {"mode": "chat", "pricing": {"input_per_million_tokens": 1.0}}},
}
(tmp_path / "test_provider.json").write_text(json.dumps(catalog))
monkeypatch.setenv("MLFLOW_MODEL_CATALOG_URI", tmp_path.as_uri())
_get_remote_cache().clear()
result = _fetch_remote_provider("test_provider")
assert result is not None
assert "test-model" in result