Skip to content

Commit 58f6e57

Browse files
authored
feat: propagate model and base URL in LLMCallException; improve error handling (#1502)
1 parent 9b59488 commit 58f6e57

File tree

10 files changed

+338
-66
lines changed

10 files changed

+338
-66
lines changed

nemoguardrails/actions/action_dispatcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from langchain_core.runnables import Runnable
2727

2828
from nemoguardrails import utils
29-
from nemoguardrails.actions.llm.utils import LLMCallException
29+
from nemoguardrails.exceptions import LLMCallException
3030

3131
log = logging.getLogger(__name__)
3232

nemoguardrails/actions/llm/utils.py

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import logging
1717
import re
18-
from typing import Any, Dict, List, Optional, Sequence, Union
18+
from typing import Dict, List, NoReturn, Optional, Sequence, Union
1919

2020
from langchain_core.callbacks.base import AsyncCallbackHandler, BaseCallbackManager
2121
from langchain_core.language_models import BaseLanguageModel
@@ -30,23 +30,25 @@
3030
reasoning_trace_var,
3131
tool_calls_var,
3232
)
33+
from nemoguardrails.exceptions import LLMCallException
3334
from nemoguardrails.integrations.langchain.message_utils import dicts_to_messages
3435
from nemoguardrails.logging.callbacks import logging_callbacks
3536
from nemoguardrails.logging.explain import LLMCallInfo
3637

3738
logger = logging.getLogger(__name__)
3839

39-
40-
class LLMCallException(Exception):
41-
"""A wrapper around the LLM call invocation exception.
42-
43-
This is used to propagate the exception out of the `generate_async` call (the default behavior is to
44-
catch it and return an "Internal server error." message.
45-
"""
46-
47-
def __init__(self, inner_exception: Any):
48-
super().__init__(f"LLM Call Exception: {str(inner_exception)}")
49-
self.inner_exception = inner_exception
40+
# Since different providers have different attributes for the base URL, we'll use this list
41+
# to attempt to extract the base URL from a `BaseLanguageModel` instance.
42+
BASE_URL_ATTRIBUTES = [
43+
"base_url",
44+
"endpoint_url",
45+
"server_url",
46+
"azure_endpoint",
47+
"openai_api_base",
48+
"api_base",
49+
"api_host",
50+
"endpoint",
51+
]
5052

5153

