Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion nemoguardrails/actions/action_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from langchain_core.runnables import Runnable

from nemoguardrails import utils
from nemoguardrails.actions.llm.utils import LLMCallException
from nemoguardrails.exceptions import LLMCallException
from nemoguardrails.logging.callbacks import logging_callbacks

log = logging.getLogger(__name__)
Expand Down
80 changes: 67 additions & 13 deletions nemoguardrails/actions/llm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,23 @@
reasoning_trace_var,
tool_calls_var,
)
from nemoguardrails.exceptions import LLMCallException
from nemoguardrails.integrations.langchain.message_utils import dicts_to_messages
from nemoguardrails.logging.callbacks import logging_callbacks
from nemoguardrails.logging.explain import LLMCallInfo


class LLMCallException(Exception):
"""A wrapper around the LLM call invocation exception.

This is used to propagate the exception out of the `generate_async` call (the default behavior is to
catch it and return an "Internal server error." message.
"""

def __init__(self, inner_exception: Any):
super().__init__(f"LLM Call Exception: {str(inner_exception)}")
self.inner_exception = inner_exception
# Since different providers have different attributes for the base URL, we'll use this list
# to attempt to extract the base URL from a `BaseLanguageModel` instance.
BASE_URL_ATTRIBUTES = [
"api_base",
"api_host",
"azure_endpoint",
"base_url",
"endpoint",
"endpoint_url",
"openai_api_base",
"server_url",
]


