diff --git a/.circleci/config.yml b/.circleci/config.yml index 0e53cfc0edb..ef6445ca0ca 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: name: Linting Testing command: | cd litellm - pip install "cryptography<40.0.0" + pip install "cryptography>=43.0.1" python -m pip install types-requests types-setuptools types-redis types-PyYAML if ! python -m mypy . \ --config-file mypy.ini \ diff --git a/README.md b/README.md index f74889fbb27..0918d2b1fa4 100644 --- a/README.md +++ b/README.md @@ -350,13 +350,21 @@ curl 'http://0.0.0.0:4000/key/generate' \ [**Read the Docs**](https://docs.litellm.ai/docs/) -## Contributing +## Run in Developer mode +### Services +1. Setup .env file in root +2. Run dependant services `docker-compose up db prometheus` -Interested in contributing? Contributions to LiteLLM Python SDK, Proxy Server, and LLM integrations are both accepted and highly encouraged! +### Backend +1. (In root) create virtual environment `python -m venv .venv` +2. Activate virtual environment `source .venv/bin/activate` +3. Install dependencies `pip install -e ".[all]"` +4. Start proxy backend `python litellm/proxy_cli.py` -**Quick start:** `git clone` → `make install-dev` → `make format` → `make lint` → `make test-unit` - -See our comprehensive [Contributing Guide (CONTRIBUTING.md)](CONTRIBUTING.md) for detailed instructions. +### Frontend +1. Navigate to `ui/litellm-dashboard` +2. Install dependencies `npm install` +3. Run `npm run dev` to start the dashboard # Enterprise For companies that need better security, user management and professional support @@ -434,18 +442,3 @@ All these checks must pass before your PR can be merged. -## Run in Developer mode -### Services -1. Setup .env file in root -2. Run dependant services `docker-compose up db prometheus` - -### Backend -1. (In root) create virtual environment `python -m venv .venv` -2. Activate virtual environment `source .venv/bin/activate` -3. Install dependencies `pip install -e ".[all]"` -4. Start proxy backend `python3 /path/to/litellm/proxy_cli.py` - -### Frontend -1. Navigate to `ui/litellm-dashboard` -2. Install dependencies `npm install` -3. Run `npm run dev` to start the dashboard diff --git a/docs/my-website/package-lock.json b/docs/my-website/package-lock.json index 4b37e2be11d..b71a15cc8e6 100644 --- a/docs/my-website/package-lock.json +++ b/docs/my-website/package-lock.json @@ -17120,9 +17120,10 @@ } }, "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -19295,9 +19296,10 @@ } }, "node_modules/tar-fs": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", - "integrity": "sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" diff --git a/litellm/caching/in_memory_cache.py b/litellm/caching/in_memory_cache.py index 63869474d47..082cac791f2 100644 --- a/litellm/caching/in_memory_cache.py +++ b/litellm/caching/in_memory_cache.py @@ -36,7 +36,7 @@ class InMemoryCache(BaseCache): max_size_in_memory [int]: Maximum number of items in cache. done to prevent memory leaks. Use 200 items as a default """ self.max_size_in_memory = ( - max_size_in_memory or 200 + max_size_in_memory if max_size_in_memory is not None else 200 ) # set an upper bound of 200 items in-memory self.default_ttl = default_ttl or 600 self.max_size_per_item = ( @@ -103,20 +103,32 @@ class InMemoryCache(BaseCache): def evict_cache(self): """ Eviction policy: - - check if any items in ttl_dict are expired -> remove them from ttl_dict and cache_dict + 1. First, remove expired items from ttl_dict and cache_dict + 2. If cache is still at or above max_size_in_memory, evict items with earliest expiration times This guarantees the following: - - 1. When item ttl not set: At minimumm each item will remain in memory for 5 minutes - - 2. When ttl is set: the item will remain in memory for at least that amount of time + - 1. When item ttl not set: At minimum each item will remain in memory for the default ttl + - 2. When ttl is set: the item will remain in memory for at least that amount of time, unless cache size requires eviction - 3. the size of in-memory cache is bounded """ current_time = time.time() + + # Step 1: Remove expired items expired_keys = [key for key, ttl in self.ttl_dict.items() if current_time > ttl] for key in expired_keys: self._remove_key(key) + # Step 2: If cache is still full, evict items with earliest expiration times + if len(self.cache_dict) >= self.max_size_in_memory: + # Sort by expiration time (earliest first) and evict until we're under the limit + items_by_expiration = sorted(self.ttl_dict.items(), key=lambda x: x[1]) + keys_to_evict = items_by_expiration[:len(self.cache_dict) - self.max_size_in_memory + 1] + + for key, _ in keys_to_evict: + self._remove_key(key) + # de-reference the removed item # https://www.geeksforgeeks.org/diagnosing-and-fixing-memory-leaks-in-python/ # One of the most common causes of memory leaks in Python is the retention of objects that are no longer being used. @@ -135,6 +147,10 @@ class InMemoryCache(BaseCache): return False def set_cache(self, key, value, **kwargs): + # Handle the edge case where max_size_in_memory is 0 + if self.max_size_in_memory == 0: + return # Don't cache anything if max size is 0 + if len(self.cache_dict) >= self.max_size_in_memory: # only evict when cache is full self.evict_cache() diff --git a/litellm/constants.py b/litellm/constants.py index 005eb2bb6d0..6e70ae0671f 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -812,6 +812,11 @@ BEDROCK_EMBEDDING_PROVIDERS_LITERAL = Literal[ ] BEDROCK_CONVERSE_MODELS = [ + "qwen.qwen3-coder-480b-a35b-v1:0", + "qwen.qwen3-235b-a22b-2507-v1:0", + "qwen.qwen3-coder-30b-a3b-v1:0", + "qwen.qwen3-32b-v1:0", + "deepseek.v3-v1:0", "openai.gpt-oss-20b-1:0", "openai.gpt-oss-120b-1:0", "anthropic.claude-opus-4-1-20250805-v1:0", diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 24577731384..69943a0fe4d 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -671,6 +671,7 @@ class LangFuseLogger: generation_id = None usage = None + usage_details = None if response_obj is not None: if ( hasattr(response_obj, "id") @@ -687,6 +688,11 @@ class LangFuseLogger: "completion_tokens": _usage_obj.completion_tokens, "total_cost": cost if self._supports_costs() else None, } + usage_details = LangfuseUsageDetails(input=_usage_obj.prompt_tokens, + output=_usage_obj.completion_tokens, + cache_creation_input_tokens=_usage_obj.get('cache_creation_input_tokens', 0), + cache_read_input_tokens=_usage_obj.get('cache_read_input_tokens', 0)) + generation_name = clean_metadata.pop("generation_name", None) if generation_name is None: # if `generation_name` is None, use sensible default values @@ -719,6 +725,7 @@ class LangFuseLogger: "input": input if not mask_input else "redacted-by-litellm", "output": output if not mask_output else "redacted-by-litellm", "usage": usage, + "usage_details": usage_details, "metadata": log_requester_metadata(clean_metadata), "level": level, "version": clean_metadata.pop("version", None), diff --git a/litellm/litellm_core_utils/object_pooling.py b/litellm/litellm_core_utils/object_pooling.py index 846e6536f80..81c3ec2e133 100644 --- a/litellm/litellm_core_utils/object_pooling.py +++ b/litellm/litellm_core_utils/object_pooling.py @@ -16,11 +16,35 @@ Memory Management Strategy: from typing import Any, Callable, Optional, Type, TypeVar -from pond import Pond, PooledObject, PooledObjectFactory +try: + from pond import Pond, PooledObject, PooledObjectFactory # type: ignore + POND_AVAILABLE = True +except ImportError: # pragma: no cover + POND_AVAILABLE = False + class Pond: # type: ignore + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + def register(self, *args: Any, **kwargs: Any) -> None: + pass + + def borrow(self, *args: Any, **kwargs: Any) -> Any: + pass + + def recycle(self, *args: Any, **kwargs: Any) -> None: + pass + + class PooledObject: # type: ignore + def __init__(self, keeped_object: Any = None) -> None: + self.keeped_object = keeped_object + + class PooledObjectFactory: # type: ignore + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass T = TypeVar('T') -class GenericPooledObjectFactory(PooledObjectFactory): +class GenericPooledObjectFactory(PooledObjectFactory): # type: ignore[misc] """Generic factory class for creating pooled objects of any type.""" def __init__( @@ -79,7 +103,7 @@ def get_object_pool( time_between_eviction_runs: int = 300, # Less frequent eviction to maintain high reuse ratio eviction_weight: float = 0.3, # Less aggressive eviction for better reuse prewarm_count: int = 5 # Lower pre-warm count to reduce initial memory usage -) -> Pond: +) -> Pond | None: """Get or create a global object pool instance with balanced eviction-based memory control. Memory is controlled through moderate eviction to balance reuse ratio and memory usage: @@ -98,9 +122,13 @@ def get_object_pool( prewarm_count: Number of objects to pre-warm the pool with (default: 5) Returns: - Pond instance for the specified object type + Pond instance for the specified object type or None if pond is not available """ + # If pond is not available, disable pooling gracefully + if not POND_AVAILABLE: + return None + if pool_name in _pools: return _pools[pool_name] @@ -134,4 +162,4 @@ def _prewarm_pool(pond: Pond, pool_name: str, prewarm_count: int = 20) -> None: pond.recycle(pooled_obj, name=f"{pool_name}Factory") except Exception: # If pre-warming fails, just continue - break \ No newline at end of file + break diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 832ff36bfb8..3742693ce67 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -2032,6 +2032,36 @@ "supports_tool_choice": false, "supports_vision": true }, + "azure/gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "azure/gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, @@ -5282,6 +5312,49 @@ "supports_tool_choice": true, "supports_vision": true }, + "deepseek-chat": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "deepseek-reasoner": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false + }, "dashscope/qwen-coder": { "input_cost_per_token": 3e-07, "litellm_provider": "dashscope", @@ -7203,6 +7276,18 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "deepseek.v3-v1:0": { + "input_cost_per_token": 5.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 163840, + "max_output_tokens": 81920, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.68e-06, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "dolphin": { "input_cost_per_token": 5e-07, "litellm_provider": "nlp_cloud", @@ -12415,6 +12500,36 @@ "supports_tool_choice": false, "supports_vision": true }, + "gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, "gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_flex": 1.25e-08, @@ -17520,6 +17635,54 @@ "mode": "chat", "output_cost_per_token": 2.8e-07 }, + "qwen.qwen3-coder-480b-a35b-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262000, + "max_output_tokens": 65536, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.8e-06, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-235b-a22b-2507-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-coder-30b-a3b-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6.0e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-32b-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.0e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "recraft/recraftv2": { "litellm_provider": "recraft", "mode": "image_generation", @@ -17946,6 +18109,32 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "sambanova/DeepSeek-V3.1": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "sambanova", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "sambanova", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, "sample_spec": { "code_interpreter_cost_per_session": 0.0, "computer_use_input_cost_per_1k_tokens": 0.0, @@ -20467,6 +20656,24 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "vertex_ai/deepseek-ai/deepseek-v3.1-maas": { + "input_cost_per_token": 1.35e-06, + "litellm_provider": "vertex_ai-deepseek_models", + "max_input_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 5.4e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_regions": [ + "us-west2" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "vertex_ai/deepseek-ai/deepseek-r1-0528-maas": { "input_cost_per_token": 1.35e-06, "litellm_provider": "vertex_ai-deepseek_models", @@ -20880,6 +21087,30 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, "vertex_ai/veo-2.0-generate-001": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9d5298c30f8..a92b7d8ad2e 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -422,6 +422,7 @@ class LiteLLMRoutes(enum.Enum): "/user/update", "/user/delete", "/user/info", + "/user/list", # team "/team/new", "/team/update", diff --git a/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py b/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py index e167e73ac4d..b65664e00bd 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py +++ b/litellm/proxy/guardrails/guardrail_hooks/lakera_ai_v2.py @@ -3,9 +3,10 @@ import os from datetime import datetime from typing import Dict, List, Literal, Optional, Tuple, Union +from fastapi import HTTPException + import litellm from litellm._logging import verbose_proxy_logger -from litellm.exceptions import GuardrailRaisedException from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, @@ -237,9 +238,8 @@ class LakeraAIGuardrail(CustomGuardrail): ) else: # If there are other violations or not set to mask PII, raise exception - raise GuardrailRaisedException( - guardrail_name=self.guardrail_name, - message="Lakera AI flagged this request. Please review the request and try again.", + raise self._get_http_exception_for_blocked_guardrail( + lakera_guardrail_response ) ######################################################### @@ -304,9 +304,8 @@ class LakeraAIGuardrail(CustomGuardrail): ) else: # If there are other violations or not set to mask PII, raise exception - raise GuardrailRaisedException( - guardrail_name=self.guardrail_name, - message="Lakera AI flagged this request. Please review the request and try again.", + raise self._get_http_exception_for_blocked_guardrail( + lakera_guardrail_response ) ######################################################### @@ -327,8 +326,32 @@ class LakeraAIGuardrail(CustomGuardrail): if not lakera_response: return False - for item in lakera_response.get("payload", []) or []: - detector_type = item.get("detector_type", "") or "" - if not detector_type.startswith("pii/"): - return False - return True + # Check breakdown field for detected violations + breakdown = lakera_response.get("breakdown", []) or [] + if not breakdown: + return False + + has_violations = False + for item in breakdown: + if item.get("detected", False): + has_violations = True + detector_type = item.get("detector_type", "") or "" + if not detector_type.startswith("pii/"): + return False + + # Return True only if there are violations and they are all PII + return has_violations + + def _get_http_exception_for_blocked_guardrail( + self, lakera_response: Optional[LakeraAIResponse] + ) -> HTTPException: + """ + Get the HTTP exception for a blocked guardrail, similar to Bedrock's implementation. + """ + return HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "lakera_guardrail_response": lakera_response, + }, + ) diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index addf2443d36..921b564407f 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -23,3 +23,16 @@ model_list: litellm_params: model: gemini/* api_key: os.environ/GEMINI_API_KEY + + +guardrails: + - guardrail_name: lakera + litellm_params: + guardrail: lakera_v2 + mode: pre_call + api_key: os.environ/LAKERA_API_KEY + default_on: false + project_id: project-9770817088 + breakdown: true + payload: true + dev_info: true diff --git a/litellm/types/integrations/langfuse.py b/litellm/types/integrations/langfuse.py index e509d074172..08ad667cac4 100644 --- a/litellm/types/integrations/langfuse.py +++ b/litellm/types/integrations/langfuse.py @@ -7,3 +7,10 @@ class LangfuseLoggingConfig(TypedDict): langfuse_secret: Optional[str] langfuse_public_key: Optional[str] langfuse_host: Optional[str] + + +class LangfuseUsageDetails(TypedDict): + input: Optional[int] + output: Optional[int] + cache_creation_input_tokens: Optional[int] + cache_read_input_tokens: Optional[int] diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 832ff36bfb8..3742693ce67 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -2032,6 +2032,36 @@ "supports_tool_choice": false, "supports_vision": true }, + "azure/gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "azure", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "azure/gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, @@ -5282,6 +5312,49 @@ "supports_tool_choice": true, "supports_vision": true }, + "deepseek-chat": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "deepseek-reasoner": { + "cache_read_input_token_cost": 6e-08, + "input_cost_per_token": 6e-07, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 1.7e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false + }, "dashscope/qwen-coder": { "input_cost_per_token": 3e-07, "litellm_provider": "dashscope", @@ -7203,6 +7276,18 @@ "supports_prompt_caching": true, "supports_tool_choice": true }, + "deepseek.v3-v1:0": { + "input_cost_per_token": 5.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 163840, + "max_output_tokens": 81920, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 1.68e-06, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "dolphin": { "input_cost_per_token": 5e-07, "litellm_provider": "nlp_cloud", @@ -12415,6 +12500,36 @@ "supports_tool_choice": false, "supports_vision": true }, + "gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-07, + "input_cost_per_token": 1.25e-06, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-05, + "supported_endpoints": [ + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": false, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, "gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_flex": 1.25e-08, @@ -17520,6 +17635,54 @@ "mode": "chat", "output_cost_per_token": 2.8e-07 }, + "qwen.qwen3-coder-480b-a35b-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262000, + "max_output_tokens": 65536, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.8e-06, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-235b-a22b-2507-v1:0": { + "input_cost_per_token": 2.2e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 8.8e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-coder-30b-a3b-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 131072, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 6.0e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "qwen.qwen3-32b-v1:0": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 131072, + "max_output_tokens": 16384, + "max_tokens": 131072, + "mode": "chat", + "output_cost_per_token": 6.0e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "recraft/recraftv2": { "litellm_provider": "recraft", "mode": "image_generation", @@ -17946,6 +18109,32 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "sambanova/DeepSeek-V3.1": { + "max_tokens": 32768, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "sambanova", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, + "sambanova/gpt-oss-120b": { + "max_tokens": 131072, + "max_input_tokens": 131072, + "max_output_tokens": 131072, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 4.5e-06, + "litellm_provider": "sambanova", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "source": "https://cloud.sambanova.ai/plans/pricing" + }, "sample_spec": { "code_interpreter_cost_per_session": 0.0, "computer_use_input_cost_per_1k_tokens": 0.0, @@ -20467,6 +20656,24 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "vertex_ai/deepseek-ai/deepseek-v3.1-maas": { + "input_cost_per_token": 1.35e-06, + "litellm_provider": "vertex_ai-deepseek_models", + "max_input_tokens": 163840, + "max_output_tokens": 32768, + "max_tokens": 163840, + "mode": "chat", + "output_cost_per_token": 5.4e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", + "supported_regions": [ + "us-west2" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "vertex_ai/deepseek-ai/deepseek-r1-0528-maas": { "input_cost_per_token": 1.35e-06, "litellm_provider": "vertex_ai-deepseek_models", @@ -20880,6 +21087,30 @@ "supports_function_calling": true, "supports_tool_choice": true }, + "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, + "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": { + "input_cost_per_token": 1.5e-07, + "litellm_provider": "vertex_ai-qwen_models", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.2e-06, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_tool_choice": true + }, "vertex_ai/veo-2.0-generate-001": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, diff --git a/poetry.lock b/poetry.lock index a8ef9ee79df..87599768db6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2730,9 +2730,10 @@ files = [ name = "madoka" version = "0.7.1" description = "Memory-efficient CountMin Sketch key-value structure (based on Madoka C++ library)" -optional = false +optional = true python-versions = "*" groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "madoka-0.7.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:7521eee9ace30b376bb54fdcb2cb42bf6b7a0346b0d0b612f25f3299aa4a95af"}, {file = "madoka-0.7.1.tar.gz", hash = "sha256:e258baa84fc0a3764365993b8bf5e1b065383a6ca8c9f862fb3e3e709843fae7"}, @@ -4044,9 +4045,10 @@ xlsxwriter = ["xlsxwriter"] name = "pondpond" version = "1.4.1" description = "Pond is a high performance object-pooling library for Python." -optional = false +optional = true python-versions = ">=3.8" groups = ["main"] +markers = "extra == \"proxy\"" files = [ {file = "pondpond-1.4.1-py3-none-any.whl", hash = "sha256:641028ead4e8018ca6de1220c660ddd6d6fbf62a60e72f410655dd0451d82880"}, {file = "pondpond-1.4.1.tar.gz", hash = "sha256:8afa34b869d1434d21dd2ec12644abc3b1733fcda8fcf355300338a13a79bb7b"}, @@ -6747,11 +6749,11 @@ type = ["pytest-mypy"] caching = ["diskcache"] extra-proxy = ["azure-identity", "azure-keyvault-secrets", "google-cloud-iam", "google-cloud-kms", "prisma", "redisvl", "resend"] mlflow = ["mlflow"] -proxy = ["PyJWT", "apscheduler", "azure-identity", "azure-storage-blob", "backoff", "boto3", "cryptography", "fastapi", "fastapi-sso", "fastuuid", "gunicorn", "litellm-enterprise", "litellm-proxy-extras", "mcp", "orjson", "polars", "pynacl", "python-multipart", "pyyaml", "rich", "rq", "uvicorn", "uvloop", "websockets"] +proxy = ["PyJWT", "apscheduler", "azure-identity", "azure-storage-blob", "backoff", "boto3", "cryptography", "fastapi", "fastapi-sso", "fastuuid", "gunicorn", "litellm-enterprise", "litellm-proxy-extras", "mcp", "orjson", "polars", "pondpond", "pynacl", "python-multipart", "pyyaml", "rich", "rq", "uvicorn", "uvloop", "websockets"] semantic-router = ["semantic-router"] utils = ["numpydoc"] [metadata] lock-version = "2.1" python-versions = ">=3.8.1,<4.0, !=3.9.7" -content-hash = "75004c6a23b70be86622fa417fd0d62fa3843e6e61c8dff8507ae5c967b7205d" +content-hash = "16fdc1044b4bb316803cbf1825bc970895526949a8bef93eab741777b7210ca8" diff --git a/pyproject.toml b/pyproject.toml index b11b1a1c2e2..4113714415c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ jinja2 = "^3.1.2" aiohttp = ">=3.10" pydantic = "^2.5.0" jsonschema = "^4.22.0" -pondpond = "^1.4.1" +pondpond = {version = "^1.4.1", optional = true} numpydoc = {version = "*", optional = true} # used in utils.py fastuuid = {version = ">=0.12.0", optional = true} @@ -94,6 +94,7 @@ proxy = [ "rich", "polars", "fastuuid", + "pondpond", ] extra_proxy = [ diff --git a/tests/guardrails_tests/test_lakera_v2.py b/tests/guardrails_tests/test_lakera_v2.py index 19c4424bee6..f3b2795a275 100644 --- a/tests/guardrails_tests/test_lakera_v2.py +++ b/tests/guardrails_tests/test_lakera_v2.py @@ -11,7 +11,8 @@ from litellm.proxy.guardrails.guardrail_hooks.lakera_ai_v2 import LakeraAIGuardr from litellm.types.guardrails import PiiEntityType, PiiAction from litellm.proxy._types import UserAPIKeyAuth from litellm.caching.caching import DualCache -from litellm.exceptions import BlockedPiiEntityError +from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException +from fastapi import HTTPException from litellm.types.utils import CallTypes as LitellmCallTypes @@ -54,3 +55,179 @@ async def test_lakera_pre_call_hook_for_pii_masking(): assert "4111-1111-1111-1111" not in user_message assert "test@example.com" not in user_message + +@pytest.mark.asyncio +async def test_lakera_blocks_non_pii_violations(): + """Test that Lakera guardrail blocks requests with non-PII violations like hate speech, violence, etc.""" + + lakera_guardrail = LakeraAIGuardrail( + api_key="test_key", + ) + + # Mock the call_v2_guard method to return a response similar to the user's example + mock_response = { + 'payload': [], + 'flagged': True, + 'dev_info': {'git_revision': 'f0bc093a', 'git_timestamp': '2025-09-23T15:28:06+00:00', 'model_version': 'lakera-guard-1', 'version': '2.0.281'}, + 'metadata': {'request_uuid': 'b7cd4c8a-28aa-4285-a245-2befee514dbf'}, + 'breakdown': [ + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/crime', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/hate', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/violence', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-prompt-attack', 'detector_type': 'prompt_attack', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/email', 'detected': False, 'message_id': 0}, + ] + } + + with patch.object(lakera_guardrail, 'call_v2_guard', new_callable=AsyncMock) as mock_call: + mock_call.return_value = (mock_response, {}) + + # Create a sample request that would trigger violations + data = { + "messages": [ + {"role": "user", "content": "Some harmful content that triggers violations"} + ], + "model": "gpt-3.5-turbo", + "metadata": {} + } + + # Mock objects needed for the pre-call hook + user_api_key_dict = UserAPIKeyAuth(api_key="test_key") + cache = DualCache() + + # The guardrail should raise an HTTPException for non-PII violations + with pytest.raises(HTTPException) as exc_info: + await lakera_guardrail.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=cache, + data=data, + call_type="completion" + ) + + # Verify the exception details include the Lakera response + assert exc_info.value.status_code == 400 + assert "Violated guardrail policy" in str(exc_info.value.detail) + assert "lakera_guardrail_response" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_lakera_only_pii_violations_are_masked(): + """Test that Lakera guardrail only masks PII violations and doesn't block the request.""" + + lakera_guardrail = LakeraAIGuardrail( + api_key="test_key", + ) + + # Mock response with only PII violations + mock_response = { + 'payload': [ + {'detector_type': 'pii/email', 'start': 10, 'end': 25, 'message_id': 0} + ], + 'flagged': True, + 'breakdown': [ + {'project_id': 'project-9770817088', 'detector_type': 'pii/email', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'detector_type': 'moderated_content/hate', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'detector_type': 'prompt_attack', 'detected': False, 'message_id': 0}, + ] + } + + with patch.object(lakera_guardrail, 'call_v2_guard', new_callable=AsyncMock) as mock_call: + mock_call.return_value = (mock_response, {}) + + data = { + "messages": [ + {"role": "user", "content": "My email test@example.com here"} + ], + "model": "gpt-3.5-turbo", + "metadata": {} + } + + user_api_key_dict = UserAPIKeyAuth(api_key="test_key") + cache = DualCache() + + # Should not raise an exception, just mask the PII + result = await lakera_guardrail.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=cache, + data=data, + call_type="completion" + ) + + # Verify the request was not blocked + assert result is not None + assert "messages" in result + + +@pytest.mark.asyncio +async def test_lakera_blocks_flagged_content_with_user_scenario(): + """ + Test the exact user scenario where Lakera flagged content but request went through. + This should now be blocked with the fix to check breakdown field instead of payload. + """ + + lakera_guardrail = LakeraAIGuardrail( + api_key="test_key", + ) + + # Mock response matching the exact user scenario + mock_response = { + 'payload': [], # Empty payload like in user's case + 'flagged': True, + 'dev_info': {'git_revision': 'f0bc093a', 'git_timestamp': '2025-09-23T15:28:06+00:00', 'model_version': 'lakera-guard-1', 'version': '2.0.281'}, + 'metadata': {'request_uuid': 'b7cd4c8a-28aa-4285-a245-2befee514dbf'}, + 'breakdown': [ + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/crime', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/hate', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/profanity', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/sexual', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/violence', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-moderated-content', 'detector_type': 'moderated_content/weapons', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/address', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/credit_card', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/email', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/iban_code', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/ip_address', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/name', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/phone_number', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-pii', 'detector_type': 'pii/us_social_security_number', 'detected': False, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-prompt-attack', 'detector_type': 'prompt_attack', 'detected': True, 'message_id': 0}, + {'project_id': 'project-9770817088', 'policy_id': 'policy-lakera-default', 'detector_id': 'detector-lakera-default-unknown-links', 'detector_type': 'unknown_links', 'detected': False, 'message_id': 0} + ] + } + + with patch.object(lakera_guardrail, 'call_v2_guard', new_callable=AsyncMock) as mock_call: + mock_call.return_value = (mock_response, {}) + + # Create a sample request that would trigger violations + data = { + "messages": [ + {"role": "user", "content": "Some harmful content that should be blocked"} + ], + "model": "gpt-3.5-turbo", + "metadata": {} + } + + # Mock objects needed for the pre-call hook + user_api_key_dict = UserAPIKeyAuth(api_key="test_key") + cache = DualCache() + + # With the fix, this should now raise an HTTPException instead of letting the request through + with pytest.raises(HTTPException) as exc_info: + await lakera_guardrail.async_pre_call_hook( + user_api_key_dict=user_api_key_dict, + cache=cache, + data=data, + call_type="completion" + ) + + # Verify the exception details + assert exc_info.value.status_code == 400 + assert "Violated guardrail policy" in str(exc_info.value.detail) + assert "lakera_guardrail_response" in exc_info.value.detail + + # Verify the full response is included in the exception + lakera_response = exc_info.value.detail["lakera_guardrail_response"] + assert lakera_response["flagged"] is True + assert lakera_response["metadata"]["request_uuid"] == "b7cd4c8a-28aa-4285-a245-2befee514dbf" + assert len(lakera_response["breakdown"]) == 16 # All the breakdown items from the user's scenario + diff --git a/tests/litellm_utils_tests/test_object_pooling.py b/tests/litellm_utils_tests/test_object_pooling.py index 5cdc38ee452..8d0272e92b9 100644 --- a/tests/litellm_utils_tests/test_object_pooling.py +++ b/tests/litellm_utils_tests/test_object_pooling.py @@ -3,6 +3,7 @@ Simplified tests for object pooling utilities in litellm. """ import pytest +pytest.importorskip("pond") from litellm.litellm_core_utils.object_pooling import ( get_object_pool, @@ -103,4 +104,4 @@ class TestObjectPooling: if __name__ == "__main__": - pytest.main([__file__]) \ No newline at end of file + pytest.main([__file__]) diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py index 91dff9f8636..d41448727d5 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -27,6 +27,7 @@ import litellm from litellm import ( ModelResponse, RateLimitError, + ServiceUnavailableError, Timeout, completion, completion_cost, @@ -2020,10 +2021,16 @@ def test_bedrock_context_window_error(): def test_bedrock_converse_route(): litellm.set_verbose = True - litellm.completion( - model="bedrock/converse/us.amazon.nova-pro-v1:0", - messages=[{"role": "user", "content": "Hello, world!"}], - ) + try: + litellm.completion( + model="bedrock/converse/us.amazon.nova-pro-v1:0", + messages=[{"role": "user", "content": "Hello, world!"}], + ) + except ServiceUnavailableError as e: + if "Too many requests" in str(e): + pytest.skip("Skipping test due to AWS Bedrock rate limiting") + else: + raise def test_bedrock_mapped_converse_models(): diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion.json index bede5753a2a..b995df2d445 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion.json @@ -68,7 +68,14 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 - } + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 + }, + "traceId": "litellm-test-6a51ae70-a4e7-499e-afcd-dce2a3b31850" }, "timestamp": "2025-01-16T19:28:55.125258Z" } diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json index aa96e5949e5..d9f52477fc8 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json @@ -63,7 +63,13 @@ "output": 10, "unit": "TOKENS", "totalCost": 0.00018 - } + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 10 + } }, "timestamp": "2025-05-26T21:13:16.797156Z" } diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json index 1e60a0479b1..348fe5956da 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json @@ -109,6 +109,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:27:51.703046Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json index a217a901285..b63bedf16a2 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_langfuse_metadata.json @@ -87,6 +87,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:19:11.235541Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json index be21c297dcc..f4242d7edc3 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json @@ -58,6 +58,12 @@ "output": 10, "unit": "TOKENS", "totalCost": 3.5e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 10 } }, "timestamp": "2025-02-07T00:23:27.670175Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json index 021c2b1b73c..84ea9768f01 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json @@ -73,6 +73,12 @@ "output": 10, "unit": "TOKENS", "totalCost": 3.5e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 10 } }, "timestamp": "2025-05-24T17:01:19.408586Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json index 07fca9daafb..e7442ce0a02 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json @@ -77,6 +77,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T15:31:28.964179Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json index d7003c3f99e..04c24c8963a 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json @@ -77,6 +77,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T16:38:26.017252Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json index 51a4fac0579..3e27f5b54b4 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json @@ -61,6 +61,12 @@ "output": 10, "unit": "TOKENS", "totalCost": 7.5e-06 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 10 } }, "timestamp": "2025-05-26T21:15:40.610953Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json index e8f01f6d723..a21ab058fcd 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json @@ -84,6 +84,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:59:39.368310Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json index 786fe20e7b4..3f400f65914 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json @@ -76,6 +76,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T18:06:50.959850Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json index 69db4314f0f..1873d4a6ea1 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json @@ -70,6 +70,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:59:32.889548Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json index eae3134555e..34e3c9f8daf 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json @@ -70,6 +70,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:59:36.162997Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json index 69db4314f0f..1873d4a6ea1 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json @@ -70,6 +70,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:59:32.889548Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json index c4fe594fa14..4d6ce12ec6d 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json @@ -76,6 +76,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:55:28.855732Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json index d348ac50392..5e5edc795ec 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json @@ -76,6 +76,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:53:53.754511Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json index d5468d302c9..7bbf4e4eeee 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json @@ -80,6 +80,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:56:35.478171Z" diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json index 1107766993a..fcdcb47aea5 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json @@ -84,6 +84,12 @@ "output": 20, "unit": "TOKENS", "totalCost": 5.4999999999999995e-05 + }, + "usageDetails": { + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "input": 10, + "output": 20 } }, "timestamp": "2025-01-22T17:56:38.787196Z" diff --git a/tests/pass_through_tests/test_anthropic_passthrough.py b/tests/pass_through_tests/test_anthropic_passthrough.py index e0549d17fa2..002fb20e9e8 100644 --- a/tests/pass_through_tests/test_anthropic_passthrough.py +++ b/tests/pass_through_tests/test_anthropic_passthrough.py @@ -43,8 +43,9 @@ async def test_anthropic_basic_completion_with_headers(): json.dumps(response_json, indent=4, default=str), ) reported_usage = response_json.get("usage", None) - anthropic_api_input_tokens = reported_usage.get("input_tokens", None) - anthropic_api_output_tokens = reported_usage.get("output_tokens", None) + # fix null checks for reported_usage + anthropic_api_input_tokens = reported_usage.get("input_tokens", None) if reported_usage else None + anthropic_api_output_tokens = reported_usage.get("output_tokens", None) if reported_usage else None litellm_call_id = response_headers.get("x-litellm-call-id") print(f"LiteLLM Call ID: {litellm_call_id}") diff --git a/tests/test_litellm/caching/test_in_memory_cache.py b/tests/test_litellm/caching/test_in_memory_cache.py index 72f264b8b7d..616c60c74a0 100644 --- a/tests/test_litellm/caching/test_in_memory_cache.py +++ b/tests/test_litellm/caching/test_in_memory_cache.py @@ -88,3 +88,101 @@ def test_in_memory_cache_ttl_allow_override(): new_ttl_time = in_memory_cache.ttl_dict["new-fake-key"] assert new_ttl_time is not None assert new_ttl_time != initial_ttl_time + + +def test_in_memory_cache_max_size_with_ttl(): + """ + Test that max_size_in_memory is respected even when all items have long TTLs. + This tests the fix for the unbounded growth issue. + """ + in_memory_cache = InMemoryCache(max_size_in_memory=3) + long_ttl = 86400 # 1 day + + # Fill the cache to max capacity + for i in range(3): + in_memory_cache.set_cache(key=f"key_{i}", value=f"value_{i}", ttl=long_ttl) + time.sleep(0.01) # Small delay to ensure different timestamps + + assert len(in_memory_cache.cache_dict) == 3 + assert len(in_memory_cache.ttl_dict) == 3 + + # Add another item - should evict the earliest item + in_memory_cache.set_cache(key="key_3", value="value_3", ttl=long_ttl) + + # Cache should still be at max size, not larger + assert len(in_memory_cache.cache_dict) == 3 + assert len(in_memory_cache.ttl_dict) == 3 + + # key_0 should have been evicted (it was added first) + assert "key_0" not in in_memory_cache.cache_dict + assert "key_0" not in in_memory_cache.ttl_dict + + # Other keys should still be present + assert "key_1" in in_memory_cache.cache_dict + assert "key_2" in in_memory_cache.cache_dict + assert "key_3" in in_memory_cache.cache_dict + + +def test_in_memory_cache_expired_items_evicted_first(): + """ + Test that expired items are evicted before non-expired items when cache is full. + """ + in_memory_cache = InMemoryCache(max_size_in_memory=3) + + # Add items with short TTL that will expire + in_memory_cache.set_cache(key="expired_1", value="value_1", ttl=1) + in_memory_cache.set_cache(key="expired_2", value="value_2", ttl=1) + + # Add item with long TTL + in_memory_cache.set_cache(key="long_lived", value="value_long", ttl=86400) + + assert len(in_memory_cache.cache_dict) == 3 + + # Wait for short TTL items to expire + time.sleep(2) + + # Add new item - should evict expired items first, not the long-lived one + in_memory_cache.set_cache(key="new_item", value="new_value", ttl=86400) + + # Long-lived item should still be present + assert "long_lived" in in_memory_cache.cache_dict + assert "new_item" in in_memory_cache.cache_dict + + # Expired items should be gone + assert "expired_1" not in in_memory_cache.cache_dict + assert "expired_2" not in in_memory_cache.cache_dict + assert "expired_1" not in in_memory_cache.ttl_dict + assert "expired_2" not in in_memory_cache.ttl_dict + + +def test_in_memory_cache_eviction_order(): + """ + Test that when non-expired items need to be evicted, those with earliest expiration times are evicted first. + """ + in_memory_cache = InMemoryCache(max_size_in_memory=2) + + # Add items with different TTLs + now = time.time() + in_memory_cache.set_cache(key="early_expire", value="value_1", ttl=100) # expires in 100 seconds + time.sleep(0.01) + in_memory_cache.set_cache(key="late_expire", value="value_2", ttl=200) # expires in 200 seconds + + # Verify TTL order + early_ttl = in_memory_cache.ttl_dict["early_expire"] + late_ttl = in_memory_cache.ttl_dict["late_expire"] + assert early_ttl < late_ttl, "early_expire should have earlier expiration time" + + assert len(in_memory_cache.cache_dict) == 2 + + # Add third item - should evict the one with earliest expiration time + in_memory_cache.set_cache(key="new_item", value="value_3", ttl=300) + + assert len(in_memory_cache.cache_dict) == 2 + + # Item with earliest expiration should be evicted + assert "early_expire" not in in_memory_cache.cache_dict + assert "early_expire" not in in_memory_cache.ttl_dict + + # Items with later expiration should remain + assert "late_expire" in in_memory_cache.cache_dict + assert "new_item" in in_memory_cache.cache_dict diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index 6cf7b98c2e6..fa2dc3e7190 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1,20 +1,222 @@ -import json -import os -import sys -from typing import Optional - -# Adds the grandparent directory to sys.path to allow importing project modules -sys.path.insert(0, os.path.abspath("../..")) - +import unittest import asyncio -from unittest.mock import patch - +from unittest.mock import patch, MagicMock +from typing import Optional +import sys +import os +import datetime +import json import pytest - import litellm from litellm.integrations.langfuse import langfuse as langfuse_module from litellm.integrations.langfuse.langfuse import LangFuseLogger +sys.path.insert(0, os.path.abspath("../..")) +from litellm.integrations.langfuse.langfuse import LangFuseLogger +# Import LangfuseUsageDetails directly from the module where it's defined +from litellm.types.integrations.langfuse import * + +class TestLangfuseUsageDetails(unittest.TestCase): + + def setUp(self): + # Set up environment variables for testing + self.env_patcher = patch.dict('os.environ', { + 'LANGFUSE_SECRET_KEY': 'test-secret-key', + 'LANGFUSE_PUBLIC_KEY': 'test-public-key', + 'LANGFUSE_HOST': 'https://test.langfuse.com' + }) + self.env_patcher.start() + + # Create mock objects + self.mock_langfuse_client = MagicMock() + self.mock_langfuse_trace = MagicMock() + self.mock_langfuse_generation = MagicMock() + + # Setup the trace and generation chain + self.mock_langfuse_trace.generation.return_value = self.mock_langfuse_generation + self.mock_langfuse_client.trace.return_value = self.mock_langfuse_trace + + # Mock the langfuse module that's imported locally in methods + self.langfuse_module_patcher = patch.dict('sys.modules', {'langfuse': MagicMock()}) + self.mock_langfuse_module = self.langfuse_module_patcher.start() + + # Create a mock for the langfuse module with version + self.mock_langfuse = MagicMock() + self.mock_langfuse.version = MagicMock() + self.mock_langfuse.version.__version__ = "3.0.0" # Set a version that supports all features + + # Mock the Langfuse class + self.mock_langfuse_class = MagicMock() + self.mock_langfuse_class.return_value = self.mock_langfuse_client + + # Set up the sys.modules['langfuse'] mock + sys.modules['langfuse'] = self.mock_langfuse + sys.modules['langfuse'].Langfuse = self.mock_langfuse_class + + # Mock the Langfuse client + self.mock_langfuse_client = MagicMock() + self.mock_langfuse_trace = MagicMock() + self.mock_langfuse_generation = MagicMock() + + # Setup the trace and generation chain + self.mock_langfuse_trace.generation.return_value = self.mock_langfuse_generation + self.mock_langfuse_client.trace.return_value = self.mock_langfuse_trace + + # Mock the Langfuse class + self.mock_langfuse_class = MagicMock() + self.mock_langfuse_class.return_value = self.mock_langfuse_client + self.mock_langfuse.Langfuse = self.mock_langfuse_class + + # Create the logger + self.logger = LangFuseLogger() + + # Add the log_event_on_langfuse method to the instance + def log_event_on_langfuse(self, kwargs, response_obj, start_time=None, end_time=None, user_id=None, level="DEFAULT", status_message=None): + # This implementation calls _log_langfuse_v2 directly + return self._log_langfuse_v2( + user_id=user_id, + metadata=kwargs.get("litellm_params", {}).get("metadata", {}), + litellm_params=kwargs.get("litellm_params", {}), + output=None, + start_time=start_time, + end_time=end_time, + kwargs=kwargs, + optional_params=kwargs.get("optional_params", {}), + input=None, + response_obj=response_obj, + level=level, + litellm_call_id=kwargs.get("litellm_call_id", None), + print_verbose=True # Add the missing parameter + ) + + # Bind the method to the instance + import types + self.logger.log_event_on_langfuse = types.MethodType(log_event_on_langfuse, self.logger) + + # Make sure _is_langfuse_v2 returns True + def mock_is_langfuse_v2(self): + return True + + self.logger._is_langfuse_v2 = types.MethodType(mock_is_langfuse_v2, self.logger) + + def tearDown(self): + self.env_patcher.stop() + self.langfuse_module_patcher.stop() + + def test_langfuse_usage_details_type(self): + """Test that LangfuseUsageDetails TypedDict is properly defined with the correct fields""" + # Create an instance of LangfuseUsageDetails + usage_details: LangfuseUsageDetails = { + "input": 10, + "output": 20, + "cache_creation_input_tokens": 5, + "cache_read_input_tokens": 3 + } + + # Verify all fields are present + self.assertEqual(usage_details["input"], 10) + self.assertEqual(usage_details["output"], 20) + self.assertEqual(usage_details["cache_creation_input_tokens"], 5) + self.assertEqual(usage_details["cache_read_input_tokens"], 3) + + # Test with all fields (all fields are required in TypedDict by default) + minimal_usage_details: LangfuseUsageDetails = { + "input": 10, + "output": 20, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + } + + self.assertEqual(minimal_usage_details["input"], 10) + self.assertEqual(minimal_usage_details["output"], 20) + + def test_log_langfuse_v2_usage_details(self): + """Test that usage_details in _log_langfuse_v2 is correctly typed and assigned""" + # Create a mock response object with usage information + response_obj = MagicMock() + response_obj.usage = MagicMock() + response_obj.usage.prompt_tokens = 15 + response_obj.usage.completion_tokens = 25 + + # Add the cache token attributes using get method + def mock_get(key, default=None): + if key == 'cache_creation_input_tokens': + return 7 + elif key == 'cache_read_input_tokens': + return 4 + return default + + response_obj.usage.get = mock_get + + # Create kwargs for the log_event method + kwargs = { + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello"}], + "litellm_params": {"metadata": {}} + } + + # Create start and end times + start_time = datetime.datetime.now() + end_time = start_time + datetime.timedelta(seconds=1) + + # Call the log_event method + with patch.object(self.logger, '_log_langfuse_v2') as mock_log_langfuse_v2: + self.logger.log_event_on_langfuse( + kwargs=kwargs, + response_obj=response_obj, + start_time=start_time, + end_time=end_time + ) + + # Check if _log_langfuse_v2 was called + mock_log_langfuse_v2.assert_called_once() + + # Get the arguments passed to _log_langfuse_v2 + call_args = mock_log_langfuse_v2.call_args[1] + + # Verify response_obj was passed correctly + self.assertEqual(call_args["response_obj"], response_obj) + + def test_langfuse_usage_details_optional_fields(self): + """Test that LangfuseUsageDetails fields are properly defined as Optional""" + # Create an instance with None values for optional fields + usage_details: LangfuseUsageDetails = { + "input": 10, + "output": 20, + "cache_creation_input_tokens": None, + "cache_read_input_tokens": None + } + + # Verify fields can be None + self.assertEqual(usage_details["input"], 10) + self.assertEqual(usage_details["output"], 20) + self.assertIsNone(usage_details["cache_creation_input_tokens"]) + self.assertIsNone(usage_details["cache_read_input_tokens"]) + + def test_langfuse_usage_details_structure(self): + """Test that LangfuseUsageDetails has the correct structure as defined in the commit""" + # This test directly verifies the structure of the TypedDict + # without relying on the LangFuseLogger class + + # Create a dictionary that matches the LangfuseUsageDetails structure + usage_details = { + "input": 15, + "output": 25, + "cache_creation_input_tokens": 7, + "cache_read_input_tokens": 4 + } + + # Verify the structure matches what we expect + self.assertIn("input", usage_details) + self.assertIn("output", usage_details) + self.assertIn("cache_creation_input_tokens", usage_details) + self.assertIn("cache_read_input_tokens", usage_details) + + # Verify the values + self.assertEqual(usage_details["input"], 15) + self.assertEqual(usage_details["output"], 25) + self.assertEqual(usage_details["cache_creation_input_tokens"], 7) + self.assertEqual(usage_details["cache_read_input_tokens"], 4) def test_max_langfuse_clients_limit(): """