diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index 2d0c43d09..d0f6a5a6b 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -74,51 +74,67 @@ async def _embedding_dimension_check( model_factory: Optional[str] = None, timeout_seconds: Optional[float] = None, ): + # For embedding types, try the user-provided URL first; if that returns + # no valid dimension, fall back to the URL with /embeddings appended. + # Some providers serve embeddings at the bare base URL while others + # require the explicit /embeddings endpoint. if model_type in EMBEDDING_TYPES: - model_base_url = _normalize_embedding_url(model_base_url) + original_url = model_base_url + normalized_url = _normalize_embedding_url(original_url) + urls_to_try = [original_url] + if normalized_url != original_url: + urls_to_try.append(normalized_url) + else: + urls_to_try = [model_base_url] effective_timeout = timeout_seconds if timeout_seconds else 5.0 - if model_type == "embedding": - # DashScope text embedding models use OpenAI-compatible endpoint, same as generic - embedding = await OpenAICompatibleEmbedding( - model_name=model_name, - base_url=model_base_url, - api_key=model_api_key, - embedding_dim=0, - ssl_verify=ssl_verify, - ).dimension_check(timeout=effective_timeout) - if len(embedding) > 0: - return len(embedding[0]) - logging.warning( - f"Embedding dimension check for {model_name} gets empty response") - return 0 - elif model_type == "multi_embedding": - model_factory_lower = (model_factory or "").lower() - if model_factory_lower == "dashscope": - embedding_instance = DashScopeMultimodalEmbedding( - api_key=model_api_key, - base_url=model_base_url, + for url in urls_to_try: + if model_type == "embedding": + # DashScope text embedding models use OpenAI-compatible endpoint, same as generic + embedding = await OpenAICompatibleEmbedding( model_name=model_name, - embedding_dim=0, - ssl_verify=ssl_verify, - ) - else: - embedding_instance = SiliconflowMultimodalEmbedding( + base_url=url, api_key=model_api_key, - base_url=model_base_url, - model_name=model_name, embedding_dim=0, ssl_verify=ssl_verify, - ) - embedding = await embedding_instance.dimension_check(timeout=effective_timeout) - if isinstance(embedding, list) and len(embedding) > 0 and isinstance(embedding[0], list): - return len(embedding[0]) + ).dimension_check(timeout=effective_timeout) + if len(embedding) > 0: + return len(embedding[0]) + elif model_type == "multi_embedding": + model_factory_lower = (model_factory or "").lower() + if model_factory_lower == "dashscope": + embedding_instance = DashScopeMultimodalEmbedding( + api_key=model_api_key, + base_url=url, + model_name=model_name, + embedding_dim=0, + ssl_verify=ssl_verify, + ) + else: + embedding_instance = SiliconflowMultimodalEmbedding( + api_key=model_api_key, + base_url=url, + model_name=model_name, + embedding_dim=0, + ssl_verify=ssl_verify, + ) + embedding = await embedding_instance.dimension_check(timeout=effective_timeout) + if isinstance(embedding, list) and len(embedding) > 0 and isinstance(embedding[0], list): + return len(embedding[0]) + else: + raise ValueError(f"Unsupported model type: {model_type}") + + # All URL variants failed + if model_type == "embedding": logging.warning( - f"Embedding dimension check for {model_name} gets unexpected response: {type(embedding)}, value: {embedding}") - return 0 - else: - raise ValueError(f"Unsupported model type: {model_type}") + f"Embedding dimension check for {model_name} gets empty response") + elif model_type == "multi_embedding": + logging.warning( + f"Embedding dimension check for {model_name} gets unexpected response") + return 0 + + async def _provider_catalog_connectivity_check( @@ -175,42 +191,56 @@ async def _perform_connectivity_check( model_base_url = model_base_url.replace( LOCALHOST_NAME, DOCKER_INTERNAL_HOST).replace(LOCALHOST_IP, DOCKER_INTERNAL_HOST) - # Normalize embedding URLs by appending /embeddings if not present + # For embedding types, try the user-provided URL first; if that fails, + # fall back to the URL with /embeddings appended. Some providers serve + # embeddings at the bare base URL while others require the explicit endpoint. if model_type in EMBEDDING_TYPES: - model_base_url = _normalize_embedding_url(model_base_url) + original_url = model_base_url + normalized_url = _normalize_embedding_url(model_base_url) + urls_to_try = [original_url] + if normalized_url != original_url: + urls_to_try.append(normalized_url) + else: + urls_to_try = [model_base_url] effective_timeout = timeout_seconds if timeout_seconds else 5.0 - connectivity: bool + connectivity: bool = False if model_type == "embedding": - emb = await OpenAICompatibleEmbedding( - model_name=model_name, - base_url=model_base_url, - api_key=model_api_key, - embedding_dim=0, - ssl_verify=ssl_verify, - ).dimension_check(timeout=effective_timeout) - connectivity = len(emb) > 0 and len(emb[0]) > 0 - elif model_type == "multi_embedding": - model_factory_lower = (model_factory or "").lower() - if model_factory_lower == "dashscope": - embedding = DashScopeMultimodalEmbedding( - api_key=model_api_key, - base_url=model_base_url, + for url in urls_to_try: + emb = await OpenAICompatibleEmbedding( model_name=model_name, - embedding_dim=0, - ssl_verify=ssl_verify, - ) - else: - embedding = SiliconflowMultimodalEmbedding( + base_url=url, api_key=model_api_key, - base_url=model_base_url, - model_name=model_name, embedding_dim=0, ssl_verify=ssl_verify, - ) - emb = await embedding.dimension_check(timeout=effective_timeout) - connectivity = len(emb) > 0 and len(emb[0]) > 0 + ).dimension_check(timeout=effective_timeout) + if len(emb) > 0 and len(emb[0]) > 0: + connectivity = True + break + elif model_type == "multi_embedding": + model_factory_lower = (model_factory or "").lower() + for url in urls_to_try: + if model_factory_lower == "dashscope": + embedding = DashScopeMultimodalEmbedding( + api_key=model_api_key, + base_url=url, + model_name=model_name, + embedding_dim=0, + ssl_verify=ssl_verify, + ) + else: + embedding = SiliconflowMultimodalEmbedding( + api_key=model_api_key, + base_url=url, + model_name=model_name, + embedding_dim=0, + ssl_verify=ssl_verify, + ) + emb = await embedding.dimension_check(timeout=effective_timeout) + if len(emb) > 0 and len(emb[0]) > 0: + connectivity = True + break elif model_type == "llm": observer = MessageObserver() set_monitoring_operation("connectivity_check", diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py index f2594563e..0044db2b1 100644 --- a/backend/services/model_management_service.py +++ b/backend/services/model_management_service.py @@ -324,17 +324,25 @@ async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict raise ValueError( f"Name {model_data['display_name']} is already in use, please choose another display name") - # If embedding or multi_embedding, ensure base_url ends with /embeddings + # If embedding or multi_embedding, verify connectivity and get dimension. + # Try the user-provided URL first; if that fails, fall back to + # appending /embeddings (some providers serve embeddings at the + # bare base URL while others require the explicit endpoint). if model_data.get("model_type") in ("embedding", "multi_embedding"): base_url = model_data.get("base_url", "") - if base_url and "/embeddings" not in base_url: - model_data["base_url"] = f"{base_url.rstrip('/')}/embeddings" # Infer model_factory from base_url if not set model_data["model_factory"] = _infer_model_factory( model_data["model_type"], model_data["base_url"], model_data.get("model_factory") ) - # Get embedding dimension + # Try original URL first dimension = await embedding_dimension_check(model_data) + # If failed and URL doesn't already contain /embeddings, retry with it appended + if dimension is None and base_url and "/embeddings" not in base_url: + model_data["base_url"] = f"{base_url.rstrip('/')}/embeddings" + model_data["model_factory"] = _infer_model_factory( + model_data["model_type"], model_data["base_url"], model_data.get("model_factory") + ) + dimension = await embedding_dimension_check(model_data) if dimension is None: raise ValueError( f"Failed to get embedding dimension for model '{model_data.get('display_name', model_data.get('model_name'))}'. " diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index 0dcd5bd9c..dac333dba 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -97,15 +97,420 @@ def __init__(self, code, message="", data=None): ) + +# --------------------------------------------------------------------------- +# Tests for _embedding_dimension_check URL fallback logic (NEW) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_edc_original_url_succeeds_no_fallback(): + """_embedding_dimension_check: original URL succeeds, no fallback needed.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + mock_inst = mock.MagicMock() + mock_inst.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2, 0.3]]) + mock_cls.return_value = mock_inst + + dim = await _embedding_dimension_check( + "m", "embedding", "http://127.0.0.1:8794/embed", "k") + + assert dim == 3 + assert mock_cls.call_count == 1 + mock_cls.assert_called_with( + model_name="m", base_url="http://127.0.0.1:8794/embed", + api_key="k", embedding_dim=0, ssl_verify=True) + + +@pytest.mark.asyncio +async def test_edc_fallback_to_embeddings_url(): + """_embedding_dimension_check: original fails, /embeddings appended succeeds.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst_fail = mock.MagicMock() + inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + inst_ok = mock.MagicMock() + inst_ok.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2]]) + mock_cls.side_effect = [inst_fail, inst_ok] + + dim = await _embedding_dimension_check( + "m", "embedding", "https://api.openai.com/v1", "k") + + assert dim == 2 + assert mock_cls.call_count == 2 + mock_cls.assert_any_call( + model_name="m", base_url="https://api.openai.com/v1", + api_key="k", embedding_dim=0, ssl_verify=True) + mock_cls.assert_any_call( + model_name="m", base_url="https://api.openai.com/v1/embeddings", + api_key="k", embedding_dim=0, ssl_verify=True) + + +@pytest.mark.asyncio +async def test_edc_both_urls_fail_returns_zero(): + """_embedding_dimension_check: both URLs fail -> returns 0.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[]) + mock_cls.return_value = inst + + dim = await _embedding_dimension_check( + "m", "embedding", "https://api.openai.com/v1", "k") + + assert dim == 0 + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_edc_url_already_has_embeddings_single_try(): + """_embedding_dimension_check: URL already has /embeddings -> only one try.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2, 0.3, 0.4]]) + mock_cls.return_value = inst + + dim = await _embedding_dimension_check( + "m", "embedding", "https://api.openai.com/v1/embeddings", "k") + + assert dim == 4 + assert mock_cls.call_count == 1 + + +@pytest.mark.asyncio +async def test_edc_multi_embedding_fallback(): + """_embedding_dimension_check multi_embedding: original fails, normalized succeeds.""" + with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_cls: + inst_fail = mock.MagicMock() + inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + inst_ok = mock.MagicMock() + inst_ok.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2, 0.3]]) + mock_cls.side_effect = [inst_fail, inst_ok] + + dim = await _embedding_dimension_check( + "m", "multi_embedding", "https://api.siliconflow.cn/v1", "k", + model_factory="silicon") + + assert dim == 3 + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_edc_multi_embedding_dashscope_fallback(): + """_embedding_dimension_check multi_embedding dashscope: original fails, normalized succeeds.""" + with mock.patch("backend.services.model_health_service.DashScopeMultimodalEmbedding") as mock_cls: + inst_fail = mock.MagicMock() + inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + inst_ok = mock.MagicMock() + inst_ok.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2]]) + mock_cls.side_effect = [inst_fail, inst_ok] + + dim = await _embedding_dimension_check( + "m", "multi_embedding", "https://dashscope.aliyuncs.com/v1", "k", + model_factory="dashscope") + + assert dim == 2 + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_edc_multi_embedding_both_fail(): + """_embedding_dimension_check multi_embedding: both URLs fail -> returns 0.""" + with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[]) + mock_cls.return_value = inst + + dim = await _embedding_dimension_check( + "m", "multi_embedding", "https://api.siliconflow.cn/v1", "k") + + assert dim == 0 + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_edc_custom_timeout_used(): + """_embedding_dimension_check: custom timeout_seconds is forwarded.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[[0.1]]) + mock_cls.return_value = inst + + dim = await _embedding_dimension_check( + "m", "embedding", "https://api.openai.com/v1/embeddings", "k", + timeout_seconds=15.0) + + assert dim == 1 + inst.dimension_check.assert_called_once_with(timeout=15.0) + + +@pytest.mark.asyncio +async def test_edc_unsupported_type_raises(): + """_embedding_dimension_check: unsupported model_type raises ValueError.""" + with pytest.raises(ValueError, match="Unsupported model type"): + await _embedding_dimension_check( + "m", "unsupported", "https://api.example.com", "k") + + +# --------------------------------------------------------------------------- +# Tests for _perform_connectivity_check URL fallback logic (NEW) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_pcc_embedding_original_url_succeeds(): + """_perform_connectivity_check embedding: original URL succeeds, no fallback.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2]]) + mock_cls.return_value = inst + + result = await _perform_connectivity_check( + "m", "embedding", "http://proxy.local:8794/embed", "k") + + assert result is True + assert mock_cls.call_count == 1 + mock_cls.assert_called_with( + model_name="m", base_url="http://proxy.local:8794/embed", + api_key="k", embedding_dim=0, ssl_verify=True) + + +@pytest.mark.asyncio +async def test_pcc_embedding_fallback_succeeds(): + """_perform_connectivity_check embedding: original fails, normalized succeeds.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst_fail = mock.MagicMock() + inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + inst_ok = mock.MagicMock() + inst_ok.dimension_check = mock.AsyncMock(return_value=[[0.1]]) + mock_cls.side_effect = [inst_fail, inst_ok] + + result = await _perform_connectivity_check( + "m", "embedding", "https://api.openai.com/v1", "k") + + assert result is True + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_pcc_embedding_both_urls_fail(): + """_perform_connectivity_check embedding: both URLs fail -> False.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[]) + mock_cls.return_value = inst + + result = await _perform_connectivity_check( + "m", "embedding", "https://api.openai.com/v1", "k") + + assert result is False + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_pcc_embedding_url_already_has_embeddings(): + """_perform_connectivity_check embedding: URL has /embeddings -> single try.""" + with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2]]) + mock_cls.return_value = inst + + result = await _perform_connectivity_check( + "m", "embedding", "https://api.openai.com/v1/embeddings", "k") + + assert result is True + assert mock_cls.call_count == 1 + + +@pytest.mark.asyncio +async def test_pcc_multi_embedding_fallback_succeeds(): + """_perform_connectivity_check multi_embedding: original fails, normalized succeeds.""" + with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_cls: + inst_fail = mock.MagicMock() + inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + inst_ok = mock.MagicMock() + inst_ok.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2]]) + mock_cls.side_effect = [inst_fail, inst_ok] + + result = await _perform_connectivity_check( + "m", "multi_embedding", "https://api.siliconflow.cn/v1", "k") + + assert result is True + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_pcc_multi_embedding_both_fail(): + """_perform_connectivity_check multi_embedding: both URLs fail -> False.""" + with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_cls: + inst = mock.MagicMock() + inst.dimension_check = mock.AsyncMock(return_value=[]) + mock_cls.return_value = inst + + result = await _perform_connectivity_check( + "m", "multi_embedding", "https://api.siliconflow.cn/v1", "k") + + assert result is False + assert mock_cls.call_count == 2 + + +@pytest.mark.asyncio +async def test_pcc_multi_embedding_dashscope_fallback(): + """_perform_connectivity_check multi_embedding dashscope: fallback with URL verification.""" + with mock.patch("backend.services.model_health_service.DashScopeMultimodalEmbedding") as mock_cls: + inst_fail = mock.MagicMock() + inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + inst_ok = mock.MagicMock() + inst_ok.dimension_check = mock.AsyncMock(return_value=[[0.1, 0.2, 0.3]]) + mock_cls.side_effect = [inst_fail, inst_ok] + + result = await _perform_connectivity_check( + "m", "multi_embedding", "https://dashscope.aliyuncs.com/v1", "k", + model_factory="dashscope") + + assert result is True + assert mock_cls.call_count == 2 + calls = mock_cls.call_args_list + assert calls[0].kwargs["base_url"] == "https://dashscope.aliyuncs.com/v1" + assert calls[1].kwargs["base_url"] == "https://dashscope.aliyuncs.com/v1/embeddings" + + +# --------------------------------------------------------------------------- +# Tests for embedding_dimension_check public wrapper URL+SSL interaction (NEW) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_edc_public_url_fallback_via_inner(): + """embedding_dimension_check: inner handles URL fallback, wrapper gets dimension.""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_inner, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_name: + mock_name.return_value = "m" + mock_inner.return_value = 1536 + + dim = await embedding_dimension_check({ + "model_name": "m", "model_type": "embedding", + "base_url": "https://api.openai.com/v1", "api_key": "k", + "ssl_verify": True}) + + assert dim == 1536 + mock_inner.assert_called_once() + + +@pytest.mark.asyncio +async def test_edc_public_ssl_fallback_after_url_fallback_fails(): + """embedding_dimension_check: ssl=True returns 0, ssl=False returns dimension.""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_inner, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_name: + mock_name.return_value = "m" + mock_inner.side_effect = [0, 768] + + dim = await embedding_dimension_check({ + "model_name": "m", "model_type": "embedding", + "base_url": "https://api.openai.com/v1/embeddings", "api_key": "k", + "ssl_verify": True}) + + assert dim == 768 + assert mock_inner.call_count == 2 + mock_inner.assert_any_call( + "m", "embedding", "https://api.openai.com/v1/embeddings", "k", True, + model_factory=None, timeout_seconds=None) + mock_inner.assert_any_call( + "m", "embedding", "https://api.openai.com/v1/embeddings", "k", False, + model_factory=None, timeout_seconds=None) + + +@pytest.mark.asyncio +async def test_edc_public_all_fail_returns_none(): + """embedding_dimension_check: all attempts fail -> None.""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_inner, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_name: + mock_name.return_value = "m" + mock_inner.return_value = 0 + + dim = await embedding_dimension_check({ + "model_name": "m", "model_type": "embedding", + "base_url": "https://api.openai.com/v1/embeddings", "api_key": "k", + "ssl_verify": True}) + + assert dim is None + assert mock_inner.call_count == 2 + + +@pytest.mark.asyncio +async def test_edc_public_no_ssl_fallback_when_ssl_false(): + """embedding_dimension_check: ssl_verify=False -> single attempt.""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_inner, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_name: + mock_name.return_value = "m" + mock_inner.return_value = 0 + + dim = await embedding_dimension_check({ + "model_name": "m", "model_type": "embedding", + "base_url": "https://api.openai.com/v1/embeddings", "api_key": "k", + "ssl_verify": False}) + + assert dim is None + assert mock_inner.call_count == 1 + + +@pytest.mark.asyncio +async def test_edc_public_value_error_returns_none(): + """embedding_dimension_check: ValueError -> None.""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_inner, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_name: + mock_name.return_value = "m" + mock_inner.side_effect = ValueError("bad") + + dim = await embedding_dimension_check({ + "model_name": "m", "model_type": "embedding", + "base_url": "u", "api_key": "k", "ssl_verify": True}) + + assert dim is None + + +@pytest.mark.asyncio +async def test_edc_public_general_exception_returns_none(): + """embedding_dimension_check: general exception -> None.""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_inner, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_name: + mock_name.return_value = "m" + mock_inner.side_effect = RuntimeError("boom") + + dim = await embedding_dimension_check({ + "model_name": "m", "model_type": "embedding", + "base_url": "u", "api_key": "k", "ssl_verify": True}) + + assert dim is None + + +# --------------------------------------------------------------------------- +# Tests for verify_model_config_connectivity embedding (NEW) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_vmcc_embedding_ssl_fallback(): + """verify_model_config_connectivity: embedding ssl_verify fallback works.""" + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_conn: + mock_conn.side_effect = [False, True] + + result = await verify_model_config_connectivity({ + "model_name": "emb-model", "model_type": "embedding", + "base_url": "https://api.openai.com/v1/embeddings", + "api_key": "k", "ssl_verify": True}) + + assert result["connectivity"] is True + assert mock_conn.call_count == 2 + + +# --------------------------------------------------------------------------- +# End of NEW tests +# --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_perform_connectivity_check_embedding(): - # Setup + # Setup - now tries original URL first, then falls back to /embeddings with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_embedding: - mock_embedding_instance = mock.MagicMock() - mock_embedding_instance.dimension_check = mock.AsyncMock(return_value=[ - [1] - ]) - mock_embedding.return_value = mock_embedding_instance + # First call (original URL) fails, second call (/embeddings) succeeds + mock_inst_fail = mock.MagicMock() + mock_inst_fail.dimension_check = mock.AsyncMock(return_value=[]) + mock_inst_ok = mock.MagicMock() + mock_inst_ok.dimension_check = mock.AsyncMock(return_value=[[1]]) + mock_embedding.side_effect = [mock_inst_fail, mock_inst_ok] # Execute result = await _perform_connectivity_check( @@ -117,19 +522,28 @@ async def test_perform_connectivity_check_embedding(): # Assert assert result is True - mock_embedding.assert_called_once_with( + assert mock_embedding.call_count == 2 + # First call with original URL + mock_embedding.assert_any_call( + model_name="text-embedding-ada-002", + base_url="https://api.openai.com", + api_key="test-key", + embedding_dim=0, + ssl_verify=True, + ) + # Second call with /embeddings appended + mock_embedding.assert_any_call( model_name="text-embedding-ada-002", base_url="https://api.openai.com/embeddings", api_key="test-key", embedding_dim=0, ssl_verify=True, ) - mock_embedding_instance.dimension_check.assert_called_once() @pytest.mark.asyncio async def test_perform_connectivity_check_multi_embedding(): - # Setup + # Setup - URL already has /embeddings, so original URL succeeds on first try with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_embedding: mock_embedding_instance = mock.MagicMock() mock_embedding_instance.dimension_check = mock.AsyncMock(return_value=[ @@ -137,11 +551,11 @@ async def test_perform_connectivity_check_multi_embedding(): ]) mock_embedding.return_value = mock_embedding_instance - # Execute + # Execute - use URL that already has /embeddings result = await _perform_connectivity_check( "jina-embeddings-v2", "multi_embedding", - "https://api.jina.ai", + "https://api.jina.ai/embeddings", "test-key", ) @@ -690,6 +1104,7 @@ async def test_save_config_with_error(): @pytest.mark.asyncio async def test_embedding_dimension_check_embedding_success(): + # URL already has /embeddings, so original URL succeeds on first try with mock.patch("backend.services.model_health_service.OpenAICompatibleEmbedding") as mock_embedding: mock_embedding_instance = mock.MagicMock() mock_embedding_instance.dimension_check = mock.AsyncMock( @@ -697,7 +1112,7 @@ async def test_embedding_dimension_check_embedding_success(): mock_embedding.return_value = mock_embedding_instance dimension = await _embedding_dimension_check( - "test-embedding", "embedding", "http://test.com", "test-key" + "test-embedding", "embedding", "http://test.com/embeddings", "test-key" ) assert dimension == 3 mock_embedding.assert_called_once_with( @@ -711,6 +1126,7 @@ async def test_embedding_dimension_check_embedding_success(): @pytest.mark.asyncio async def test_embedding_dimension_check_multi_embedding_success(): + # URL already has /embeddings, so original URL succeeds on first try with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_embedding: mock_embedding_instance = mock.MagicMock() mock_embedding_instance.dimension_check = mock.AsyncMock( @@ -718,7 +1134,7 @@ async def test_embedding_dimension_check_multi_embedding_success(): mock_embedding.return_value = mock_embedding_instance dimension = await _embedding_dimension_check( - "test-multi-embedding", "multi_embedding", "http://test.com", "test-key" + "test-multi-embedding", "multi_embedding", "http://test.com/embeddings", "test-key" ) assert dimension == 4 mock_embedding.assert_called_once_with( @@ -796,7 +1212,7 @@ async def test_embedding_dimension_check_wrapper_exception(): @pytest.mark.asyncio async def test_embedding_dimension_check_multi_embedding_empty_response(): - """Test multi_embedding dimension check with an empty response.""" + """Test multi_embedding dimension check with an empty response - both URLs fail.""" with mock.patch("backend.services.model_health_service.SiliconflowMultimodalEmbedding") as mock_embedding, \ mock.patch("backend.services.model_health_service.logging") as mock_logging: mock_embedding_instance = mock.MagicMock() @@ -809,13 +1225,8 @@ async def test_embedding_dimension_check_multi_embedding_empty_response(): ) assert dimension == 0 - mock_embedding.assert_called_once_with( - api_key="test-key", - base_url="http://test.com/embeddings", - model_name="test-multi-embedding", - embedding_dim=0, - ssl_verify=True, - ) + # Now tries both original URL and /embeddings URL + assert mock_embedding.call_count == 2 mock_logging.warning.assert_called_once() diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py index d45740923..1f1e2e1d8 100644 --- a/test/backend/services/test_model_management_service.py +++ b/test/backend/services/test_model_management_service.py @@ -2144,3 +2144,187 @@ def test_record_capacity_suggestion_accept_labels_counter(): counter.add.assert_called_once_with( 1, {"match_kind": "catalog_fuzzy", "provider": "dashscope"} ) + + +# --------------------------------------------------------------------------- +# Tests for create_model_for_tenant embedding URL fallback logic (NEW) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_cmt_embedding_original_url_succeeds_no_fallback(): + """create_model_for_tenant: embedding original URL succeeds -> stored with original URL.""" + svc = import_svc() + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock.AsyncMock(return_value=768)) as mock_dim, \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("", "test-emb")), \ + mock.patch.object(svc, "_infer_model_factory", return_value=None): + + # Use non-localhost URL to avoid automatic replacement + model_data = { + "model_name": "test-emb", + "display_name": "Test Emb", + "base_url": "http://proxy.local:8794/embed", + "model_type": "embedding", + "api_key": "k", + } + + await svc.create_model_for_tenant("u1", "t1", model_data) + + # embedding_dimension_check called only once (original URL succeeded) + assert mock_dim.call_count == 1 + # Model stored with original URL + created = mock_create.call_args[0][0] + assert created["base_url"] == "http://proxy.local:8794/embed" + assert created["max_tokens"] == 768 + + +@pytest.mark.asyncio +async def test_cmt_embedding_fallback_to_embeddings_url(): + """create_model_for_tenant: original URL fails, /embeddings appended succeeds.""" + svc = import_svc() + + # First call returns None (fail), second call returns dimension + mock_dim = mock.AsyncMock(side_effect=[None, 1024]) + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock_dim), \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("", "test-emb")), \ + mock.patch.object(svc, "_infer_model_factory", return_value=None): + + model_data = { + "model_name": "test-emb", + "display_name": "Test Emb", + "base_url": "https://api.openai.com/v1", + "model_type": "embedding", + "api_key": "k", + } + + await svc.create_model_for_tenant("u1", "t1", model_data) + + # embedding_dimension_check called twice + assert mock_dim.call_count == 2 + # Model stored with /embeddings appended URL + created = mock_create.call_args[0][0] + assert created["base_url"] == "https://api.openai.com/v1/embeddings" + assert created["max_tokens"] == 1024 + + +@pytest.mark.asyncio +async def test_cmt_embedding_url_already_has_embeddings_no_fallback(): + """create_model_for_tenant: URL already has /embeddings -> no fallback attempted.""" + svc = import_svc() + + mock_dim = mock.AsyncMock(return_value=512) + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock_dim), \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("", "test-emb")), \ + mock.patch.object(svc, "_infer_model_factory", return_value=None): + + model_data = { + "model_name": "test-emb", + "display_name": "Test Emb", + "base_url": "https://api.openai.com/v1/embeddings", + "model_type": "embedding", + "api_key": "k", + } + + await svc.create_model_for_tenant("u1", "t1", model_data) + + # Only one call since URL already has /embeddings + assert mock_dim.call_count == 1 + created = mock_create.call_args[0][0] + assert created["base_url"] == "https://api.openai.com/v1/embeddings" + + +@pytest.mark.asyncio +async def test_cmt_embedding_both_urls_fail_raises(): + """create_model_for_tenant: both URLs fail -> raises Exception.""" + svc = import_svc() + + mock_dim = mock.AsyncMock(return_value=None) + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock_dim), \ + mock.patch.object(svc, "split_repo_name", return_value=("", "test-emb")), \ + mock.patch.object(svc, "_infer_model_factory", return_value=None): + + model_data = { + "model_name": "test-emb", + "display_name": "Test Emb", + "base_url": "https://api.openai.com/v1", + "model_type": "embedding", + "api_key": "k", + } + + with pytest.raises(Exception) as exc: + await svc.create_model_for_tenant("u1", "t1", model_data) + assert "Failed to get embedding dimension" in str(exc.value) + # Called twice: original + fallback + assert mock_dim.call_count == 2 + + +@pytest.mark.asyncio +async def test_cmt_multi_embedding_fallback_to_embeddings_url(): + """create_model_for_tenant: multi_embedding fallback to /embeddings works.""" + svc = import_svc() + + mock_dim = mock.AsyncMock(side_effect=[None, 2048]) + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock_dim), \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("", "clip-emb")), \ + mock.patch.object(svc, "_infer_model_factory", return_value=None): + + model_data = { + "model_name": "clip-emb", + "display_name": "Clip Emb", + "base_url": "https://api.siliconflow.cn/v1", + "model_type": "multi_embedding", + "api_key": "k", + } + + await svc.create_model_for_tenant("u1", "t1", model_data) + + assert mock_dim.call_count == 2 + # multi_embedding creates two records + assert mock_create.call_count == 2 + created = mock_create.call_args_list[0][0][0] + assert created["base_url"] == "https://api.siliconflow.cn/v1/embeddings" + assert created["max_tokens"] == 2048 + + +@pytest.mark.asyncio +async def test_cmt_embedding_fallback_reinfers_model_factory(): + """create_model_for_tenant: model_factory is re-inferred after URL fallback.""" + svc = import_svc() + + mock_dim = mock.AsyncMock(side_effect=[None, 1536]) + + with mock.patch.object(svc, "get_models_by_display_name", return_value=[]), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock_dim), \ + mock.patch.object(svc, "create_model_record"), \ + mock.patch.object(svc, "split_repo_name", return_value=("", "test-emb")), \ + mock.patch.object(svc, "_infer_model_factory", side_effect=[None, "dashscope"]) as mock_infer: + + model_data = { + "model_name": "test-emb", + "display_name": "Test Emb", + "base_url": "https://dashscope.aliyuncs.com/v1", + "model_type": "embedding", + "api_key": "k", + } + + await svc.create_model_for_tenant("u1", "t1", model_data) + + # _infer_model_factory called twice: once for original URL, once for fallback URL + assert mock_infer.call_count == 2 + # Second call with fallback URL + second_call_url = mock_infer.call_args_list[1][0][1] + assert second_call_url == "https://dashscope.aliyuncs.com/v1/embeddings" +