5254
def _infer_provider_from_module(llm: BaseLanguageModel) -> Optional[str]:
@@ -160,7 +162,7 @@ async def llm_call(
160162
The generated text response
161163
"""
162164
if llm is None:
163-
raise LLMCallException("No LLM provided to llm_call()")
165+
raise LLMCallException(ValueError("No LLM provided to llm_call()"))
164166
_setup_llm_call_info(llm, model_name, model_provider)
165167
all_callbacks = _prepare_callbacks(custom_callback_handlers)
166168

@@ -200,6 +202,58 @@ def _prepare_callbacks(
200202
return logging_callbacks
201203

202204

205+
def _raise_llm_call_exception(
206+
exception: Exception,
207+
llm: Union[BaseLanguageModel, Runnable],
208+
) -> NoReturn:
209+
"""Raise an LLMCallException with enriched context about the failed invocation.
210+
211+
Args:
212+
exception: The original exception that occurred
213+
llm: The LLM instance that was being invoked
214+
215+
Raises:
216+
LLMCallException with context message including model name and endpoint
217+
"""
218+
# Extract model name from context
219+
llm_call_info = llm_call_info_var.get()
220+
model_name = (
221+
llm_call_info.llm_model_name
222+
if llm_call_info
223+
else _infer_model_name(llm)
224+
if isinstance(llm, BaseLanguageModel)
225+
else ""
226+
)
227+
228+
# Extract endpoint URL from the LLM instance
229+
endpoint_url = None
230+
for attr in BASE_URL_ATTRIBUTES:
231+
if hasattr(llm, attr):
232+
value = getattr(llm, attr, None)
233+
if value:
234+
endpoint_url = str(value)
235+
break
236+
237+
# If we didn't find endpoint URL, check the nested client object.
238+
if not endpoint_url and hasattr(llm, "client"):
239+
client = getattr(llm, "client", None)
240+
if client and hasattr(client, "base_url"):
241+
endpoint_url = str(client.base_url)
242+
243+
# Build context message with model and endpoint info
244+
context_parts = []
245+
if model_name:
246+
context_parts.append(f"model={model_name}")
247+
if endpoint_url:
248+
context_parts.append(f"endpoint={endpoint_url}")
249+
250+
if context_parts:
251+
detail = f"Error invoking LLM ({', '.join(context_parts)})"
252+
raise LLMCallException(exception, detail=detail) from exception
253+
else:
254+
raise LLMCallException(exception) from exception
255+
256+
203257
async def _invoke_with_string_prompt(
204258
llm: Union[BaseLanguageModel, Runnable],
205259
prompt: str,
@@ -210,7 +264,7 @@ async def _invoke_with_string_prompt(
210264
try:
211265
return await llm.ainvoke(prompt, config=RunnableConfig(callbacks=callbacks), stop=stop)
212266
except Exception as e:
213-
raise LLMCallException(e)
267+
_raise_llm_call_exception(e, llm)
214268

215269

216270
async def _invoke_with_message_list(
@@ -225,7 +279,7 @@ async def _invoke_with_message_list(
225279
try:
226280
return await llm.ainvoke(messages, config=RunnableConfig(callbacks=callbacks), stop=stop)
227281
except Exception as e:
228-
raise LLMCallException(e)
282+
_raise_llm_call_exception(e, llm)
229283

230284

231285
def _convert_messages_to_langchain_format(prompt: List[dict]) -> List:

nemoguardrails/exceptions.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
from typing import Optional, Union
16+
17+
__all__ = [
18+
"ConfigurationError",
19+
"InvalidModelConfigurationError",
20+
"InvalidRailsConfigurationError",
21+
"LLMCallException",
22+
]
23+
24+
25+
class ConfigurationError(ValueError):
26+
"""
27+
Base class for Guardrails Configuration validation errors.
28+
"""
29+
30+
pass
31+
32+
33+
class InvalidModelConfigurationError(ConfigurationError):
34+
"""Raised when a guardrail configuration's model is invalid."""
35+
36+
pass
37+
38+
39+
class InvalidRailsConfigurationError(ConfigurationError):
40+
"""Raised when rails configuration is invalid.
41+
42+
Examples:
43+
- Input/output rail references a model that doesn't exist in config
44+
- Rail references a flow that doesn't exist
45+
- Missing required prompt template
46+
- Invalid rail parameters
47+
"""
48+
49+
pass
50+
51+
52+
class LLMCallException(Exception):
53+
"""A wrapper around the LLM call invocation exception.
54+
55+
This is used to propagate the exception out of the `generate_async` call. The default behavior is to
56+
catch it and return an "Internal server error." message.
57+
"""
58+
59+
inner_exception: Union[BaseException, str]
60+
detail: Optional[str]
61+
62+
def __init__(self, inner_exception: Union[BaseException, str], detail: Optional[str] = None):
63+
"""Initialize LLMCallException.
64+
65+
Args:
66+
inner_exception: The original exception that occurred
67+
detail: Optional context to prepend (for example, the model name or endpoint)
68+
"""
69+
message = f"{detail or 'LLM Call Exception'}: {str(inner_exception)}"
70+
super().__init__(message)
71+
72+
self.inner_exception = inner_exception
73+
self.detail = detail

nemoguardrails/rails/llm/config.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
from nemoguardrails.colang.v1_0.runtime.flows import _normalize_flow_id
3838
from nemoguardrails.colang.v2_x.lang.utils import format_colang_parsing_error_message
3939
from nemoguardrails.colang.v2_x.runtime.errors import ColangParsingError
40+
from nemoguardrails.exceptions import (
41+
InvalidModelConfigurationError,
42+
InvalidRailsConfigurationError,
43+
)
4044

4145
log = logging.getLogger(__name__)
4246

@@ -136,8 +140,8 @@ def set_and_validate_model(cls, data: Any) -> Any:
136140
model_from_params = parameters.get("model_name") or parameters.get("model")
137141

138142
if model_field and model_from_params:
139-
raise ValueError(
140-
"Model name must be specified in exactly one place: either in the 'model' field or in parameters, not both."
143+
raise InvalidModelConfigurationError(
144+
"Model name must be specified in exactly one place: either the `model` field, or in `parameters` (`parameters.model` or `parameters.model_name`).",
141145
)
142146
if not model_field and model_from_params:
143147
data["model"] = model_from_params
@@ -151,8 +155,8 @@ def set_and_validate_model(cls, data: Any) -> Any:
151155
def model_must_be_none_empty(self) -> "Model":
152156
"""Validate that a model name is present either directly or in parameters."""
153157
if not self.model or not self.model.strip():
154-
raise ValueError(
155-
"Model name must be specified either directly in the 'model' field or through 'model_name'/'model' in parameters"
158+
raise InvalidModelConfigurationError(
159+
"Model name must be specified in exactly one place: either the `model` field, or in `parameters` (`parameters.model` or `parameters.model_name`)."
156160
)
157161
return self
158162

@@ -334,10 +338,10 @@ class TaskPrompt(BaseModel):
334338
@root_validator(pre=True, allow_reuse=True)
335339
def check_fields(cls, values):
336340
if not values.get("content") and not values.get("messages"):
337-
raise ValueError("One of `content` or `messages` must be provided.")
341+
raise InvalidRailsConfigurationError("One of `content` or `messages` must be provided.")
338342

339343
if values.get("content") and values.get("messages"):
340-
raise ValueError("Only one of `content` or `messages` must be provided.")
344+
raise InvalidRailsConfigurationError("Only one of `content` or `messages` must be provided.")
341345

342346
return values
343347

@@ -1414,7 +1418,11 @@ def check_model_exists_for_input_rails(cls, values):
14141418
if not flow_model:
14151419
continue
14161420
if flow_model not in model_types:
1417-
raise ValueError(f"No `{flow_model}` model provided for input flow `{_normalize_flow_id(flow)}`")
1421+
flow_id = _normalize_flow_id(flow)
1422+
available_types = ", ".join(f"'{str(t)}'" for t in sorted(model_types)) if model_types else "none"
1423+
raise InvalidRailsConfigurationError(
1424+
f"Input flow '{flow_id}' references model type '{flow_model}' that is not defined in the configuration. Detected model types: {available_types}."
1425+
)
14181426
return values
14191427

14201428
@root_validator(pre=True)
@@ -1436,7 +1444,11 @@ def check_model_exists_for_output_rails(cls, values):
14361444
if not flow_model:
14371445
continue
14381446
if flow_model not in model_types:
1439-
raise ValueError(f"No `{flow_model}` model provided for output flow `{_normalize_flow_id(flow)}`")
1447+
flow_id = _normalize_flow_id(flow)
1448+
available_types = ", ".join(f"'{str(t)}'" for t in sorted(model_types)) if model_types else "none"
1449+
raise InvalidRailsConfigurationError(
1450+
f"Output flow '{flow_id}' references model type '{flow_model}' that is not defined in the configuration. Detected model types: {available_types}."
1451+
)
14401452
return values
14411453

14421454
@root_validator(pre=True)
@@ -1450,9 +1462,13 @@ def check_prompt_exist_for_self_check_rails(cls, values):
14501462

14511463
# Input moderation prompt verification
14521464
if "self check input" in enabled_input_rails and "self_check_input" not in provided_task_prompts:
1453-
raise ValueError("You must provide a `self_check_input` prompt template.")
1465+
raise InvalidRailsConfigurationError(
1466+
"Missing a `self_check_input` prompt template, which is required for the `self check input` rail."
1467+
)
14541468
if "llama guard check input" in enabled_input_rails and "llama_guard_check_input" not in provided_task_prompts:
1455-
raise ValueError("You must provide a `llama_guard_check_input` prompt template.")
1469+
raise InvalidRailsConfigurationError(
1470+
"Missing a `llama_guard_check_input` prompt template, which is required for the `llama guard check input` rail."
1471+
)
14561472

14571473
# Only content-safety and topic-safety include a $model reference in the rail flow text
14581474
# Need to match rails with flow_id (excluding $model reference) and match prompts
@@ -1462,20 +1478,28 @@ def check_prompt_exist_for_self_check_rails(cls, values):
14621478

14631479
# Output moderation prompt verification
14641480
if "self check output" in enabled_output_rails and "self_check_output" not in provided_task_prompts:
1465-
raise ValueError("You must provide a `self_check_output` prompt template.")
1481+
raise InvalidRailsConfigurationError(
1482+
"Missing a `self_check_output` prompt template, which is required for the `self check output` rail."
1483+
)
14661484
if (
14671485
"llama guard check output" in enabled_output_rails
14681486
and "llama_guard_check_output" not in provided_task_prompts
14691487
):
1470-
raise ValueError("You must provide a `llama_guard_check_output` prompt template.")
1488+
raise InvalidRailsConfigurationError(
1489+
"Missing a `llama_guard_check_output` prompt template, which is required for the `llama guard check output` rail."
1490+
)
14711491
if (
14721492
"patronus lynx check output hallucination" in enabled_output_rails
14731493
and "patronus_lynx_check_output_hallucination" not in provided_task_prompts
14741494
):
1475-
raise ValueError("You must provide a `patronus_lynx_check_output_hallucination` prompt template.")
1495+
raise InvalidRailsConfigurationError(
1496+
"Missing a `patronus_lynx_check_output_hallucination` prompt template, which is required for the `patronus lynx check output hallucination` rail."
1497+
)
14761498

14771499
if "self check facts" in enabled_output_rails and "self_check_facts" not in provided_task_prompts:
1478-
raise ValueError("You must provide a `self_check_facts` prompt template.")
1500+
raise InvalidRailsConfigurationError(
1501+
"Missing a `self_check_facts` prompt template, which is required for the `self check facts` rail."
1502+
)
14791503

14801504
# Only content-safety and topic-safety include a $model reference in the rail flow text
14811505
# Need to match rails with flow_id (excluding $model reference) and match prompts
@@ -1528,7 +1552,7 @@ def validate_models_api_key_env_var(cls, models):
15281552
api_keys = [m.api_key_env_var for m in models]
15291553
for api_key in api_keys:
15301554
if api_key and not os.environ.get(api_key):
1531-
raise ValueError(f"Model API Key environment variable '{api_key}' not set.")
1555+
raise InvalidRailsConfigurationError(f"Model API Key environment variable '{api_key}' not set.")
15321556
return models
15331557

15341558
raw_llm_call_action: Optional[str] = Field(
@@ -1801,4 +1825,6 @@ def _validate_rail_prompts(rails: list[str], prompts: list[Any], validation_rail
18011825
prompt_flow_id = flow_id.replace(" ", "_")
18021826
expected_prompt = f"{prompt_flow_id} $model={flow_model}"
18031827
if expected_prompt not in prompts:
1804-
raise ValueError(f"You must provide a `{expected_prompt}` prompt template.")
1828+
raise InvalidRailsConfigurationError(
1829+
f"Missing a `{expected_prompt}` prompt template, which is required for the `{validation_rail}` rail."
1830+
)

0 commit comments

Comments
 (0)