diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index e733f48ffd7..5fbd787b3b6 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -238,7 +238,7 @@ def is_non_content_values_set(message: AllMessageValues) -> bool: return any(message.get(key, None) is not None for key in message if key not in ignore_keys) -_IMAGE_CONTENT_PART_TYPES: Final = frozenset({"image_url", "input_image", "image"}) +_IMAGE_CONTENT_PART_TYPES: Final = ("image_url", "input_image", "image", "computer_screenshot") _IMAGE_SCAN_MAX_DEPTH: Final = 4 @@ -557,15 +557,24 @@ def as_openai_image_part(part: Mapping[str, object]) -> ChatCompletionImageObjec return {"type": "image_url", "image_url": {"url": url}} if url else None -def request_contains_image_content(messages: Sequence[Mapping[str, object]]) -> bool: - """Whether any message carries an image content part, across the dialects that reach - pre-routing hooks untranslated: chat-completions ``image_url``, Responses ``input_image``, - and Anthropic Messages ``image``, including images nested inside ``tool_result`` blocks.""" - return any( - isinstance(content, list) and _content_parts_contain_image(content) - for message in messages - for content in (message.get("content"),) - ) +def _message_contains_image_content(message: object) -> bool: + match message: + case {"content": list()}: + return _content_parts_contain_image(content_parts(_CONTENT_PART.validate_python(message))) + case {"type": "function_call_output" | "custom_tool_call_output" | "tool_result", "output": list()}: + output: Final = _CONTENT_PART.validate_python(message)["output"] + return _content_parts_contain_image(_CONTENT_PARTS.validate_python(output)) + case {"type": "computer_call_output", "output": dict()}: + return _content_parts_contain_image((_CONTENT_PART.validate_python(message)["output"],)) + case _: + return False + + +def request_contains_image_content(messages: object) -> bool: + """Inspect native message content without translating or validating the request.""" + if not isinstance(messages, (list, tuple)): + return False + return any(_message_contains_image_content(message) for message in _CONTENT_PARTS.validate_python(messages)) def audio_or_image_in_message_content(message: AllMessageValues) -> bool: diff --git a/litellm/router.py b/litellm/router.py index 65c61cf5954..b648dbaea8f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -15040,17 +15040,12 @@ class Router(DeprecatedRouterMembers): @staticmethod def _request_carries_image( - messages: list[dict[str, str]] | None, # mutable-ok: handed to resolve_structured_messages as-is + messages: object, request_kwargs: Mapping[str, object], ) -> bool: from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content - from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages - structured: Final = resolve_structured_messages( - messages=cast(list[dict[str, object]] | None, messages), # cast-ok: chat messages are plain dicts - request_kwargs=cast(dict[str, object], request_kwargs), # cast-ok: the resolver only reads the mapping - ) - return bool(structured) and request_contains_image_content(structured) + return request_contains_image_content(messages or request_kwargs.get("messages") or request_kwargs.get("input")) @staticmethod def _pre_call_check_requested_chat_params(request_kwargs: Mapping[str, object]) -> frozenset[str]: diff --git a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 4ce6aee1619..1ef97370b2b 100644 --- a/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/unit/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -2001,6 +2001,12 @@ class TestRequestContainsImageContent: [{"role": "user", "content": [{"type": "input_audio", "input_audio": {"data": "x"}}]}], [{"role": "user", "content": [{"type": "tool_result", "content": [{"type": "text", "text": "ok"}]}]}], [{"role": "user", "content": None}], + [{"role": "user", "content": [None, "text", {"type": []}]}], + ["malformed"], + [None, 5], + "malformed", + 5, + None, [], ], ) diff --git a/tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py b/tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py index f808d07efd3..21cbe8667ff 100644 --- a/tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py +++ b/tests/unit/proxy/hooks/test_unit_test_max_model_budget_limiter.py @@ -926,19 +926,37 @@ async def test_bedrock_traffic_charges_the_bare_family_name_budget(): @pytest.mark.asyncio -async def test_user_model_budget_window_resets_when_the_period_elapses(): - """ - A monthly user budget must start a fresh window once the period elapses, - and the window start must be scoped to that one budget model so a second - model on a shorter period cannot drag it forward. - """ +@pytest.mark.parametrize( + ("charged_at", "expired_window_start", "next_reset"), + [ + ( + datetime(2026, 10, 31, tzinfo=timezone.utc), + datetime(2026, 9, 30, tzinfo=timezone.utc), + datetime(2026, 11, 30, tzinfo=timezone.utc), + ), + ( + datetime(2024, 3, 1, tzinfo=timezone.utc), + datetime(2024, 1, 31, tzinfo=timezone.utc), + datetime(2024, 4, 1, tzinfo=timezone.utc), + ), + ( + datetime(2025, 3, 1, tzinfo=timezone.utc), + datetime(2025, 1, 31, tzinfo=timezone.utc), + datetime(2025, 4, 1, tzinfo=timezone.utc), + ), + ], + ids=["shorter-current-month", "leap-february", "non-leap-february"], +) +async def test_user_model_budget_window_resets_when_the_period_elapses( + charged_at: datetime, expired_window_start: datetime, next_reset: datetime +) -> None: from litellm.proxy.hooks.model_max_budget_limiter import ( model_budget_spend_cache_key, model_budget_start_time_cache_key, ) dual_cache = DualCache() - limiter = PROXY_VirtualKeyModelMaxBudgetLimiter(dual_cache=dual_cache) + limiter = PROXY_VirtualKeyModelMaxBudgetLimiter(dual_cache=dual_cache, clock=charged_at.timestamp) user_id = "user-1" user_model_max_budget = {"gpt-4": {"budget_limit": 1.0, "time_period": "1mo"}} spend_key = model_budget_spend_cache_key( @@ -969,13 +987,10 @@ async def test_user_model_budget_window_resets_when_the_period_elapses(): model="gpt-4", ) - # Age the window past its period. The next charge opens a new window rather - # than adding to the exhausted one. - elapsed = int(timedelta(days=31, minutes=1).total_seconds()) await dual_cache.async_set_cache( key=start_time_key, - value=datetime.now(timezone.utc).timestamp() - elapsed, - ttl=elapsed, + value=expired_window_start.timestamp(), + ttl=60, ) await limiter.async_log_success_event(kwargs, response_obj=None, start_time=None, end_time=None) @@ -985,14 +1000,13 @@ async def test_user_model_budget_window_resets_when_the_period_elapses(): entity_id=user_id, model_max_budget=user_model_max_budget, cache=dual_cache, + now=charged_at, ) == { "gpt-4": { "current_spend": 1.5, "budget_limit": 1.0, "time_period": "1mo", - "budget_reset_at": await _reset_at_of_open_window( - dual_cache, Litellm_EntityType.USER, user_id, "gpt-4", "1mo" - ), + "budget_reset_at": next_reset.isoformat(), } } diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 7ac27e22a93..9bfa78ab288 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -31328,7 +31328,7 @@ def _candidate_ids( request_kwargs: dict[str, object] | None = None, ) -> list[str]: candidates: Final = router._without_declared_text_only_deployments( - router.get_model_list(model_name="mixed"), "mixed", messages, request_kwargs + router._core_get_model_list(model_name="mixed"), "mixed", messages, request_kwargs ) return [d["model_info"]["id"] for d in candidates] @@ -31812,3 +31812,65 @@ def test_router_warns_when_a_deployment_enforces_endpoints_without_a_list(caplog assert "Deployment 'embed-from-config' sets enforce_supported_endpoints without" in caplog.text assert "Deployment 'embed-from-api' sets enforce_supported_endpoints without" in caplog.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["sync", "async"]) +@pytest.mark.parametrize("metadata_key", [None, "metadata", "litellm_metadata"]) +@pytest.mark.parametrize( + ("route", "messages", "request_kwargs"), + [ + ("/v1/moderations", None, {"input": ["first", "second"]}), + ("/v1/embeddings", None, {"messages": []}), + ("/v1/messages", ["malformed"], {}), + ("/v1/messages", "malformed", {}), + ("/v1/messages", 5, {}), + ("/v1/messages", None, {"messages": ["malformed"]}), + ("/v1/responses", None, {"input": [{"type": "computer_call_output", "output": {"type": "text", "text": "no image"}}]}), + ("/v1/responses", None, {"input": [{"type": "computer_call_output", "output": "malformed"}]}), + ], + ids=["moderation-list", "embedding-empty-messages", "message-entry", "message-string", "message-number", "kwargs-messages", "computer-output-text", "computer-output-string"], +) +async def test_image_routing_leaves_non_image_and_malformed_requests_to_the_endpoint( + mode: str, metadata_key: str | None, route: str, messages: object, request_kwargs: dict[str, object] +) -> None: + router: Final = _mixed_router( + {**_DECLARED_TEXT_LEG, "litellm_params": {**_DECLARED_TEXT_LEG["litellm_params"], "order": 0}}, + {**_VISION_LEG, "litellm_params": {**_VISION_LEG["litellm_params"], "order": 1}}, + ) + kwargs: Final = { + **request_kwargs, + **({metadata_key: {"user_api_key_request_route": route}} if metadata_key is not None else {}), + } + original: Final = copy.deepcopy(request_kwargs) + deployment: Final = ( + await router._core_async_get_available_deployment(model="mixed", messages=messages, request_kwargs=kwargs) + if mode == "async" + else router.get_available_deployment(model="mixed", messages=messages, request_kwargs=kwargs) + ) + assert deployment["model_info"]["id"] == "declared-text" + assert all(kwargs[key] == value for key, value in original.items()) + + +@pytest.mark.parametrize( + "request_kwargs", + [ + {"messages": _IMAGE_CHAT}, + {"input": _IMAGE_RESPONSES_INPUT}, + {"messages": [{"role": "user", "content": [{"type": "image", "source": {"type": "url", "url": "https://example.invalid/image.png"}}]}]}, + {"messages": [{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_image", "content": [_IMAGE_PART]}]}]}, + {"input": [{"type": "function_call_output", "call_id": "call_image", "output": [{"type": "input_image", "image_url": "https://example.invalid/image.png"}]}]}, + {"input": [{"type": "custom_tool_call_output", "call_id": "call_image", "output": [{"type": "input_image", "image_url": "https://example.invalid/image.png"}]}]}, + {"input": [{"type": "computer_call_output", "call_id": "call_image", "output": {"type": "computer_screenshot", "image_url": "https://example.invalid/image.png"}}]}, + {"input": [{"role": "user", "content": [{"type": "computer_screenshot", "image_url": "https://example.invalid/image.png"}]}]}, + {"input": [{"type": "function_call_output", "call_id": "call_image", "output": [{"type": "computer_screenshot", "image_url": "https://example.invalid/image.png"}]}]}, + {"input": [{"type": "custom_tool_call_output", "call_id": "call_image", "output": [{"type": "computer_screenshot", "image_url": "https://example.invalid/image.png"}]}]}, + {"input": [{"type": "computer_call_output", "call_id": "call_image", "output": {"type": "computer_screenshot", "url": "https://example.invalid/image.png"}}]}, + ], + ids=["chat", "responses", "anthropic", "tool-result", "function-output", "custom-output", "computer-output", "screenshot-content", "screenshot-function-output", "screenshot-custom-output", "screenshot-url"], +) +def test_image_routing_inspects_native_request_content_without_translation(request_kwargs: dict[str, object]) -> None: + router: Final = _mixed_router(_VISION_LEG, _DECLARED_TEXT_LEG) + original: Final = copy.deepcopy(request_kwargs) + assert _candidate_ids(router, request_kwargs=request_kwargs) == ["vision"] + assert request_kwargs == original