Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c5d535d
feat(exceptions): add LLM client error hierarchy
Pouyanpi Apr 17, 2026
22e86ca
feat(llm): add spec-compliant SSE decoder
Pouyanpi Apr 17, 2026
09ce687
feat(llm): add HTTP transport for OpenAI-compatible APIs
Pouyanpi Apr 17, 2026
ef2e6d9
feat(llm): add OpenAIChatModel implementing LLMModel protocol
Pouyanpi Apr 17, 2026
2842dbb
test(llm): add recorded fixtures and live integration tests
Pouyanpi Apr 17, 2026
26f604b
feat(llm): add DefaultFramework with client pooling and stream pipeline
Pouyanpi Apr 17, 2026
ef85c75
refactor(tests): switch default framework and migrate tests to LLMModel
Pouyanpi Apr 17, 2026
1e0e770
review: apply review suggestions
Pouyanpi Apr 21, 2026
ab89ba3
reviw: index by dict
Pouyanpi Apr 21, 2026
d7c5d3e
address further review suggestions by greptileai
Pouyanpi Apr 21, 2026
4366839
address review suggestions by Tim
Pouyanpi Apr 27, 2026
aab7923
address review suggestions by coderabbit
Pouyanpi Apr 27, 2026
46c4d0b
address review suggestions by greptileai
Pouyanpi Apr 27, 2026
2631a36
fix: make reset framework async
Pouyanpi Apr 27, 2026
b506b00
refactor(llm/clients): move provider_name to OpenAIChatModel
Pouyanpi Apr 27, 2026
fe06537
refactor(llm/clients): drop payload peek from BaseClient._error_context
Pouyanpi Apr 27, 2026
178f3da
feat(llm/clients): warn on plaintext HTTP with api_key
Pouyanpi Apr 28, 2026
a68a1fb
Update nemoguardrails/llm/clients/_errors.py
Pouyanpi Apr 28, 2026
a5ce8e7
fix(llm/default_framework): use stable string repr for cache key to a…
Pouyanpi Apr 28, 2026
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 examples/bots/abc/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ sample_conversation: |
models:
- type: main
engine: openai
model: gpt-3.5-turbo-instruct
model: gpt-4

rails:
input:
Expand Down
136 changes: 136 additions & 0 deletions nemoguardrails/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@
"InvalidModelConfigurationError",
"InvalidRailsConfigurationError",
"LLMCallException",
"LLMClientError",
Comment thread
Pouyanpi marked this conversation as resolved.
"LLMAuthenticationError",
"LLMRateLimitError",
"LLMBadRequestError",
"LLMContextWindowError",
"LLMUnsupportedParamsError",
"LLMServerError",
"LLMTimeoutError",
"LLMConnectionError",
"LLMResponseValidationError",
"StreamingNotSupportedError",
]

