diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index f9dcec30612..a6c32d78c00 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -35,11 +35,6 @@ from litellm.types.utils import ( StandardLoggingGuardrailInformation, ) -try: - from fastapi.exceptions import HTTPException -except ImportError: - HTTPException = None - if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation @@ -107,9 +102,9 @@ def is_guardrail_intervention(e: Exception) -> bool: ), ): return True - if HTTPException is not None and isinstance(e, HTTPException) and e.status_code in _GUARDRAIL_BLOCK_STATUS_CODES: - return True - return False + from litellm.proxy.guardrails.exception_utils import is_fastapi_http_exception + + return is_fastapi_http_exception(e, _GUARDRAIL_BLOCK_STATUS_CODES) def _strict_guardrail_modes_enabled() -> bool: diff --git a/litellm/integrations/gcs_bucket/gcs_bucket.py b/litellm/integrations/gcs_bucket/gcs_bucket.py index e338f490496..092357ae92b 100644 --- a/litellm/integrations/gcs_bucket/gcs_bucket.py +++ b/litellm/integrations/gcs_bucket/gcs_bucket.py @@ -14,7 +14,6 @@ from litellm.integrations.gcs_bucket.gcs_bucket_base import GCSBucketBase from litellm.litellm_core_utils.cloud_storage_security import ( sanitize_cloud_object_component, ) -from litellm.proxy._types import CommonProxyErrors from litellm.types.integrations.base_health_check import IntegrationHealthCheckStatus from litellm.types.integrations.gcs_bucket import * from litellm.types.utils import StandardLoggingPayload @@ -27,6 +26,7 @@ else: class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils): def __init__(self, bucket_name: str | None = None) -> None: + from litellm.proxy._types import CommonProxyErrors from litellm.proxy.proxy_server import premium_user self.batch_size = int(os.getenv("GCS_BATCH_SIZE", GCS_DEFAULT_BATCH_SIZE)) @@ -52,6 +52,7 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils): #### ASYNC #### async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + from litellm.proxy._types import CommonProxyErrors from litellm.proxy.proxy_server import premium_user if premium_user is not True: diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 4c61fac82bb..6c1b7946394 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -15,6 +15,7 @@ from typing_extensions import ParamSpec, TypeVar import litellm from litellm import verbose_logger +from litellm._lazy_imports import _get_default_encoding from litellm.constants import ( DEFAULT_IMAGE_HEIGHT, DEFAULT_IMAGE_TOKEN_COUNT, @@ -29,7 +30,6 @@ from litellm.constants import ( TOKEN_COUNTER_MAX_EXACT_CHARS, ) from litellm.litellm_core_utils.asyncify import asyncify -from litellm.litellm_core_utils.default_encoding import encoding as default_encoding from litellm.litellm_core_utils.url_utils import safe_get from litellm.llms.custom_httpx.http_handler import _get_httpx_client from litellm.types.llms.anthropic import ( @@ -638,7 +638,7 @@ def _get_exact_count_function( else: def encode_length(text: str) -> int: - return len(default_encoding.encode(text, disallowed_special=())) + return len(_get_default_encoding().encode(text, disallowed_special=())) return _get_tiktoken_count_function(encode_length) diff --git a/litellm/proxy/guardrails/exception_utils.py b/litellm/proxy/guardrails/exception_utils.py new file mode 100644 index 00000000000..47f2655fdaf --- /dev/null +++ b/litellm/proxy/guardrails/exception_utils.py @@ -0,0 +1,9 @@ +from collections.abc import Collection + + +def is_fastapi_http_exception(e: Exception, block_status_codes: Collection[int]) -> bool: + try: + from fastapi.exceptions import HTTPException + except ImportError: + return False + return isinstance(e, HTTPException) and e.status_code in block_status_codes diff --git a/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py b/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py index a458752bed0..fb53994089b 100644 --- a/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py +++ b/tests/test_litellm/integrations/gcs_bucket/test_gcs_bucket_base.py @@ -131,6 +131,13 @@ class TestGCSBucketBase: class TestGCSBucketLoggerBucketName: + @pytest.mark.asyncio + async def test_constructor_rejects_non_premium_user(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + + with pytest.raises(ValueError, match="GCS Bucket logging is a premium feature"): + GCSBucketLogger(bucket_name="config-bucket") + @pytest.mark.asyncio async def test_the_bucket_name_it_is_constructed_with_survives(self, monkeypatch): """Reading config.yaml out of a GCS bucket asks for that bucket, not the logging one (LIT-6982).""" @@ -145,3 +152,11 @@ class TestGCSBucketLoggerBucketName: monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True) assert GCSBucketLogger().BUCKET_NAME == "logging-bucket" + + @pytest.mark.asyncio + async def test_async_logging_rejects_non_premium_user(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + logger = object.__new__(GCSBucketLogger) + + with pytest.raises(ValueError, match="GCS Bucket logging is a premium feature"): + await logger.async_log_success_event({}, None, None, None) diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index b47aee79efc..6ffbd4e3f1f 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -1754,6 +1754,20 @@ class TestCustomGuardrailSpendLogMatchRedaction: class TestGuardrailInterventionClassification: """A routing decision is a deliberate guardrail intervention, not a failure.""" + def test_http_exception_classification_returns_false_without_fastapi(self, monkeypatch): + import builtins + + real_import = builtins.__import__ + + def import_without_fastapi(name, *args, **kwargs): + if name == "fastapi.exceptions": + raise ImportError("fastapi is unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_fastapi) + + assert CustomGuardrail._is_guardrail_intervention(Exception("not an intervention")) is False + def test_sensitive_data_route_exception_is_intervention(self): from litellm.exceptions import SensitiveDataRouteException diff --git a/tests/test_litellm/litellm_core_utils/test_token_counter.py b/tests/test_litellm/litellm_core_utils/test_token_counter.py index 60f25c48443..ba3a6be609f 100644 --- a/tests/test_litellm/litellm_core_utils/test_token_counter.py +++ b/tests/test_litellm/litellm_core_utils/test_token_counter.py @@ -98,6 +98,13 @@ def test_token_counter_short_text_matches_tiktoken(text): assert token_counter_new(model="us.anthropic.claude-sonnet-4-6", text=text) == expected +def test_token_counter_default_encoding_matches_cl100k(): + encoding: Final = tiktoken.get_encoding("cl100k_base") + expected: Final = len(encoding.encode("hello world", disallowed_special=())) + + assert token_counter_new(model=None, text="hello world") == expected + + def test_token_counter_text_over_chunk_boundary_stays_close_to_tiktoken(): text = ("The quick brown fox jumps over the lazy dog. " * 30)[:1025] encoding = tiktoken.get_encoding("cl100k_base") diff --git a/tests/test_litellm/test_lazy_imports.py b/tests/test_litellm/test_lazy_imports.py index 2b16a812611..07ead78207b 100644 --- a/tests/test_litellm/test_lazy_imports.py +++ b/tests/test_litellm/test_lazy_imports.py @@ -1,6 +1,9 @@ """Simple tests for lazy import functionality.""" +import os +import subprocess import sys +from typing import Final import pytest @@ -38,6 +41,22 @@ from litellm._lazy_imports import ( ) +def test_import_litellm_does_not_load_fastapi_or_bpe_table(): + result: Final = subprocess.run( + [ + sys.executable, + "-c", + "import sys, litellm; print(','.join(m for m in ('fastapi','starlette','litellm.litellm_core_utils.default_encoding') if m in sys.modules))", + ], + check=True, + capture_output=True, + text=True, + env={**os.environ, "LITELLM_LOCAL_MODEL_COST_MAP": "True"}, + ) + + assert result.stdout.strip() == "" + + def _clear_names_from_globals(names: tuple): """Clear all names from litellm globals.""" # Get the actual globals dict, not a copy