diff --git a/models/openai_api_compatible/manifest.yaml b/models/openai_api_compatible/manifest.yaml index a008e8935..f2b91f8d5 100644 --- a/models/openai_api_compatible/manifest.yaml +++ b/models/openai_api_compatible/manifest.yaml @@ -1,4 +1,4 @@ -version: 0.0.65 +version: 0.0.66 type: plugin author: "langgenius" name: "openai_api_compatible" diff --git a/models/openai_api_compatible/models/rerank/rerank.py b/models/openai_api_compatible/models/rerank/rerank.py index a65dad943..d2100d13b 100644 --- a/models/openai_api_compatible/models/rerank/rerank.py +++ b/models/openai_api_compatible/models/rerank/rerank.py @@ -1,9 +1,7 @@ import base64 import ipaddress -import json import logging -import re -from typing import Mapping, Optional, Union, Any +from typing import Mapping, Optional from urllib.parse import urlparse import requests @@ -27,6 +25,26 @@ class OpenAIRerankModel(OAICompatRerankModel): + @staticmethod + def _resolve_rerank_endpoint_url(credentials: Mapping) -> str: + rerank_endpoint_url = (credentials.get("rerank_endpoint_url") or "").strip() + if rerank_endpoint_url: + return rerank_endpoint_url + + endpoint_url = credentials.get("endpoint_url", "").rstrip("/") + return f"{endpoint_url}/rerank" + + @staticmethod + def _safe_request_error_detail( + error: requests.exceptions.RequestException, + ) -> str: + if ( + isinstance(error, requests.exceptions.HTTPError) + and error.response is not None + ): + return f"HTTP {error.response.status_code}" + return type(error).__name__ + def validate_credentials(self, model: str, credentials: dict) -> None: """ Validate model credentials @@ -91,7 +109,7 @@ def _invoke( return RerankResult(model=model, docs=[]) # Build API request - endpoint_url = credentials.get("endpoint_url", "").rstrip("/") + rerank_endpoint_url = self._resolve_rerank_endpoint_url(credentials) api_key = credentials.get("api_key", "") endpoint_model_name = credentials.get("endpoint_model_name", "") or model @@ -112,20 +130,15 @@ def _invoke( } try: - logger.info(f"Rerank API Request (text mode) to {endpoint_url}/rerank") + logger.info("Rerank API Request (text mode)") response = requests.post( - f"{endpoint_url}/rerank", + rerank_endpoint_url, headers=headers, json=payload, timeout=60, ) - if response.status_code != 200: - logger.error( - f"Rerank API Error {response.status_code}: {response.text[:1000]}" - ) - response.raise_for_status() result = response.json() @@ -164,9 +177,12 @@ def _invoke( ) except requests.exceptions.RequestException as ex: - raise InvokeServerUnavailableError(str(ex)) - except Exception as ex: - raise InvokeError(str(ex)) + detail = self._safe_request_error_detail(ex) + raise InvokeServerUnavailableError( + f"Rerank API request failed ({detail})" + ) from None + except Exception: + raise InvokeError("Rerank API returned an invalid response") from None def _invoke_multimodal( self, @@ -216,7 +232,7 @@ def _invoke_multimodal( ) # Build API request - endpoint_url = credentials.get("endpoint_url", "").rstrip("/") + rerank_endpoint_url = self._resolve_rerank_endpoint_url(credentials) api_key = credentials.get("api_key", "") endpoint_model_name = credentials.get("endpoint_model_name", "") or model @@ -228,13 +244,7 @@ def _invoke_multimodal( headers["Authorization"] = f"Bearer {api_key}" # Convert documents to ScoreMultiModalParam format - documents_params = [] - for i, doc in enumerate(docs): - doc_param = self._to_score_multimodal_param(doc) - documents_params.append(doc_param) - logger.debug( - f"Document {i}: {json.dumps(doc_param, ensure_ascii=False)[:200]}" - ) + documents_params = [self._to_score_multimodal_param(doc) for doc in docs] # Build payload according to vLLM/Qwen3 format # Use `top_n if top_n is not None else len(docs)` to correctly handle top_n=0 @@ -247,24 +257,17 @@ def _invoke_multimodal( try: logger.info( - f"Rerank API Request (multimodal mode) to {endpoint_url}/rerank" + "Rerank API Request (multimodal mode): %d documents", + len(documents_params), ) - logger.info(f"Query: {query_text[:100]}") - logger.info(f"Documents count: {len(documents_params)}") - logger.debug(f"Payload: {json.dumps(payload, ensure_ascii=False)[:1000]}") response = requests.post( - f"{endpoint_url}/rerank", + rerank_endpoint_url, headers=headers, json=payload, timeout=60, ) - if response.status_code != 200: - logger.error( - f"Rerank API Error {response.status_code}: {response.text[:1000]}" - ) - response.raise_for_status() result = response.json() @@ -308,9 +311,12 @@ def _invoke_multimodal( ) except requests.exceptions.RequestException as ex: - raise InvokeServerUnavailableError(str(ex)) - except Exception as ex: - raise InvokeError(str(ex)) + detail = self._safe_request_error_detail(ex) + raise InvokeServerUnavailableError( + f"Rerank API request failed ({detail})" + ) from None + except Exception: + raise InvokeError("Rerank API returned an invalid response") from None def _validate_image_url(self, url: str) -> str: """ @@ -330,7 +336,7 @@ def _validate_image_url(self, url: str) -> str: # Only allow http/https URLs if not (url.startswith("http://") or url.startswith("https://")): - logger.warning(f"Blocked non-HTTP URL: {url[:50]}...") + logger.warning("Blocked non-HTTP image URL") return "" # Parse URL to check for SSRF attempts @@ -343,7 +349,7 @@ def _validate_image_url(self, url: str) -> str: # Block localhost if hostname in ("localhost", "127.0.0.1", "::1"): - logger.warning(f"Blocked localhost URL: {url[:50]}...") + logger.warning("Blocked localhost image URL") return "" # Block private IP ranges @@ -356,15 +362,15 @@ def _validate_image_url(self, url: str) -> str: or ip.is_link_local or ip.is_reserved ): - logger.warning(f"Blocked private IP URL: {url[:50]}...") + logger.warning("Blocked private IP image URL") return "" except ValueError: # Not an IP address, it's a hostname - allow it pass return url - except Exception as e: - logger.warning(f"URL validation failed: {e}") + except Exception: + logger.warning("Image URL validation failed") return "" def _to_score_multimodal_param(self, content: MultiModalContent) -> dict: diff --git a/models/openai_api_compatible/provider/openai_api_compatible.yaml b/models/openai_api_compatible/provider/openai_api_compatible.yaml index c229a70dd..9dcedfc91 100644 --- a/models/openai_api_compatible/provider/openai_api_compatible.yaml +++ b/models/openai_api_compatible/provider/openai_api_compatible.yaml @@ -74,6 +74,18 @@ model_credential_schema: placeholder: zh_Hans: Base URL, e.g. https://api.openai.com/v1 en_US: Base URL, e.g. https://api.openai.com/v1 + - variable: rerank_endpoint_url + label: + zh_Hans: 重排序端点 URL + en_US: Rerank Endpoint URL + type: text-input + required: false + show_on: + - variable: __model_type + value: rerank + placeholder: + zh_Hans: 可选完整 URL;留空使用 API Base URL + /rerank;请勿包含凭据或令牌 + en_US: Optional complete URL; defaults to API Base URL + /rerank; do not include credentials or tokens - variable: endpoint_model_name label: zh_Hans: API endpoint中的模型名称 diff --git a/models/openai_api_compatible/tests/test_rerank_authorization.py b/models/openai_api_compatible/tests/test_rerank_authorization.py index f6f4a0331..e634176a7 100644 --- a/models/openai_api_compatible/tests/test_rerank_authorization.py +++ b/models/openai_api_compatible/tests/test_rerank_authorization.py @@ -1,4 +1,4 @@ -"""Regression for issue #1724. +"""Regression tests for rerank request construction. The official OpenAI-API-compatible plugin rerank implementation used to emit an empty `Authorization: ` header when the API key was missing, @@ -6,13 +6,17 @@ only when an API key is truthy. These tests drive the model directly so we can capture the headers that -the implementation actually sends, without needing a real rerank -gateway. +the implementation actually sends and the URL it targets, without needing +a real rerank gateway. """ +import logging +import traceback from unittest.mock import MagicMock, patch import pytest +import requests +from dify_plugin.errors.model import InvokeError, InvokeServerUnavailableError from models.rerank.rerank import OpenAIRerankModel @@ -40,6 +44,218 @@ def _credentials(**overrides): return creds +@pytest.mark.parametrize( + "overrides", + [ + {}, + {"rerank_endpoint_url": ""}, + {"rerank_endpoint_url": " \t "}, + ], + ids=["missing", "empty", "whitespace"], +) +def test_text_rerank_uses_legacy_endpoint_when_custom_endpoint_is_empty(overrides): + model = OpenAIRerankModel(model_schemas=[]) + with patch("models.rerank.rerank.requests.post") as mock_post: + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + ) + model._invoke( + model="bge-reranker-v2-m3", + credentials=_credentials(**overrides), + query="q", + docs=["d1"], + ) + + req = _captured_request(mock_post) + assert req["url"] == "https://rerank.example.com/v1/rerank" + + +def test_text_rerank_uses_custom_endpoint_url_exactly_after_trimming(): + model = OpenAIRerankModel(model_schemas=[]) + with patch("models.rerank.rerank.requests.post") as mock_post: + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + ) + model._invoke( + model="bge-reranker-v2-m3", + credentials=_credentials( + rerank_endpoint_url=( + " https://gateway.example.com/v1/reranks/?route=qwen " + ) + ), + query="q", + docs=["d1"], + ) + + req = _captured_request(mock_post) + assert req["url"] == "https://gateway.example.com/v1/reranks/?route=qwen" + + +def test_credential_validation_uses_custom_endpoint_url(): + model = OpenAIRerankModel(model_schemas=[]) + with patch("models.rerank.rerank.requests.post") as mock_post: + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + ) + model.validate_credentials( + model="bge-reranker-v2-m3", + credentials=_credentials( + rerank_endpoint_url="https://gateway.example.com/v1/reranks" + ), + ) + + req = _captured_request(mock_post) + assert req["url"] == "https://gateway.example.com/v1/reranks" + + +def test_request_error_does_not_expose_custom_endpoint_url(): + endpoint_url = "https://user:password@gateway.example.com/v1/reranks?token=secret" + model = OpenAIRerankModel(model_schemas=[]) + with patch( + "models.rerank.rerank.requests.post", + side_effect=requests.exceptions.ConnectionError(endpoint_url), + ): + with pytest.raises(InvokeServerUnavailableError) as exc_info: + model._invoke( + model="bge-reranker-v2-m3", + credentials=_credentials(rerank_endpoint_url=endpoint_url), + query="q", + docs=["d1"], + ) + + assert str(exc_info.value) == "Rerank API request failed (ConnectionError)" + assert endpoint_url not in "".join(traceback.format_exception(exc_info.value)) + + +def test_http_error_exposes_only_status_code(caplog): + endpoint_url = "https://user:password@gateway.example.com/rerank?token=secret" + response = requests.Response() + response.status_code = 401 + response.url = endpoint_url + response.reason = "Unauthorized" + response._content = b"response-secret" + model = OpenAIRerankModel(model_schemas=[]) + caplog.set_level(logging.DEBUG, logger="models.rerank.rerank") + + with patch("models.rerank.rerank.requests.post", return_value=response): + with pytest.raises(InvokeServerUnavailableError) as exc_info: + model._invoke( + model="bge-reranker-v2-m3", + credentials=_credentials(rerank_endpoint_url=endpoint_url), + query="q", + docs=["d1"], + ) + + error_output = "".join(traceback.format_exception(exc_info.value)) + assert str(exc_info.value) == "Rerank API request failed (HTTP 401)" + assert endpoint_url not in error_output + assert "response-secret" not in error_output + caplog.text + + +@pytest.mark.parametrize("multimodal", [False, True]) +def test_invalid_response_does_not_expose_values(multimodal): + model = OpenAIRerankModel(model_schemas=[]) + response_secret = "response-secret" + response = MagicMock( + status_code=200, + json=lambda: {"results": [{"index": 0, "relevance_score": response_secret}]}, + ) + + with patch("models.rerank.rerank.requests.post", return_value=response): + with pytest.raises(InvokeError) as exc_info: + if multimodal: + from dify_plugin.entities.model.text_embedding import ( + MultiModalContent, + MultiModalContentType, + ) + + query = MultiModalContent( + content_type=MultiModalContentType.TEXT, + content="q", + ) + docs = [ + MultiModalContent( + content_type=MultiModalContentType.TEXT, + content="d1", + ) + ] + model._invoke_multimodal( + model="qwen3-vl-reranker", + credentials=_credentials(), + query=query, + docs=docs, + ) + else: + model._invoke( + model="bge-reranker-v2-m3", + credentials=_credentials(), + query="q", + docs=["d1"], + ) + + error_output = "".join(traceback.format_exception(exc_info.value)) + assert str(exc_info.value) == "Rerank API returned an invalid response" + assert response_secret not in error_output + + +def test_rerank_logs_exclude_multimodal_content_and_image_urls(caplog): + from dify_plugin.entities.model.text_embedding import ( + MultiModalContent, + MultiModalContentType, + ) + + model = OpenAIRerankModel(model_schemas=[]) + caplog.set_level(logging.DEBUG, logger="models.rerank.rerank") + query = MultiModalContent( + content_type=MultiModalContentType.TEXT, + content="query-secret", + ) + docs = [ + MultiModalContent( + content_type=MultiModalContentType.TEXT, + content="document-secret", + ), + MultiModalContent( + content_type=MultiModalContentType.IMAGE, + content="file://image-secret", + ), + ] + + with patch("models.rerank.rerank.requests.post") as mock_post: + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + ) + model._invoke_multimodal( + model="qwen3-vl-reranker", + credentials=_credentials(), + query=query, + docs=docs, + ) + + model._validate_image_url("http://localhost/localhost-secret") + model._validate_image_url("http://10.0.0.1/private-secret") + with patch( + "models.rerank.rerank.urlparse", + side_effect=ValueError("parse-secret"), + ): + model._validate_image_url("https://example.com/url-secret") + + secrets = ( + "query-secret", + "document-secret", + "image-secret", + "localhost-secret", + "private-secret", + "url-secret", + "parse-secret", + ) + assert all(secret not in caplog.text for secret in secrets) + + def test_text_rerank_omits_authorization_when_api_key_missing(): model = OpenAIRerankModel(model_schemas=[]) with patch("models.rerank.rerank.requests.post") as mock_post: @@ -148,3 +364,32 @@ def test_multimodal_rerank_includes_bearer_when_api_key_present(): ) req = _captured_request(mock_post) assert req["headers"]["Authorization"] == "Bearer sk-test-5678" + + +def test_multimodal_rerank_uses_custom_endpoint_url(): + from dify_plugin.entities.model.text_embedding import ( + MultiModalContent, + MultiModalContentType, + ) + + model = OpenAIRerankModel(model_schemas=[]) + query = MultiModalContent( + content_type=MultiModalContentType.TEXT, content="q" + ) + docs = [MultiModalContent(content_type=MultiModalContentType.TEXT, content="d1")] + with patch("models.rerank.rerank.requests.post") as mock_post: + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"results": []}, + ) + model._invoke_multimodal( + model="qwen3-vl-reranker", + credentials=_credentials( + rerank_endpoint_url="https://gateway.example.com/v1/reranks" + ), + query=query, + docs=docs, + ) + + req = _captured_request(mock_post) + assert req["url"] == "https://gateway.example.com/v1/reranks"