diff --git a/docs/docs/extraction/prerequisites-support-matrix.md b/docs/docs/extraction/prerequisites-support-matrix.md index 6efdae1b4..e782d07ab 100644 --- a/docs/docs/extraction/prerequisites-support-matrix.md +++ b/docs/docs/extraction/prerequisites-support-matrix.md @@ -187,7 +187,9 @@ When you call [NVIDIA-hosted NIMs](deployment-options.md#when-to-use-nvidia-host Local Hugging Face inference defaults to `nvidia/NVIDIA-Nemotron-Parse-v1.2`. Set `nemotron_parse_model="nvidia/NVIDIA-Nemotron-Parse-2.0"` to use Parse 2.0 locally. - To use hosted Build, set `nemotron_parse_invoke_url` to the Build chat-completions URL and set `method="nemotron_parse"`. You can normally omit `nemotron_parse_model` so the library selects the model automatically. If you set `nemotron_parse_model` explicitly, it must match the endpoint contract. Mixed Build and self-hosted endpoint lists require an explicit model. + To use hosted Build, set `nemotron_parse_invoke_url` to the Build chat-completions URL and set `method="nemotron_parse"`. You can normally omit `nemotron_parse_model` so the library selects the model automatically. If you set `nemotron_parse_model` explicitly, it must match the endpoint contract. + + Each endpoint list must contain only hosted Build endpoints or only compatible self-hosted endpoints. The library rejects a list that mixes hosted Build and self-hosted endpoints because one extraction workflow uses one model ID and request contract. Setting `nemotron_parse_model` explicitly does not make a mixed list valid. To use both deployment types, configure separate ingestors or extraction workflows for each endpoint contract. When the chart manages the Parse NIM, it wires the model that matches the selected v1.2 or v2.0 image. To select Parse v2.0, enable Parse and set `nimOperator.nemotron_parse.image.repository=nvcr.io/nim/nvidia/nemotron-parse-v2.0` and `nimOperator.nemotron_parse.image.tag=2.0.8-variant`. For a direct external v2.0 endpoint, set `nemotron_parse_model="nvidia/nemotron-parse-v2.0"` explicitly with its invoke URL. diff --git a/docs/docs/extraction/troubleshoot.md b/docs/docs/extraction/troubleshoot.md index b5395b985..3a6b5a72e 100644 --- a/docs/docs/extraction/troubleshoot.md +++ b/docs/docs/extraction/troubleshoot.md @@ -266,7 +266,9 @@ HTTP 400: Content cannot be a plain string. The model does not support text inpu This can occur when you send a versioned self-hosted model (for example `nvidia/nemotron-parse-v1.2` or `nvidia/nemotron-parse-v2.0`) to the NVIDIA-hosted Build endpoint, which expects the image-only `nvidia/nemotron-parse` contract. It can also occur when the selected self-hosted Parse image and configured model use different versions. The library may replace the raw HTTP error with a targeted model/contract mismatch hint. -To use hosted Build, omit `nemotron_parse_model` so the library selects `nvidia/nemotron-parse` automatically, or set `nemotron_parse_model="nvidia/nemotron-parse"` explicitly. Send `nvidia/nemotron-parse-v1.2` or `nvidia/nemotron-parse-v2.0` only to its matching self-hosted chat endpoint. For direct external Parse v2.0 endpoints, set `nemotron_parse_model="nvidia/nemotron-parse-v2.0"` explicitly. For more information, refer to [Nemotron Parse: hosted Build and self-hosted NIM contracts](prerequisites-support-matrix.md#nemotron-parse-hosted-vs-self-hosted). +To use hosted Build, omit `nemotron_parse_model` so the library selects `nvidia/nemotron-parse` automatically, or set `nemotron_parse_model="nvidia/nemotron-parse"` explicitly. Send `nvidia/nemotron-parse-v1.2` or `nvidia/nemotron-parse-v2.0` only to its matching self-hosted chat endpoint. For direct external Parse v2.0 endpoints, set `nemotron_parse_model="nvidia/nemotron-parse-v2.0"` explicitly. + +Do not combine hosted Build and self-hosted endpoints in one `nemotron_parse_invoke_url` list. The library rejects this configuration because one workflow cannot send different model IDs and request contracts to individual endpoints. Setting `nemotron_parse_model` does not override this restriction. Use a homogeneous endpoint list, or configure separate ingestors or extraction workflows for hosted Build and self-hosted capacity. For more information, refer to [Nemotron Parse: hosted Build and self-hosted NIM contracts](prerequisites-support-matrix.md#nemotron-parse-hosted-vs-self-hosted). ## Hosted Page Elements NIM image size limits { #hosted-page-elements-nim-image-size-limits } diff --git a/nemo_retriever/src/nemo_retriever/common/params/models.py b/nemo_retriever/src/nemo_retriever/common/params/models.py index be5cae75e..5fdeaebb9 100644 --- a/nemo_retriever/src/nemo_retriever/common/params/models.py +++ b/nemo_retriever/src/nemo_retriever/common/params/models.py @@ -25,6 +25,7 @@ ) from nemo_retriever.common.modality.caption.model_profiles import DEFAULT_LOCAL_CAPTION_MODEL_ID +from nemo_retriever.common.params.utils import validate_nemotron_parse_endpoint_list from nemo_retriever.common.remote_auth import resolve_remote_api_key IngestorRunMode = Literal["inprocess", "batch", "service"] @@ -581,6 +582,8 @@ def _auto_enable_features(self) -> "ExtractParams": "`nemotron_parse_invoke_url` and `nemotron_parse_model` require " "`method='nemotron_parse'`; Parse-specific configuration is otherwise ignored." ) + if self.method == "nemotron_parse": + validate_nemotron_parse_endpoint_list(self.nemotron_parse_invoke_url or self.invoke_url) if not self.use_page_elements: consumers = [("use_table_structure", self.use_table_structure and self.extract_tables)] enabled = [name for name, on in consumers if on] diff --git a/nemo_retriever/src/nemo_retriever/common/params/utils.py b/nemo_retriever/src/nemo_retriever/common/params/utils.py index 09417387b..08002c24d 100644 --- a/nemo_retriever/src/nemo_retriever/common/params/utils.py +++ b/nemo_retriever/src/nemo_retriever/common/params/utils.py @@ -7,11 +7,29 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict +from urllib.parse import urlsplit if TYPE_CHECKING: from nemo_retriever.common.params.models import BatchTuningParams +def validate_nemotron_parse_endpoint_list(invoke_url: str | None) -> tuple[str, ...]: + """Normalize Parse endpoints and reject mixed NVIDIA Build/self-hosted lists.""" + invoke_urls = tuple(part.strip() for part in str(invoke_url or "").split(",") if part.strip()) + build_endpoints = tuple( + (urlsplit(endpoint).hostname or "").lower() == "integrate.api.nvidia.com" for endpoint in invoke_urls + ) + if any(build_endpoints) and not all(build_endpoints): + raise ValueError( + "Nemotron Parse endpoint lists cannot mix NVIDIA Build and self-hosted endpoints. " + "One `nemotron_parse_model` and request contract apply to the entire endpoint list, but NVIDIA Build " + "requires `nvidia/nemotron-parse` with the hosted tool-call contract and self-hosted Parse requires a " + "versioned model with a tagged contract. Configure a homogeneous endpoint list or use separate " + "ingestors for NVIDIA Build and self-hosted Parse." + ) + return invoke_urls + + def coerce_params[T](params: T | None, model_cls: type[T], kwargs: dict[str, Any]) -> T: """Merge *params* and *kwargs* into an instance of *model_cls*. diff --git a/nemo_retriever/src/nemo_retriever/operators/extract/parse/nemotron_parse.py b/nemo_retriever/src/nemo_retriever/operators/extract/parse/nemotron_parse.py index 2615e90a8..695623dbf 100644 --- a/nemo_retriever/src/nemo_retriever/operators/extract/parse/nemotron_parse.py +++ b/nemo_retriever/src/nemo_retriever/operators/extract/parse/nemotron_parse.py @@ -37,6 +37,7 @@ from nemo_retriever.models.nim.chat_completions import invoke_chat_completions_images from nemo_retriever.models.nim.nim import NIMClient, invoke_image_inference_batches from nemo_retriever.common.params import RemoteRetryParams +from nemo_retriever.common.params.utils import validate_nemotron_parse_endpoint_list try: from PIL import Image @@ -178,18 +179,12 @@ def _resolve_nemotron_parse_contract( ) -> _ResolvedNemotronParseContract: """Resolve the internal request/response contract for a chat endpoint.""" - invoke_urls = [part.strip() for part in str(invoke_url or "").split(",") if part.strip()] + invoke_urls = validate_nemotron_parse_endpoint_list(invoke_url) if not invoke_urls: raise ValueError("Nemotron Parse invoke_url is required.") build_endpoints = [_is_nvidia_build_endpoint(url) for url in invoke_urls] explicit_model = str(model_name or "").strip() - if not explicit_model and any(build_endpoints) and not all(build_endpoints): - raise ValueError( - "Nemotron Parse endpoint lists cannot mix NVIDIA Build and self-hosted endpoints " - "unless `nemotron_parse_model` is set explicitly." - ) - resolved_model = explicit_model or ( NEMOTRON_PARSE_HOSTED_MODEL if all(build_endpoints) else NEMOTRON_PARSE_REMOTE_DEFAULT_MODEL ) @@ -320,6 +315,8 @@ def nemotron_parse_pages( invoke_url = str(invoke_url or "").strip() or str(kwargs.get("nemotron_parse_invoke_url") or "").strip() use_remote = bool(invoke_url) + if use_remote: + validate_nemotron_parse_endpoint_list(invoke_url) if not use_remote and model is None: raise ValueError("A local `model` is required when `invoke_url` is not provided.") @@ -531,6 +528,7 @@ def __init__( super().__init__(**kwargs) self._invoke_url = str(nemotron_parse_invoke_url or "").strip() or str(invoke_url or "").strip() self._nemotron_parse_model = nemotron_parse_model + validate_nemotron_parse_endpoint_list(self._invoke_url) self._api_key = api_key self._request_timeout_s = float(request_timeout_s) self._task_prompt = str(task_prompt) @@ -638,6 +636,7 @@ def __init__( str(nemotron_parse_invoke_url or "").strip() or str(invoke_url or "").strip() or self.DEFAULT_INVOKE_URL ) self._nemotron_parse_model = nemotron_parse_model + validate_nemotron_parse_endpoint_list(self._invoke_url) self._api_key = api_key self._request_timeout_s = float(request_timeout_s) self._task_prompt = str(task_prompt) diff --git a/nemo_retriever/tests/test_actor_operators.py b/nemo_retriever/tests/test_actor_operators.py index 21730e25f..2f1adc769 100644 --- a/nemo_retriever/tests/test_actor_operators.py +++ b/nemo_retriever/tests/test_actor_operators.py @@ -585,15 +585,44 @@ def test_contract_resolution(self, endpoint, model, expected_model, expected_pro assert contract.model == expected_model assert contract.profile.value == expected_profile - def test_contract_resolution_rejects_mixed_endpoints_without_model(self): + @pytest.mark.parametrize( + "model", + [None, "nvidia/nemotron-parse", "nvidia/nemotron-parse-v1.2"], + ) + def test_contract_resolution_rejects_mixed_endpoints(self, model): from nemo_retriever.operators.extract.parse.nemotron_parse import _resolve_nemotron_parse_contract endpoints = "https://integrate.api.nvidia.com/v1/chat/completions," "http://parse:8000/v1/chat/completions" with pytest.raises(ValueError, match="cannot mix NVIDIA Build and self-hosted"): - _resolve_nemotron_parse_contract(endpoints, None) + _resolve_nemotron_parse_contract(endpoints, model) + + def test_remote_actors_reject_mixed_endpoints_before_client_creation(self): + from nemo_retriever.operators.extract.parse.nemotron_parse import ( + NemotronParseCPUActor, + NemotronParseGPUActor, + ) - contract = _resolve_nemotron_parse_contract(endpoints, "nvidia/nemotron-parse-v1.2") - assert contract.profile.value == "v1_2_tagged" + endpoints = "https://integrate.api.nvidia.com/v1/chat/completions," "http://parse:8000/v1/chat/completions" + with patch("nemo_retriever.operators.extract.parse.nemotron_parse.NIMClient") as client: + for actor_class in (NemotronParseCPUActor, NemotronParseGPUActor): + with pytest.raises(ValueError, match="Configure a homogeneous endpoint list"): + actor_class( + nemotron_parse_invoke_url=endpoints, + nemotron_parse_model="nvidia/nemotron-parse-v1.2", + ) + + client.assert_not_called() + + def test_pages_reject_mixed_endpoints_without_input_rows(self): + from nemo_retriever.operators.extract.parse.nemotron_parse import nemotron_parse_pages + + endpoints = "https://integrate.api.nvidia.com/v1/chat/completions," "http://parse:8000/v1/chat/completions" + with pytest.raises(ValueError, match="One `nemotron_parse_model` and request contract"): + nemotron_parse_pages( + pd.DataFrame(), + invoke_url=endpoints, + nemotron_parse_model="nvidia/nemotron-parse-v1.2", + ) def test_forced_v1_2_build_text_rejection_reports_contract_mismatch(self): from nemo_retriever.operators.extract.parse.nemotron_parse import nemotron_parse_pages diff --git a/nemo_retriever/tests/test_params_models.py b/nemo_retriever/tests/test_params_models.py index 22b0a97ca..a644057b6 100644 --- a/nemo_retriever/tests/test_params_models.py +++ b/nemo_retriever/tests/test_params_models.py @@ -54,6 +54,20 @@ def test_normal_and_selected_parse_configurations_are_valid(self) -> None: ) assert params.method == "nemotron_parse" + @pytest.mark.parametrize( + "model", + [None, "nvidia/nemotron-parse", "nvidia/nemotron-parse-v1.2"], + ) + def test_mixed_build_and_self_hosted_parse_endpoints_are_rejected(self, model: str | None) -> None: + endpoints = "https://integrate.api.nvidia.com/v1/chat/completions," "http://127.0.0.1:8018/v1/chat/completions" + + with pytest.raises(ValidationError, match="cannot mix NVIDIA Build and self-hosted"): + ExtractParams( + method="nemotron_parse", + nemotron_parse_invoke_url=endpoints, + nemotron_parse_model=model, + ) + def test_graphic_elements_controls_are_removed(self) -> None: assert "use_graphic_elements" not in ExtractParams.model_fields assert "graphic_elements_invoke_url" not in ExtractParams.model_fields