def _infer_provider_from_module(llm: BaseLanguageModel) -> Optional[str]:
Expand Down Expand Up @@ -209,6 +211,58 @@ def _prepare_callbacks(
return logging_callbacks


def _raise_llm_call_exception(
exception: Exception,
llm: Union[BaseLanguageModel, Runnable],
) -> None:
"""Raise an LLMCallException with enriched context about the failed invocation.

Args:
exception: The original exception that occurred
llm: The LLM instance that was being invoked

Raises:
LLMCallException with context message including model name and endpoint
"""
# Extract model name from context
llm_call_info = llm_call_info_var.get()
model_name = (
llm_call_info.llm_model_name
if llm_call_info
else _infer_model_name(llm)
if isinstance(llm, BaseLanguageModel)
else ""
)

# Extract endpoint URL from the LLM instance
endpoint_url = None
for attr in BASE_URL_ATTRIBUTES:
if hasattr(llm, attr):
value = getattr(llm, attr, None)
if value:
endpoint_url = str(value)
break

# If we didn't find endpoint URL, check the nested client object.
if not endpoint_url and hasattr(llm, "client"):
client = getattr(llm, "client", None)
if client and hasattr(client, "base_url"):
endpoint_url = str(client.base_url)

# Build context message with model and endpoint info
context_parts = []
if model_name:
context_parts.append(f"model={model_name}")
if endpoint_url:
context_parts.append(f"endpoint={endpoint_url}")

if context_parts:
context_message = f"Error invoking LLM ({', '.join(context_parts)})"
raise LLMCallException(exception, context_message=context_message)
else:
raise LLMCallException(exception)


async def _invoke_with_string_prompt(
llm: Union[BaseLanguageModel, Runnable],
prompt: str,
Expand All @@ -218,7 +272,7 @@ async def _invoke_with_string_prompt(
try:
return await llm.ainvoke(prompt, config=RunnableConfig(callbacks=callbacks))
except Exception as e:
raise LLMCallException(e)
_raise_llm_call_exception(e, llm)


async def _invoke_with_message_list(
Expand All @@ -232,7 +286,7 @@ async def _invoke_with_message_list(
try:
return await llm.ainvoke(messages, config=RunnableConfig(callbacks=callbacks))
except Exception as e:
raise LLMCallException(e)
_raise_llm_call_exception(e, llm)


def _convert_messages_to_langchain_format(prompt: List[dict]) -> List:
Expand Down
70 changes: 70 additions & 0 deletions nemoguardrails/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Any, Optional

__all__ = [
"ConfigurationError",
"InvalidModelConfigurationError",
"InvalidRailsConfigurationError",
"LLMCallException",
]


class ConfigurationError(ValueError):
"""
Base class for Guardrails Configuration validation errors.
"""

pass


class InvalidModelConfigurationError(ConfigurationError):
"""Raised when a guardrail configuration's model is invalid."""

pass


class InvalidRailsConfigurationError(ConfigurationError):
"""Raised when rails configuration is invalid.

Examples:
- Input/output rail references a model that doesn't exist in config
- Rail references a flow that doesn't exist
- Missing required prompt template
- Invalid rail parameters
"""

pass


class LLMCallException(Exception):
"""A wrapper around the LLM call invocation exception.

This is used to propagate the exception out of the `generate_async` call. The default behavior is to
catch it and return an "Internal server error." message.
"""

def __init__(self, inner_exception: Any, context_message: Optional[str] = None):
"""Initialize LLMCallException.

Args:
inner_exception: The original exception that occurred
context_message: Optional context to prepend (for example, the model name or endpoint)
"""
message = f"{context_message or 'LLM Call Exception'}: {str(inner_exception)}"
super().__init__(message)

self.inner_exception = inner_exception
self.context_message = context_message
6 changes: 3 additions & 3 deletions nemoguardrails/llm/models/langchain_initializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,13 @@ def init_langchain_model(
initializers: list[ModelInitializer] = [
# Try special case handlers first (handles both chat and text)
ModelInitializer(_handle_model_special_cases, ["chat", "text"]),
# FIXME: is text and chat a good idea?
# For text mode, use text completion, we are using both text and chat as the last resort
ModelInitializer(_init_text_completion_model, ["text", "chat"]),
Comment on lines +145 to +147
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: text completion now runs before chat models - verify this doesn't break existing chat configurations that expect chat completion to be tried first. the FIXME suggests uncertainty about this ordering.

# For chat mode, first try the standard chat completion API
ModelInitializer(_init_chat_completion_model, ["chat"]),
# For chat mode, fall back to community chat models
ModelInitializer(_init_community_chat_models, ["chat"]),
Comment on lines +145 to 151
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: reordering changes fallback behavior - text completion now runs before chat/community models. FIXME comment suggests uncertainty. verify this doesn't break existing configurations expecting chat completion first

# FIXME: is text and chat a good idea?
# For text mode, use text completion, we are using both text and chat as the last resort
ModelInitializer(_init_text_completion_model, ["text", "chat"]),
Comment on lines -149 to -151
Copy link
Contributor Author

@JashG JashG Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NeMo Guardrails MS uses a custom chat client for main models. We often face this generic error:

nemoguardrails.llm.models.langchain_initializer.ModelInitializationError: Failed to initialize model 'meta/llama-3.3-70b-instruct' with provider 'nimchat' in 'chat' mode: Could not find LLM provider 'nimchat'

However, the relevant exception was thrown by our custom class, but gets swallowed here by the exception thrown by _init_text_completion_model.

I repositioned _init_text_completion_model to come before ModelInitializer(_init_community_chat_models, ["chat"]), to ensure the relevant exception is bubbled up. Let me know if there are any issues with this.

]

# Track the last exception for better error reporting
Expand Down
70 changes: 48 additions & 22 deletions nemoguardrails/rails/llm/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
from nemoguardrails.colang.v1_0.runtime.flows import _normalize_flow_id
from nemoguardrails.colang.v2_x.lang.utils import format_colang_parsing_error_message
from nemoguardrails.colang.v2_x.runtime.errors import ColangParsingError
from nemoguardrails.exceptions import (
InvalidModelConfigurationError,
InvalidRailsConfigurationError,
)
from nemoguardrails.llm.types import Task

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -144,8 +148,8 @@ def set_and_validate_model(cls, data: Any) -> Any:
model_from_params = parameters.get("model_name") or parameters.get("model")

if model_field and model_from_params:
raise ValueError(
"Model name must be specified in exactly one place: either in the 'model' field or in parameters, not both."
raise InvalidModelConfigurationError(
f"Model name must be specified in exactly one place: either the `model` field, or in `parameters` (`parameters.model` or `parameters.model_name`).",
)
if not model_field and model_from_params:
data["model"] = model_from_params
Expand All @@ -162,8 +166,8 @@ def set_and_validate_model(cls, data: Any) -> Any:
def model_must_be_none_empty(self) -> "Model":
"""Validate that a model name is present either directly or in parameters."""
if not self.model or not self.model.strip():
raise ValueError(
"Model name must be specified either directly in the 'model' field or through 'model_name'/'model' in parameters"
raise InvalidModelConfigurationError(
f"Model name must be specified in exactly one place: either the `model` field, or in `parameters` (`parameters.model` or `parameters.model_name`)."
)
return self

Expand Down Expand Up @@ -349,10 +353,14 @@ class TaskPrompt(BaseModel):
@root_validator(pre=True, allow_reuse=True)
def check_fields(cls, values):
if not values.get("content") and not values.get("messages"):
raise ValueError("One of `content` or `messages` must be provided.")
raise InvalidRailsConfigurationError(
"One of `content` or `messages` must be provided."
)

if values.get("content") and values.get("messages"):
raise ValueError("Only one of `content` or `messages` must be provided.")
raise InvalidRailsConfigurationError(
"Only one of `content` or `messages` must be provided."
)

return values

Expand Down Expand Up @@ -1476,8 +1484,14 @@ def check_model_exists_for_input_rails(cls, values):
if not flow_model:
continue
if flow_model not in model_types:
raise ValueError(
f"No `{flow_model}` model provided for input flow `{_normalize_flow_id(flow)}`"
flow_id = _normalize_flow_id(flow)
available_types = (
", ".join(f"'{str(t)}'" for t in sorted(model_types))
if model_types
else "none"
)
raise InvalidRailsConfigurationError(
f"Input flow '{flow_id}' references model type '{flow_model}' that is not defined in the configuration. Detected model types: {available_types}."
)
return values

Expand Down Expand Up @@ -1505,8 +1519,14 @@ def check_model_exists_for_output_rails(cls, values):
if not flow_model:
continue
if flow_model not in model_types:
raise ValueError(
f"No `{flow_model}` model provided for output flow `{_normalize_flow_id(flow)}`"
flow_id = _normalize_flow_id(flow)
available_types = (
", ".join(f"'{str(t)}'" for t in sorted(model_types))
if model_types
else "none"
)
raise InvalidRailsConfigurationError(
f"Output flow '{flow_id}' references model type '{flow_model}' that is not defined in the configuration. Detected model types: {available_types}."
)
return values

Expand All @@ -1527,13 +1547,15 @@ def check_prompt_exist_for_self_check_rails(cls, values):
"self check input" in enabled_input_rails
and "self_check_input" not in provided_task_prompts
):
raise ValueError("You must provide a `self_check_input` prompt template.")
raise InvalidRailsConfigurationError(
f"Missing a `self_check_input` prompt template, which is required for the `self check input` rail."
)
if (
"llama guard check input" in enabled_input_rails
and "llama_guard_check_input" not in provided_task_prompts
):
raise ValueError(
"You must provide a `llama_guard_check_input` prompt template."
raise InvalidRailsConfigurationError(
f"Missing a `llama_guard_check_input` prompt template, which is required for the `llama guard check input` rail."
)

# Only content-safety and topic-safety include a $model reference in the rail flow text
Expand All @@ -1551,27 +1573,31 @@ def check_prompt_exist_for_self_check_rails(cls, values):
"self check output" in enabled_output_rails
and "self_check_output" not in provided_task_prompts
):
raise ValueError("You must provide a `self_check_output` prompt template.")
raise InvalidRailsConfigurationError(
f"Missing a `self_check_output` prompt template, which is required for the `self check output` rail."
)
if (
"llama guard check output" in enabled_output_rails
and "llama_guard_check_output" not in provided_task_prompts
):
raise ValueError(
"You must provide a `llama_guard_check_output` prompt template."
raise InvalidRailsConfigurationError(
f"Missing a `llama_guard_check_output` prompt template, which is required for the `llama guard check output` rail."
)
if (
"patronus lynx check output hallucination" in enabled_output_rails
and "patronus_lynx_check_output_hallucination" not in provided_task_prompts
):
raise ValueError(
"You must provide a `patronus_lynx_check_output_hallucination` prompt template."
raise InvalidRailsConfigurationError(
f"Missing a `patronus_lynx_check_output_hallucination` prompt template, which is required for the `patronus lynx check output hallucination` rail."
)

if (
"self check facts" in enabled_output_rails
and "self_check_facts" not in provided_task_prompts
):
raise ValueError("You must provide a `self_check_facts` prompt template.")
raise InvalidRailsConfigurationError(
f"Missing a `self_check_facts` prompt template, which is required for the `self check facts` rail."
)

# Only content-safety and topic-safety include a $model reference in the rail flow text
# Need to match rails with flow_id (excluding $model reference) and match prompts
Expand Down Expand Up @@ -1638,7 +1664,7 @@ def validate_models_api_key_env_var(cls, models):
api_keys = [m.api_key_env_var for m in models]
for api_key in api_keys:
if api_key and not os.environ.get(api_key):
raise ValueError(
raise InvalidRailsConfigurationError(
f"Model API Key environment variable '{api_key}' not set."
)
return models
Expand Down Expand Up @@ -1931,6 +1957,6 @@ def _validate_rail_prompts(
prompt_flow_id = flow_id.replace(" ", "_")
expected_prompt = f"{prompt_flow_id} $model={flow_model}"
if expected_prompt not in prompts:
raise ValueError(
f"You must provide a `{expected_prompt}` prompt template."
raise InvalidRailsConfigurationError(
f"Missing a `{expected_prompt}` prompt template, which is required for the `{validation_rail}` rail."
)
Loading