Expand Down Expand Up @@ -78,3 +88,129 @@ def __init__(self, inner_exception: Union[BaseException, str], detail: Optional[

self.inner_exception = inner_exception
self.detail = detail


class LLMClientError(Exception):
"""Base class for LLM client errors.

``status_code`` holds the HTTP response status when one was received,
or ``0`` when no response arrived (client-side timeout or network
error). Callers should branch on exception class rather than
``status_code`` to distinguish HTTP vs network failures, the type
hierarchy is the authoritative discriminator.
"""

def __init__(
self,
status_code: int,
error_message: str,
error_type: Optional[str] = None,
error_code: Optional[str] = None,
param: Optional[str] = None,
body: Optional[dict] = None,
response_headers: Optional[dict] = None,
model_name: Optional[str] = None,
provider_name: Optional[str] = None,
base_url: Optional[str] = None,
):
self.status_code = status_code
self.error_message = error_message
self.error_type = error_type
self.error_code = error_code
self.param = param
self.body = body
self.response_headers = response_headers
self.model_name = model_name
self.provider_name = provider_name
self.base_url = base_url
super().__init__(f"[{status_code}] {error_message}" if status_code > 0 else error_message)

def __str__(self) -> str:
parts = []
if self.model_name:
parts.append(f"model={self.model_name}")
if self.provider_name:
parts.append(f"provider={self.provider_name}")
if self.base_url:
parts.append(f"endpoint={self.base_url}")
context = f" ({', '.join(parts)})" if parts else ""
prefix = f"[{self.status_code}]" if self.status_code > 0 else ""
return f"{prefix}{context} {self.error_message}".strip()


class LLMAuthenticationError(LLMClientError):
pass


class LLMRateLimitError(LLMClientError):
def __init__(
self,
status_code: int,
error_message: str,
error_type: Optional[str] = None,
error_code: Optional[str] = None,
param: Optional[str] = None,
body: Optional[dict] = None,
response_headers: Optional[dict] = None,
model_name: Optional[str] = None,
provider_name: Optional[str] = None,
base_url: Optional[str] = None,
retry_after_seconds: Optional[float] = None,
):
super().__init__(
status_code,
error_message,
error_type,
error_code,
param,
body,
response_headers,
model_name,
provider_name,
base_url,
)
self.retry_after_seconds = retry_after_seconds


class LLMBadRequestError(LLMClientError):
pass


class LLMContextWindowError(LLMBadRequestError):
pass


class LLMUnsupportedParamsError(LLMBadRequestError):
pass


class LLMServerError(LLMClientError):
pass


class LLMTimeoutError(LLMClientError):
pass


class LLMConnectionError(LLMClientError):
pass


class LLMResponseValidationError(LLMClientError):
def __init__(
self,
message: str,
response_data: Optional[dict] = None,
model_name: Optional[str] = None,
provider_name: Optional[str] = None,
base_url: Optional[str] = None,
):
self.response_data = response_data
super().__init__(
status_code=0,
error_message=message,
body=response_data,
model_name=model_name,
provider_name=provider_name,
base_url=base_url,
)
3 changes: 3 additions & 0 deletions nemoguardrails/integrations/langchain/llm_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ def get_llm_provider_names(self) -> List[str]:

return _get_llm()

async def reset(self) -> None:
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def create_model(
self,
model_name: str,
Expand Down
18 changes: 18 additions & 0 deletions nemoguardrails/llm/clients/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 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 nemoguardrails.llm.clients.openai_compatible import OpenAICompatibleClient

__all__ = ["OpenAICompatibleClient"]
206 changes: 206 additions & 0 deletions nemoguardrails/llm/clients/_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 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.

import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any, Dict, Optional, Tuple

from nemoguardrails.exceptions import (
LLMAuthenticationError,
LLMBadRequestError,
LLMClientError,
LLMContextWindowError,
LLMRateLimitError,
LLMServerError,
LLMTimeoutError,
LLMUnsupportedParamsError,
)

_CONTEXT_WINDOW_KEYWORDS = [
Comment thread
Pouyanpi marked this conversation as resolved.
"context length",
"context_length",
"context window",
"maximum token",
"max_tokens",
"too many tokens",
"token limit",
]

_UNSUPPORTED_PARAMS_KEYWORDS = [
"unsupported parameter",
"is not supported",
"not allowed",
Comment thread
Pouyanpi marked this conversation as resolved.
Outdated
"unknown parameter",
"unrecognized parameter",
]

_SECRET_PATTERN = re.compile(r"(sk-|nvapi-|AIza|bearer\s+)\S+", re.IGNORECASE)


@dataclass(frozen=True)
class ErrorContext:
model_name: Optional[str] = None
provider_name: Optional[str] = None
base_url: Optional[str] = None

def as_kwargs(self) -> Dict[str, Any]:
return {
"model_name": self.model_name,
"provider_name": self.provider_name,
"base_url": self.base_url,
}


_EMPTY_CONTEXT = ErrorContext()


def _redact_secrets(text: str) -> str:
return _SECRET_PATTERN.sub(lambda m: m.group(1) + "***", text)


def _parse_retry_after_value(value: Any) -> Optional[float]:
try:
return float(value)
except (TypeError, ValueError):
pass
try:
parsed = parsedate_to_datetime(str(value))
except (TypeError, ValueError):
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return (parsed - datetime.now(tz=timezone.utc)).total_seconds()


def _parse_retry_after(headers: Any) -> Optional[float]:
raw = headers.get("retry-after") if headers else None
if not raw:
return None
return _parse_retry_after_value(raw)


def _extract_from_parsed_body(parsed_body: Any) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
error_message = ""
error_type = None
error_code = None
param = None
if isinstance(parsed_body, dict):
error_obj = parsed_body.get("error", {})
if isinstance(error_obj, dict):
error_message = error_obj.get("message", "") or ""
error_type = error_obj.get("type")
error_code = error_obj.get("code")
param = error_obj.get("param")
elif isinstance(error_obj, str):
error_message = error_obj
if not error_message:
error_message = parsed_body.get("message") or parsed_body.get("detail") or ""
return error_message, error_type, error_code, param


def _build_error_fields(parsed_body: Any, raw_body: str, headers: Any, ctx: ErrorContext) -> Tuple[str, Dict[str, Any]]:
error_message, error_type, error_code, param = _extract_from_parsed_body(parsed_body)
if not error_message:
error_message = raw_body or ""
error_message = _redact_secrets(error_message)
Comment thread
Pouyanpi marked this conversation as resolved.
kwargs = dict(
error_type=error_type,
error_code=error_code,
param=param,
body=parsed_body,
response_headers=dict(headers) if headers else None,
**ctx.as_kwargs(),
)
return error_message, kwargs


def _classify_bad_request(status_code: int, error_message: str, kwargs: Dict[str, Any]) -> LLMClientError:
msg_lower = error_message.lower()
if any(kw in msg_lower for kw in _CONTEXT_WINDOW_KEYWORDS):
return LLMContextWindowError(status_code, error_message, **kwargs)
if any(kw in msg_lower for kw in _UNSUPPORTED_PARAMS_KEYWORDS):
if "stream_options" in msg_lower:
error_message = (
f"{error_message} (set include_usage_in_stream=False on the model "
"or in config.yml parameters to remove this field from streaming requests)"
)
return LLMUnsupportedParamsError(status_code, error_message, **kwargs)
return LLMBadRequestError(status_code, error_message, **kwargs)


def raise_for_status(status_code: int, body: str, headers: Any, ctx: Optional[ErrorContext] = None) -> None:
ctx = ctx or _EMPTY_CONTEXT
try:
parsed_body = json.loads(body)
except (json.JSONDecodeError, TypeError):
parsed_body = None

error_message, kwargs = _build_error_fields(parsed_body, body, headers, ctx)
if not error_message:
error_message = f"HTTP {status_code}"

if status_code in (401, 403):
raise LLMAuthenticationError(status_code, error_message, **kwargs)

if status_code == 408:
raise LLMTimeoutError(status_code, error_message, **kwargs)

if status_code == 429:
retry_after = _parse_retry_after(headers)
raise LLMRateLimitError(status_code, error_message, **kwargs, retry_after_seconds=retry_after)

if status_code == 400 or status_code == 422:
raise _classify_bad_request(status_code, error_message, kwargs)

if status_code >= 500:
raise LLMServerError(status_code, error_message, **kwargs)

raise LLMClientError(status_code, error_message, **kwargs)


_SSE_ERROR_TYPE_TO_STATUS: Dict[str, int] = {
"invalid_request_error": 400,
"authentication_error": 401,
"permission_error": 403,
"not_found_error": 404,
"rate_limit_error": 429,
"api_error": 500,
"server_error": 500,
"overloaded_error": 503,
}


def raise_for_sse_error(parsed_payload: Dict[str, Any], headers: Any, ctx: Optional[ErrorContext] = None) -> None:
ctx = ctx or _EMPTY_CONTEXT
error_obj = parsed_payload.get("error")
error_type = error_obj.get("type") if isinstance(error_obj, dict) else None
error_code = error_obj.get("code") if isinstance(error_obj, dict) else None

status: Optional[int] = None
if isinstance(error_type, str) and error_type in _SSE_ERROR_TYPE_TO_STATUS:
status = _SSE_ERROR_TYPE_TO_STATUS[error_type]
elif isinstance(error_code, int) and 400 <= error_code < 600:
status = error_code

if status is not None:
raise_for_status(status, json.dumps(parsed_payload), headers, ctx)

error_message, kwargs = _build_error_fields(parsed_payload, json.dumps(parsed_payload), headers, ctx)
if not error_message:
error_message = "Streaming error"
raise LLMClientError(0, error_message, **kwargs)
Loading
Loading