diff --git a/litellm/decisions/call.py b/litellm/decisions/call.py index ae01aa8d0f7..871fa8227ea 100644 --- a/litellm/decisions/call.py +++ b/litellm/decisions/call.py @@ -3,10 +3,10 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field, replace from functools import partial from types import MappingProxyType -from typing import Final, Literal, TypeAlias +from typing import Annotated, Final, Literal, TypeAlias import httpx -from pydantic import BaseModel, ConfigDict, StrictBool, TypeAdapter, ValidationError +from pydantic import BaseModel, BeforeValidator, ConfigDict, TypeAdapter, ValidationError from pydantic_core import to_jsonable_python from typing_extensions import assert_never @@ -23,6 +23,7 @@ from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.llms.base_llm.decisions.transformation import ( BaseDecisionsConfig, DecisionsResponseValidationError, + catalog_mode, image_media_type, image_param, input_images, @@ -61,7 +62,7 @@ from litellm.types.decisions import ( UnsupportedDecisionsRequest, ) from litellm.types.llms.openai import ChatCompletionUserMessage -from litellm.types.utils import LlmProviders, ProviderSpecificHeader +from litellm.types.utils import LlmProviders, ProviderSpecificHeader, is_decisions_model_mode from litellm.utils import ProviderConfigManager, get_model_cost_key SystemOneQuestions: TypeAlias = Mapping[str, DecisionQuestion | Mapping[str, object]] @@ -257,19 +258,105 @@ def _request_safety_identifier( return None -def _fields_without_unsupported_media( - fields: RequestFields, +def _bool_or_none(value: object) -> bool | None: + return value if isinstance(value, bool) else None + + +def _str_or_none(value: object) -> str | None: + return value if isinstance(value, str) else None + + +_DeploymentFlag: TypeAlias = Annotated[bool | None, BeforeValidator(_bool_or_none)] + + +class _DeploymentCapabilities(BaseModel): + model_config = ConfigDict(frozen=True, from_attributes=True) + + mode: Annotated[str | None, BeforeValidator(_str_or_none)] = None + supports_box_questions: _DeploymentFlag = None + supports_audio_input: _DeploymentFlag = None + supports_video_input: _DeploymentFlag = None + + def decisions_media_flag(self, flag: bool | None, model: str, custom_llm_provider: str) -> bool | None: + if flag is None: + return None + mode: Final = self.mode if self.mode is not None else catalog_mode(model, custom_llm_provider) + return flag if mode is None or is_decisions_model_mode(mode) else None + + +@dataclass(frozen=True, slots=True) +class _ResolvedSupport: + box_questions: bool + audio: bool + videos: bool + + +def _takes_audio( + provider_config: BaseDecisionsConfig, model: str, custom_llm_provider: str, deployment: _DeploymentCapabilities +) -> bool: + return provider_config.supports_audio_input( + model, + custom_llm_provider, + deployment.decisions_media_flag(deployment.supports_audio_input, model, custom_llm_provider), + ) + + +def _takes_videos( + provider_config: BaseDecisionsConfig, model: str, custom_llm_provider: str, deployment: _DeploymentCapabilities +) -> bool: + return provider_config.supports_video_input( + model, + custom_llm_provider, + deployment.decisions_media_flag(deployment.supports_video_input, model, custom_llm_provider), + ) + + +def deployment_refuses_media( + model: str, + custom_llm_provider: str, + model_info: Mapping[str, object], + call_kwargs: Mapping[str, object], + *, + audio: bool, + videos: bool, +) -> bool: + if _drops_params(call_kwargs): + return False + provider_config: Final = get_decisions_provider_config(model, custom_llm_provider) + canonical_model: Final = provider_config.canonical_model(model) + deployment: Final = _DeploymentCapabilities.model_validate(model_info) + return (audio and not _takes_audio(provider_config, canonical_model, custom_llm_provider, deployment)) or ( + videos and not _takes_videos(provider_config, canonical_model, custom_llm_provider, deployment) + ) + + +def _resolved_support( provider_config: BaseDecisionsConfig, model: str, custom_llm_provider: str, + headers: Mapping[str, str], kwargs: Mapping[str, object], + fields: RequestFields, +) -> _ResolvedSupport: + deployment: Final = _DeploymentCapabilities.model_validate(kwargs.get("model_info") or {}) + return _ResolvedSupport( + box_questions=provider_config.supports_box_questions( + model, custom_llm_provider, headers, deployment.supports_box_questions + ), + audio=bool(fields.audio) and _takes_audio(provider_config, model, custom_llm_provider, deployment), + videos=bool(fields.videos) and _takes_videos(provider_config, model, custom_llm_provider, deployment), + ) + + +def _fields_without_unsupported_media( + fields: RequestFields, support: _ResolvedSupport, kwargs: Mapping[str, object] ) -> RequestFields: if not _drops_params(kwargs): return fields return replace( fields, - audio=fields.audio if provider_config.supports_audio_input(model, custom_llm_provider) else None, - videos=fields.videos if provider_config.supports_video_input(model, custom_llm_provider) else None, + audio=fields.audio if support.audio else None, + videos=fields.videos if support.videos else None, ) @@ -292,10 +379,8 @@ class _MalformedImages: def _unsupported_params( ir_request: DecisionsIRRequest, provider_config: BaseDecisionsConfig, - model: str, - custom_llm_provider: str, headers: Mapping[str, str], - supports_box_questions: bool, + support: _ResolvedSupport, ) -> tuple[str, ...]: refuses_safety_identifier: Final = ( ir_request.safety_identifier is not None and not provider_config.supports_safety_identifier @@ -304,13 +389,9 @@ def _unsupported_params( None if provider_config.supports_images_in(ir_request.input, headers) else image_param(ir_request.input) ) refuses_assets: Final = bool(ir_request.assets) and not provider_config.supports_assets(headers) - refuses_box_questions: Final = _has_box_question(ir_request.questions) and not supports_box_questions - refuses_audio: Final = bool(ir_request.audio) and not provider_config.supports_audio_input( - model, custom_llm_provider - ) - refuses_videos: Final = bool(ir_request.videos) and not provider_config.supports_video_input( - model, custom_llm_provider - ) + refuses_box_questions: Final = _has_box_question(ir_request.questions) and not support.box_questions + refuses_audio: Final = bool(ir_request.audio) and not support.audio + refuses_videos: Final = bool(ir_request.videos) and not support.videos return ( *(("safety_identifier",) if refuses_safety_identifier else ()), *(() if refused_image_param is None else (refused_image_param,)), @@ -333,7 +414,7 @@ def _without_unsupported_params( ir_request: DecisionsIRRequest, provider_config: BaseDecisionsConfig, headers: Mapping[str, str], - supports_box_questions: bool, + support: _ResolvedSupport, ) -> DecisionsIRRequest: return replace( ir_request, @@ -345,9 +426,7 @@ def _without_unsupported_params( safety_identifier=ir_request.safety_identifier if provider_config.supports_safety_identifier else None, assets=ir_request.assets if provider_config.supports_assets(headers) else MappingProxyType({}), questions=( - ir_request.questions - if supports_box_questions - else tuple(filter(_is_not_box_question, ir_request.questions)) + ir_request.questions if support.box_questions else tuple(filter(_is_not_box_question, ir_request.questions)) ), ) @@ -368,39 +447,20 @@ def _without_malformed_images( return well_formed -class _DeploymentBoxQuestions(BaseModel): - model_config = ConfigDict(frozen=True, from_attributes=True) - - supports_box_questions: StrictBool | None = None - - -def _deployment_supports_box_questions(kwargs: Mapping[str, object]) -> bool | None: - try: - return _DeploymentBoxQuestions.model_validate(kwargs.get("model_info") or {}).supports_box_questions - except ValidationError: - return None - - def _provider_ir_request( ir_request: DecisionsIRRequest, *, provider_config: BaseDecisionsConfig, - model: str, - custom_llm_provider: str, headers: Mapping[str, str], + support: _ResolvedSupport, kwargs: Mapping[str, object], ) -> DecisionsIRRequest | _UnsupportedParams | _NothingLeftAfterDrop: - supports_box_questions: Final = provider_config.supports_box_questions( - model, custom_llm_provider, headers, _deployment_supports_box_questions(kwargs) - ) - unsupported: Final = _unsupported_params( - ir_request, provider_config, model, custom_llm_provider, headers, supports_box_questions - ) + unsupported: Final = _unsupported_params(ir_request, provider_config, headers, support) if not unsupported: return ir_request if not _drops_params(kwargs): return _UnsupportedParams(names=unsupported) - supported: Final = _without_unsupported_params(ir_request, provider_config, headers, supports_box_questions) + supported: Final = _without_unsupported_params(ir_request, provider_config, headers, support) if _leaves_no_input(supported.input): return _NothingLeftAfterDrop(names=unsupported, missing="input to decide on") if not supported.questions: @@ -479,10 +539,11 @@ def prepare_call( raise litellm.BadRequestError(message=str(error), model=model, llm_provider=provider) from error try: headers: Final = decisions_outbound_headers(extra_headers, provider, kwargs) + support: Final = _resolved_support(provider_config, canonical_model, provider, headers, kwargs, fields) request_safety_identifier: Final = _request_safety_identifier(safety_identifier, provider_config, kwargs) - request: Final = _fields_without_unsupported_media( - fields, provider_config, canonical_model, provider, kwargs - ).validate(safety_identifier=request_safety_identifier, drops_params=_drops_params(kwargs)) + request: Final = _fields_without_unsupported_media(fields, support, kwargs).validate( + safety_identifier=request_safety_identifier, drops_params=_drops_params(kwargs) + ) except ValidationError as error: raise litellm.BadRequestError( message=f"Invalid Decisions request: {error}", @@ -523,12 +584,7 @@ def prepare_call( llm_provider=provider, ) ir_request: Final = _provider_ir_request( - caller_ir_request, - provider_config=provider_config, - model=canonical_model, - custom_llm_provider=provider, - headers=headers, - kwargs=kwargs, + caller_ir_request, provider_config=provider_config, headers=headers, support=support, kwargs=kwargs ) if isinstance(ir_request, _UnsupportedParams): raise litellm.UnsupportedParamsError( diff --git a/litellm/llms/base_llm/decisions/transformation.py b/litellm/llms/base_llm/decisions/transformation.py index efd3e3fe9b5..c562c8d0f04 100644 --- a/litellm/llms/base_llm/decisions/transformation.py +++ b/litellm/llms/base_llm/decisions/transformation.py @@ -12,6 +12,7 @@ from pydantic_core import ErrorDetails from typing_extensions import assert_never import litellm +from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap from litellm.litellm_core_utils.url_utils import join_api_base from litellm.llms.base_llm.chat.transformation import BaseLLMException from litellm.secret_managers.main import get_secret_str @@ -297,13 +298,7 @@ def _systemone_question(question: DecisionsIRQuestion) -> Mapping[str, object]: assert_never(question) -def ir_to_systemone_request( - model: str, request: DecisionsIRRequest, *, supports_audio: bool = False, supports_video: bool = False -) -> Mapping[str, object] | UnsupportedDecisionsRequest: - if request.audio and not supports_audio: - return UnsupportedDecisionsRequest(reason="audio input is not supported by this Decisions model") - if request.videos and not supports_video: - return UnsupportedDecisionsRequest(reason="video input is not supported by this Decisions model") +def ir_to_systemone_request(model: str, request: DecisionsIRRequest) -> Mapping[str, object]: state: Final = _systemone_state(request.input) keyed_questions: Final = zip(systemone_keys(request.questions), request.questions, strict=True) images: Final = input_images(request.input) @@ -503,6 +498,16 @@ def ir_to_systemone_response(response: DecisionsIRResponse, request: DecisionsIR ) +def catalog_mode(model: str, custom_llm_provider: str) -> str | None: + try: + key: Final = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)["key"] + except Exception: # noqa: BLE001 # get_model_info raises bare Exception for an unmapped model, which the catalog does not list + return None + entry: Final = GetModelCostMap.loaded_model_cost_map().get(key) + mode: Final = entry.get("mode") if entry is not None else None + return mode if isinstance(mode, str) else None + + def _is_decisions_model(model: str, custom_llm_provider: str) -> bool: try: model_mode: Final = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider).get("mode") @@ -640,12 +645,16 @@ class BaseDecisionsConfig(ABC): model=model, custom_llm_provider=custom_llm_provider ) - def supports_audio_input(self, model: str, custom_llm_provider: str) -> bool: + def supports_audio_input(self, model: str, custom_llm_provider: str, deployment_flag: bool | None) -> bool: + if deployment_flag is not None: + return deployment_flag return _is_decisions_model(model, custom_llm_provider) and litellm.supports_audio_input( model=model, custom_llm_provider=custom_llm_provider ) - def supports_video_input(self, model: str, custom_llm_provider: str) -> bool: + def supports_video_input(self, model: str, custom_llm_provider: str, deployment_flag: bool | None) -> bool: + if deployment_flag is not None: + return deployment_flag return _is_decisions_model(model, custom_llm_provider) and litellm.supports_video_input( model=model, custom_llm_provider=custom_llm_provider ) @@ -656,12 +665,7 @@ class BaseDecisionsConfig(ABC): request: DecisionsIRRequest, custom_llm_provider: str, ) -> Mapping[str, object] | UnsupportedDecisionsRequest: - return ir_to_systemone_request( - self.request_model(model), - request, - supports_audio=self.supports_audio_input(model, custom_llm_provider), - supports_video=self.supports_video_input(model, custom_llm_provider), - ) + return ir_to_systemone_request(self.request_model(model), request) def unwrap_response(self, payload: object) -> object: return payload diff --git a/litellm/llms/openai/decisions/transformation.py b/litellm/llms/openai/decisions/transformation.py index 4518fe6a77e..369b6959411 100644 --- a/litellm/llms/openai/decisions/transformation.py +++ b/litellm/llms/openai/decisions/transformation.py @@ -354,10 +354,10 @@ class OpenAIDecisionsConfig(BaseDecisionsConfig): ) -> str | None: return resolve_openai_bearer_token(api_key=api_key, api_base=api_base, litellm_params=litellm_params) - def supports_audio_input(self, model: str, custom_llm_provider: str) -> bool: + def supports_audio_input(self, model: str, custom_llm_provider: str, deployment_flag: bool | None) -> bool: return False - def supports_video_input(self, model: str, custom_llm_provider: str) -> bool: + def supports_video_input(self, model: str, custom_llm_provider: str, deployment_flag: bool | None) -> bool: return False def transform_decisions_request( diff --git a/litellm/router.py b/litellm/router.py index 535072d5fcf..6392ea8cd90 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -635,6 +635,7 @@ _PRE_CALL_CHECK_FILTERED_PARAMS: Final = frozenset({"response_format"}) _STREAM_TIMEOUT_AWARE_GENERIC_HANDLERS: Final = frozenset({"anthropic_messages", "aresponses", "responses"}) _video_call_deployment_id: Final[ContextVar[str | None]] = ContextVar("video_call_deployment_id", default=None) _routed_call_type: Final[ContextVar[str | None]] = ContextVar("routed_call_type", default=None) +_DECISIONS_MEDIA_CALL_TYPES: Final = frozenset({"adecisions", "decisions", "asystemone", "systemone"}) _ROUTE_CAPABILITY_RESOLVERS: Final[Mapping[str, Callable[[str, LlmProviders], object | None]]] = MappingProxyType( { "adecisions": ProviderConfigManager.get_provider_decisions_config, @@ -4153,13 +4154,16 @@ class Router(DeprecatedRouterMembers): raise e.original_exception from e raise e + def _active_default_litellm_params(self) -> Mapping[str, object]: + default_params: Final = _REQUEST_KWARGS_ADAPTER.validate_python(self._core_default_litellm_params) + return {key: value for key, value in default_params.items() if value is not None} + def _params_sent_to_deployment( self, request_kwargs: Mapping[str, object], deployment: Deployment | None ) -> Mapping[str, object]: """The request as the deployment received it, merged in the order _update_kwargs_with_deployment applies.""" deployment_params: Final = deployment.litellm_params.model_dump(exclude_none=True) if deployment else {} - default_params: Final = _REQUEST_KWARGS_ADAPTER.validate_python(self._core_default_litellm_params) - defaults: Final = {key: value for key, value in default_params.items() if value is not None} + defaults: Final = self._active_default_litellm_params() deployment_tool_choice: Final = ( {"tool_choice": deployment_params["tool_choice"]} if deployment_params.get("tool_choice") is not None @@ -14911,6 +14915,62 @@ class Router(DeprecatedRouterMembers): return healthy_deployments return cast(_RouteDeploymentsT, filtered_deployments) # cast-ok: same list shape as the input candidates + @staticmethod + def _decisions_deployment_refuses_media( + deployment: DeploymentTypedDict, group: str, request_kwargs: Mapping[str, object], *, audio: bool, videos: bool + ) -> bool: + from litellm.decisions.call import deployment_refuses_media + + plain_deployment: Final = cast(Mapping[str, object], deployment) # cast-ok: router deployments are plain dicts + model_info: Final = cast(Mapping[str, object], plain_deployment.get("model_info") or {}) # cast-ok: plain dict + deployment_params: Final = cast(Mapping[str, object], deployment["litellm_params"]) # cast-ok: plain dict + try: + model, custom_llm_provider = Router._capability_model_and_provider(deployment["litellm_params"], group) + return deployment_refuses_media( + model, + custom_llm_provider, + model_info, + {**deployment_params, **request_kwargs}, + audio=audio, + videos=videos, + ) + except Exception as e: # noqa: BLE001 # best-effort filter: a deployment it cannot resolve answers for itself + verbose_router_logger.debug( + "litellm.router.py::_decisions_deployment_refuses_media: keeping a deployment of model=%s. Got - %s", + group, + e, + ) + return False + + def _without_decisions_deployments_refusing_media( + self, healthy_deployments: _RouteDeploymentsT, group: str, request_kwargs: Mapping[str, object] | None + ) -> _RouteDeploymentsT: + call_kwargs: Final = request_kwargs or {} + audio: Final = bool(call_kwargs.get("audio")) + videos: Final = bool(call_kwargs.get("videos")) + if ( + _routed_call_type.get() not in _DECISIONS_MEDIA_CALL_TYPES + or not (audio or videos) + or isinstance(healthy_deployments, Mapping) + ): + return healthy_deployments + + sent_kwargs: Final = {**self._active_default_litellm_params(), **call_kwargs} + deployments_taking_media: Final = [ + deployment + for deployment in healthy_deployments + if not Router._decisions_deployment_refuses_media( + cast(DeploymentTypedDict, deployment), # cast-ok: router candidates use the deployment shape + group, + sent_kwargs, + audio=audio, + videos=videos, + ) + ] + if not deployments_taking_media: + return healthy_deployments + return cast(_RouteDeploymentsT, deployments_taking_media) # cast-ok: same list shape as the input candidates + @staticmethod def _without_declared_text_only_deployments( healthy_deployments: _RouteDeploymentsT, @@ -15635,10 +15695,14 @@ class Router(DeprecatedRouterMembers): ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2) _target_order: Final = (request_kwargs or {}).pop("_target_order", None) - batch_candidates: Final = self._without_declared_text_only_deployments( - cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts + batch_candidates: Final = self._without_decisions_deployments_refusing_media( + self._without_declared_text_only_deployments( + cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts + model, + messages, + cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict + ), model, - messages, cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict ) embedding_batch_admitting: Final = deployments_admitting_embedding_batch( @@ -16878,10 +16942,14 @@ class Router(DeprecatedRouterMembers): ## ORDER FILTERING ## -> if user set 'order' in deployments, return deployments with lowest order (e.g. order=1 > order=2) _target_order: Final = (request_kwargs or {}).pop("_target_order", None) - batch_candidates: Final = self._without_declared_text_only_deployments( - cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts + batch_candidates: Final = self._without_decisions_deployments_refusing_media( + self._without_declared_text_only_deployments( + cast(list[dict[str, object]], healthy_deployments), # cast-ok: router deployments are plain dicts + model, + messages, + cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict + ), model, - messages, cast(Mapping[str, object] | None, request_kwargs), # cast-ok: legacy signature uses a bare dict ) embedding_batch_admitting: Final = deployments_admitting_embedding_batch( diff --git a/tests/code_coverage_tests/router_code_coverage.py b/tests/code_coverage_tests/router_code_coverage.py index 69ad0c91ee9..3a708ce6d0a 100644 --- a/tests/code_coverage_tests/router_code_coverage.py +++ b/tests/code_coverage_tests/router_code_coverage.py @@ -183,6 +183,9 @@ ignored_function_names = [ "_aresponses_background_stream_can_release", # Tested through the background Responses stream tests in test_router.py "_raise_released_stream_error", # Tested through the background Responses stream content-policy tests in test_router.py "_mid_stream_error_before_output", # Tested through the Responses mid-stream fallback and background stream tests + "_without_decisions_deployments_refusing_media", # Tested through the mixed media-flag group tests in tests/unit/decisions/test_main.py + "_decisions_deployment_refuses_media", # Read by _without_decisions_deployments_refusing_media, tested through the same group tests + "_active_default_litellm_params", # Tested through the router default drop_params test in tests/unit/decisions/test_main.py ] diff --git a/tests/integration/providers/test_decisions_media_deployment_flag_wire.py b/tests/integration/providers/test_decisions_media_deployment_flag_wire.py new file mode 100644 index 00000000000..a2e53ef47ca --- /dev/null +++ b/tests/integration/providers/test_decisions_media_deployment_flag_wire.py @@ -0,0 +1,123 @@ +import json +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Final, Literal + +import httpx +import pytest +from integration._support.client import Gateway, eventually, object_value +from integration._support.database import read_rows +from integration._support.wire import Reply, Request, wire_server +from pydantic import JsonValue, TypeAdapter + +_API_KEY: Final = "synthetic-decisions-key" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_INPUT: Final = "Listen to the attached clip." +_AUDIO: Final = ("data:audio/wav;base64,UklGRiQAAABXQVZF",) +_VIDEOS: Final = ("data:video/mp4;base64,AAAAIGZ0eXBpc29t",) +_QUESTION: Final[dict[str, JsonValue]] = { + "type": "predicate", + "name": "banana", + "instructions": "Does the clip name a banana?", +} +_SPEND_QUERY: Final = 'SELECT status, model_group FROM "LiteLLM_SpendLogs" WHERE request_id = %s' + +Route = Literal["/v1/decisions", "/v1/systemone"] +Media = Literal["audio", "videos"] + + +@dataclass(frozen=True, slots=True) +class _Upstream: + model: str + + def reply(self, request: Request) -> Reply: + questions: Final = object_value(_JSON_OBJECT.validate_json(request.body)["questions"]) + plain: Final[dict[str, JsonValue]] = { + "model": self.model, + "answers": {name: {"type": "noul", "noul": 0.9} for name in questions}, + "usage": {"input_tokens": 41, "output_tokens": 0}, + } + return Reply(body=json.dumps(plain).encode()) + + +def _unlisted_openrouter_model() -> _Upstream: + return _Upstream(f"openrouter/example/omni-{uuid.uuid4().hex[:12]}") + + +def _decide(gateway: Gateway, model: str, route: Route, media: Mapping[str, JsonValue]) -> httpx.Response: + body: Final[dict[str, JsonValue]] = ( + {"model": model, "input": _INPUT, "questions": [_QUESTION], "num_retries": 0, **media} + if route == "/v1/decisions" + else { + "model": model, + "state": _INPUT, + "questions": {"banana": {"type": "noul", "instructions": _QUESTION["instructions"]}}, + "num_retries": 0, + **media, + } + ) + return gateway.request("POST", route, body) + + +def _sent_media(request: Request) -> dict[str, JsonValue]: + body: Final = _JSON_OBJECT.validate_json(request.body) + return {kind: body[kind] for kind in ("audio", "videos") if kind in body} + + +def _assert_refused(response: httpx.Response, kinds: tuple[Media, ...]) -> None: + assert response.status_code == 400, response.text + assert f"does not support parameters: {list(kinds)}" in response.text, response.text + + +_BOTH: Final[dict[str, JsonValue]] = {"audio": list(_AUDIO), "videos": list(_VIDEOS)} + + +@pytest.mark.parametrize("route", ("/v1/decisions", "/v1/systemone")) +def test_a_deployment_flagged_for_audio_and_video_sends_both_clips_for_a_model_the_cost_map_does_not_list( + gateway: Gateway, route: Route +) -> None: + upstream: Final = _unlisted_openrouter_model() + with wire_server(upstream.reply) as wire, gateway.scenario() as scenario: + flags: Final[dict[str, JsonValue]] = {"supports_audio_input": True, "supports_video_input": True} + flagged: Final = scenario.model(model=upstream.model, api_base=wire.url, api_key=_API_KEY, model_info=flags) + flagged_dropping: Final = scenario.model( + model=upstream.model, api_base=wire.url, api_key=_API_KEY, model_info=flags, drop_params=True + ) + audio_only: Final = scenario.model( + model=upstream.model, api_base=wire.url, api_key=_API_KEY, model_info={"supports_audio_input": True} + ) + unflagged: Final = scenario.model(model=upstream.model, api_base=wire.url, api_key=_API_KEY) + + _assert_refused(_decide(gateway, unflagged, route, _BOTH), ("audio", "videos")) + _assert_refused(_decide(gateway, audio_only, route, _BOTH), ("videos",)) + assert wire.drain() == () + + answered: Final = _decide(gateway, flagged, route, _BOTH) + assert answered.status_code == 200, answered.text + (call,) = wire.drain() + assert _sent_media(call) == _BOTH + rows: Final = eventually( + lambda: read_rows(_SPEND_QUERY, (answered.headers["x-litellm-call-id"],)), + lambda found: len(found) == 1, + seconds=70, + ) + assert (rows[0]["status"], rows[0]["model_group"]) == ("success", flagged) + + kept: Final = _decide(gateway, flagged_dropping, route, _BOTH) + assert kept.status_code == 200, kept.text + (kept_call,) = wire.drain() + assert _sent_media(kept_call) == _BOTH + + +@pytest.mark.parametrize( + "value", + ({"supports_audio_input": True, "supports_video_input": True}, 5, [{"supports_audio_input": True}], "x" * 5000), + ids=("flags", "int", "list", "5kb"), +) +def test_a_caller_sent_model_info_never_turns_media_on(gateway: Gateway, value: JsonValue) -> None: + upstream: Final = _unlisted_openrouter_model() + with wire_server(upstream.reply) as wire, gateway.scenario() as scenario: + model: Final = scenario.model(model=upstream.model, api_base=wire.url, api_key=_API_KEY) + _assert_refused(_decide(gateway, model, "/v1/decisions", {**_BOTH, "model_info": value}), ("audio", "videos")) + assert wire.drain() == () diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index 0c357837d31..789d59bd613 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -6,10 +6,11 @@ import copy import json import re import threading -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence from concurrent.futures import wait +from functools import partial from types import MappingProxyType -from typing import Final +from typing import Final, TypeAlias import httpx import pytest @@ -23,6 +24,7 @@ import litellm from litellm.constants import AWS_SIGNING_MAX_THREADS from litellm.cost_calculator import get_response_cost_from_hidden_params from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.get_model_cost_map import ModelCostMapReloaded, refetch_model_cost_map from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers from litellm.llms.bedrock.base_aws_llm import AWS_SIGNING_EXECUTOR @@ -3702,6 +3704,387 @@ async def test_a_model_info_object_instead_of_a_dict_still_reaches_the_provider( assert response.answers[0].model_dump(mode="json")["name"] == "is_defect" +_AUDIO_CLIPS: Final = ("data:audio/wav;base64,YQ==",) +_VIDEO_CLIPS: Final = ("data:video/mp4;base64,YQ==",) + + +@pytest.mark.asyncio +async def test_a_deployment_that_advertises_media_sends_it_for_a_model_the_cost_map_does_not_list( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + backend: Final = "openrouter/example/omni-preview" + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + router: Final = litellm.Router( + model_list=[ + { + "model_name": "hears", + "litellm_params": {"model": backend, "api_key": "k"}, + "model_info": {"id": "media-hears", "supports_audio_input": True}, + }, + { + "model_name": "hears-and-sees", + "litellm_params": {"model": backend, "api_key": "k"}, + "model_info": {"id": "media-both", "supports_audio_input": True, "supports_video_input": True}, + }, + ] + ) + send: Final = partial( + router.adecisions, input="review", questions=_PREDICATE_QUESTIONS, audio=_AUDIO_CLIPS, videos=_VIDEO_CLIPS + ) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("['videos']")): + await send(model="hears") + assert not route.called + + await send(model="hears-and-sees") + + sent: Final = json.loads(route.calls.last.request.content) + assert (sent["audio"], sent["videos"]) == (list(_AUDIO_CLIPS), list(_VIDEO_CLIPS)) + + +@pytest.mark.asyncio +async def test_media_flags_on_a_deployment_whose_own_mode_is_not_decisions_never_send_clips( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + advertised: Final = {"supports_audio_input": True, "supports_video_input": True} + router: Final = litellm.Router( + model_list=[ + { + "model_name": mode, + "litellm_params": {"model": f"openrouter/example/omni-{mode}-preview", "api_key": "k"}, + "model_info": {"id": f"media-{mode}", "mode": mode, **advertised}, + } + for mode in ("chat", "decisions") + ] + ) + send: Final = partial( + router.adecisions, input="review", questions=_PREDICATE_QUESTIONS, audio=_AUDIO_CLIPS, videos=_VIDEO_CLIPS + ) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("['audio', 'videos']")): + await send(model="chat") + assert not route.called + + await send(model="decisions") + + sent: Final = json.loads(route.calls.last.request.content) + assert (sent["audio"], sent["videos"]) == (list(_AUDIO_CLIPS), list(_VIDEO_CLIPS)) + + +CatalogLoader: TypeAlias = Callable[[Mapping[str, Mapping[str, object]]], Awaitable[None]] + + +@pytest.fixture +def load_catalog_with(monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None) -> Iterator[CatalogLoader]: + async def load(rows: Mapping[str, Mapping[str, object]]) -> None: + catalog: Final = {**litellm.get_model_cost_map(url=""), **rows} + transport: Final = httpx.MockTransport(lambda _: httpx.Response(200, json=catalog)) + monkeypatch.delenv("LITELLM_LOCAL_MODEL_COST_MAP") + async with httpx.AsyncClient(transport=transport) as client: + loaded: Final = await refetch_model_cost_map(url="https://catalog.example/prices.json", client=client) + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + assert isinstance(loaded, ModelCostMapReloaded) + monkeypatch.setattr(litellm, "model_cost", loaded.model_cost_map) + litellm.get_model_info.cache_clear() + + yield load + litellm.get_model_cost_map(url="") + + +@pytest.mark.asyncio +async def test_media_flags_on_a_deployment_without_its_own_mode_follow_the_mode_the_catalog_lists( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, load_catalog_with: CatalogLoader +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + await load_catalog_with( + { + f"openrouter/example/omni-{mode}": {"litellm_provider": "openrouter", "mode": mode} + for mode in ("chat", "decisions") + } + ) + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + router: Final = litellm.Router( + model_list=[ + { + "model_name": mode, + "litellm_params": {"model": f"openrouter/example/omni-{mode}", "api_key": "k"}, + "model_info": {"id": f"listed-{mode}", "supports_audio_input": True, "supports_video_input": True}, + } + for mode in ("chat", "decisions") + ] + ) + send: Final = partial( + router.adecisions, input="review", questions=_PREDICATE_QUESTIONS, audio=_AUDIO_CLIPS, videos=_VIDEO_CLIPS + ) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("['audio', 'videos']")): + await send(model="chat") + assert not route.called + + monkeypatch.setattr(litellm, "drop_params", True) + await send(model="chat") + dropped: Final = json.loads(route.calls.last.request.content) + assert ("audio" in dropped, "videos" in dropped) == (False, False) + + await send(model="decisions") + sent: Final = json.loads(route.calls.last.request.content) + assert (sent["audio"], sent["videos"]) == (list(_AUDIO_CLIPS), list(_VIDEO_CLIPS)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sibling_mode", ("responses", "embedding")) +async def test_another_deployments_mode_never_switches_off_a_deployments_own_media_flags( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None, sibling_mode: str +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + shared_params: Final = {"model": "openrouter/example/omni-shared", "api_key": "k"} + router: Final = litellm.Router( + model_list=[ + {"model_name": "sibling", "litellm_params": shared_params, "model_info": {"id": "s", "mode": sibling_mode}}, + { + "model_name": "flagged", + "litellm_params": shared_params, + "model_info": {"id": "f", "supports_audio_input": True, "supports_video_input": True}, + }, + ] + ) + + await router.adecisions( + model="flagged", input="review", questions=_PREDICATE_QUESTIONS, audio=_AUDIO_CLIPS, videos=_VIDEO_CLIPS + ) + + sent: Final = json.loads(route.calls.last.request.content) + assert (sent["audio"], sent["videos"]) == (list(_AUDIO_CLIPS), list(_VIDEO_CLIPS)) + + +_MEDIA_SURFACES: Final[Mapping[str, Mapping[str, object]]] = MappingProxyType( + { + "adecisions": {"input": "review", "questions": _PREDICATE_QUESTIONS}, + "asystemone": {"state": "review", "questions": _QUESTIONS}, + "systemone": {"state": "review", "questions": _QUESTIONS}, + } +) + + +def _group_member( + group: str, model: str, key: str, weight: int, model_info: Mapping[str, object], **params: object +) -> dict[str, object]: + return { + "model_name": group, + "litellm_params": {"model": model, "api_key": key, "weight": weight, **params}, + "model_info": {"id": f"{group}-{key}", **model_info}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("surface", tuple(_MEDIA_SURFACES)) +async def test_a_group_sends_clips_to_a_deployment_that_takes_them_and_refuses_only_when_none_does( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None, surface: str +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct") + monkeypatch.delenv("CLOUDFLARE_API_BASE", raising=False) + monkeypatch.setitem( + litellm.model_cost, + "cloudflare/@cf/cloudflare/clef-omni", + {"litellm_provider": "cloudflare", "mode": "evaluation", "supports_audio_input": True}, + ) + respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + respx_mock.post("https://api.cloudflare.com/client/v4/accounts/acct/ai/run/@cf/cloudflare/clef-omni").respond( + json=_RESPONSE + ) + unlisted: Final = "openrouter/example/omni-preview" + listed: Final = "cloudflare/clef-omni" + router: Final = litellm.Router( + model_list=[ + _group_member("unlisted", unlisted, "refuses", 1, {}), + _group_member("unlisted", unlisted, "takes", 0, {"supports_audio_input": True}), + _group_member("listed", listed, "refuses", 1, {"supports_audio_input": False}), + _group_member("listed", listed, "takes", 0, {}), + _group_member("none", unlisted, "refuses", 1, {}), + _group_member("none", listed, "refuses", 1, {"supports_audio_input": False}), + _group_member("drops", unlisted, "drops", 1, {}, drop_params=True), + _group_member("drops", listed, "takes", 0, {}), + _group_member("unresolvable", "databricks/not a serving endpoint", "unresolvable", 0, {}), + _group_member("unresolvable", listed, "takes", 1, {}), + ] + ) + call: Final = getattr(router, surface) + + async def send(group: str, **media: object) -> object: + response: Final = call(model=group, **_MEDIA_SURFACES[surface], **media) + return await response if asyncio.iscoroutine(response) else response + + for group in ("unlisted", "listed", "unresolvable"): + await send(group, audio=_AUDIO_CLIPS) + assert respx_mock.calls.last.request.headers["authorization"] == "Bearer takes" + assert json.loads(respx_mock.calls.last.request.content)["audio"] == list(_AUDIO_CLIPS) + + await send("drops", audio=_AUDIO_CLIPS) + assert respx_mock.calls.last.request.headers["authorization"] == "Bearer drops" + assert "audio" not in json.loads(respx_mock.calls.last.request.content) + + await send("unlisted") + assert respx_mock.calls.last.request.headers["authorization"] == "Bearer refuses" + + sent_before_refusal: Final = len(respx_mock.calls) + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("['audio']")): + await send("none", audio=_AUDIO_CLIPS) + assert len(respx_mock.calls) == sent_before_refusal + + +def _answer_unless_key_is(rejected_key: str) -> Callable[[httpx.Request], httpx.Response]: + def answer(request: httpx.Request) -> httpx.Response: + if request.headers["authorization"] == f"Bearer {rejected_key}": + return httpx.Response(401, json={"error": {"message": "invalid key"}}) + return httpx.Response(200, json=_RESPONSE) + + return answer + + +@pytest.mark.asyncio +async def test_clips_to_a_group_whose_only_taker_is_cooling_down_are_refused_rather_than_rate_limited( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post("https://openrouter.ai/api/alpha/decisions").mock( + side_effect=_answer_unless_key_is("takes") + ) + unlisted: Final = "openrouter/example/omni-preview" + router: Final = litellm.Router( + model_list=[ + _group_member("g", unlisted, "refuses", 1, {}), + _group_member("g", unlisted, "takes", 1, {"supports_audio_input": True}), + ], + num_retries=0, + cooldown_time=120, + ) + with pytest.raises(litellm.AuthenticationError): + await router.adecisions(model="g-takes", input="review", questions=_PREDICATE_QUESTIONS) + sent_before_clips: Final = len(route.calls) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("['audio']")): + await router.adecisions(model="g", input="review", questions=_PREDICATE_QUESTIONS, audio=_AUDIO_CLIPS) + + assert len(route.calls) == sent_before_clips + + +@pytest.mark.asyncio +@pytest.mark.parametrize("surface", tuple(_MEDIA_SURFACES)) +async def test_a_router_default_drop_params_overrides_a_deployment_when_choosing_who_takes_clips( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None, surface: str +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + respx_mock.post("https://openrouter.ai/api/alpha/decisions").respond(json=_RESPONSE) + unlisted: Final = "openrouter/example/omni-preview" + router: Final = litellm.Router( + model_list=[ + _group_member("g", unlisted, "drops", 1, {}, drop_params=True), + _group_member("g", unlisted, "takes", 0, {"supports_audio_input": True}), + ], + default_litellm_params={"drop_params": False}, + ) + call: Final = getattr(router, surface) + + async def send(**params: object) -> object: + response: Final = call(model="g", **_MEDIA_SURFACES[surface], audio=_AUDIO_CLIPS, **params) + return await response if asyncio.iscoroutine(response) else response + + await send() + assert respx_mock.calls.last.request.headers["authorization"] == "Bearer takes" + assert json.loads(respx_mock.calls.last.request.content)["audio"] == list(_AUDIO_CLIPS) + + await send(drop_params=True) + assert respx_mock.calls.last.request.headers["authorization"] == "Bearer drops" + assert "audio" not in json.loads(respx_mock.calls.last.request.content) + + +@pytest.mark.asyncio +async def test_a_deployment_that_turns_audio_off_is_refused_or_dropped_even_when_the_cost_map_allows_it( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + monkeypatch.setitem( + litellm.model_cost, + "typesafe/jev-omni", + { + "litellm_provider": "typesafe", + "mode": "decisions", + "supports_audio_input": True, + "supports_video_input": True, + }, + ) + route: Final = respx_mock.post("https://example.com/v1/systemone").respond(json=_RESPONSE) + send: Final = partial( + litellm.asystemone, + model="typesafe/jev-omni", + state="test", + questions=_QUESTIONS, + api_base="https://example.com", + api_key="test", + audio=_AUDIO_CLIPS, + videos=_VIDEO_CLIPS, + model_info={"supports_audio_input": False}, + ) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("does not support parameters: ['audio']")): + await send() + assert not route.called + + await send(drop_params=True) + + sent: Final = json.loads(route.calls.last.request.content) + assert "audio" not in sent + assert sent["videos"] == list(_VIDEO_CLIPS) + + +@pytest.mark.asyncio +async def test_a_media_flag_that_is_not_a_boolean_leaves_the_cost_map_in_charge_of_that_kind_only( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post("https://example.com/v1/systemone").respond(json=_RESPONSE) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("does not support parameters: ['audio']")): + await litellm.asystemone( + model="typesafe/jev-unlisted", + state="test", + questions=_QUESTIONS, + api_base="https://example.com", + api_key="test", + audio=_AUDIO_CLIPS, + videos=_VIDEO_CLIPS, + model_info={"supports_audio_input": "true", "supports_video_input": True}, + ) + + assert not route.called + + +@pytest.mark.asyncio +async def test_an_openai_format_deployment_that_advertises_media_is_still_refused_it( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "drop_params", False) + route: Final = respx_mock.post("https://api.openai.com/v1/decisions").respond(json=_OPENAI_RESPONSE) + + with pytest.raises(litellm.UnsupportedParamsError, match=re.escape("['audio', 'videos']")): + await litellm.adecisions( + model="openai/gpt-6-luna", + input="review", + questions=_PREDICATE_QUESTIONS, + api_key="deployment-key", + audio=_AUDIO_CLIPS, + videos=_VIDEO_CLIPS, + model_info={"supports_audio_input": True, "supports_video_input": True}, + ) + + assert not route.called + + def test_dropping_every_question_answers_400_before_http(respx_mock: respx.MockRouter) -> None: route: Final = respx_mock.post(_PERPLEXITY_DECISIONS_URL).respond(json=_RESPONSE) diff --git a/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py b/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py index ec6f118837f..041c625eaef 100644 --- a/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py +++ b/tests/unit/llms/base_llm/decisions/test_base_decisions_transformation.py @@ -26,7 +26,6 @@ from litellm.types.decisions import ( DecisionsIRScoreAnswer, DecisionsRequestBody, OpenAIDecisionRequestBody, - UnsupportedDecisionsRequest, ) from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -279,7 +278,7 @@ def test_a_malformed_systemone_response_names_the_field_at_fault( assert [item["loc"] for item in error.value.errors()] == [field] -def test_each_media_kind_is_gated_by_its_own_support_flag() -> None: +def test_each_media_kind_is_sent_in_its_own_field() -> None: audio: Final = ["data:audio/wav;base64,YQ=="] videos: Final = ["data:video/mp4;base64,YQ=="] audio_only: Final = systemone_request_to_ir(_SYSTEMONE_BODY.validate_python({**_SYSTEMONE_REQUEST, "audio": audio})) @@ -287,22 +286,16 @@ def test_each_media_kind_is_gated_by_its_own_support_flag() -> None: _SYSTEMONE_BODY.validate_python({**_SYSTEMONE_REQUEST, "videos": videos}) ) - assert ir_to_systemone_request("clef-omni", audio_only, supports_audio=True) == { + assert ir_to_systemone_request("clef-omni", audio_only) == { "model": "clef-omni", **_SYSTEMONE_REQUEST, "audio": audio, } - assert ir_to_systemone_request("clef-omni", video_only, supports_video=True) == { + assert ir_to_systemone_request("clef-omni", video_only) == { "model": "clef-omni", **_SYSTEMONE_REQUEST, "videos": videos, } - assert isinstance( - ir_to_systemone_request("clef-omni", audio_only, supports_video=True), UnsupportedDecisionsRequest - ) - assert isinstance( - ir_to_systemone_request("clef-omni", video_only, supports_audio=True), UnsupportedDecisionsRequest - ) _ASSET_REQUEST: Final[Mapping[str, object]] = { diff --git a/tests/unit/llms/bedrock/test_bedrock_common_utils.py b/tests/unit/llms/bedrock/test_bedrock_common_utils.py index 20c0e10cb81..38c69e0ce27 100644 --- a/tests/unit/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/unit/llms/bedrock/test_bedrock_common_utils.py @@ -1,5 +1,5 @@ -import asyncio, importlib, litellm, litellm.litellm_core_utils.get_model_cost_map as bedrock_govcloud_model_cost_map, pytest +import asyncio, importlib, litellm, pytest from typing import Final @@ -1563,7 +1563,6 @@ def _pr4_bedrock_env(monkeypatch: pytest.MonkeyPatch) -> None: def _use_local_model_cost_map() -> Iterator[None]: with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") - importlib.reload(bedrock_govcloud_model_cost_map) importlib.reload(litellm) yield