diff --git a/litellm/google_genai/adapters/transformation.py b/litellm/google_genai/adapters/transformation.py index c1895376675..7bcb94368f0 100644 --- a/litellm/google_genai/adapters/transformation.py +++ b/litellm/google_genai/adapters/transformation.py @@ -459,6 +459,7 @@ class GoogleGenAIAdapter: contents: _JsonDictList | _JsonDict, config: Mapping[str, object] | None = None, litellm_params: GenericLiteLLMParams | None = None, + reasoning_effort: object = None, **kwargs, ) -> dict[str, Any]: """ @@ -545,14 +546,38 @@ class GoogleGenAIAdapter: ######################################################### # forward any litellm specific params ######################################################### - completion_request_dict = dict(completion_request) - if litellm_params: - completion_request_dict = self._add_generic_litellm_params_to_request( + direct_effort: Final = reasoning_effort + generic_effort: Final[object] = getattr(litellm_params, "reasoning_effort", None) + direct_effort_fields: Final = _validated(_CONFIG_FIELDS, direct_effort) + generic_effort_fields: Final = _validated(_CONFIG_FIELDS, generic_effort) + if ( + direct_effort is not None + and generic_effort is not None + and ( + type(direct_effort) is not type(generic_effort) + or direct_effort != generic_effort + or ( + isinstance(direct_effort, dict) + and isinstance(generic_effort, dict) + and direct_effort_fields is not None + and generic_effort_fields is not None + and type(direct_effort_fields.get("effort")) is not type(generic_effort_fields.get("effort")) + ) + ) + ): + raise ValueError("Conflicting reasoning_effort in kwargs and litellm_params") + completion_request_dict: Final = { + **completion_request, + **({"reasoning_effort": reasoning_effort} if reasoning_effort is not None else {}), + } + return ( + self._add_generic_litellm_params_to_request( completion_request_dict=completion_request_dict, litellm_params=litellm_params, ) - - return completion_request_dict + if litellm_params + else completion_request_dict + ) def _add_generic_litellm_params_to_request( self, @@ -574,6 +599,9 @@ class GoogleGenAIAdapter: for key, value in litellm_dict.items(): if key in allowed_fields: completion_request_dict[key] = value + reasoning_effort: Final[object] = getattr(litellm_params, "reasoning_effort", None) + if reasoning_effort is not None: + return {**completion_request_dict, "reasoning_effort": reasoning_effort} return completion_request_dict def translate_completion_output_params_streaming( diff --git a/tests/unit/google_genai/test_google_genai_adapter.py b/tests/unit/google_genai/test_google_genai_adapter.py index 4ab642dc99d..3430daf11b3 100644 --- a/tests/unit/google_genai/test_google_genai_adapter.py +++ b/tests/unit/google_genai/test_google_genai_adapter.py @@ -5,14 +5,270 @@ Test to verify the Google GenAI generate_content adapter functionality import json import unittest -from typing import Final +from copy import deepcopy +from typing import Final, Literal +import httpx import pytest +import respx import litellm +import litellm.google_genai.main as google_genai_main +from litellm.google_genai.adapters.handler import GenerateContentToCompletionHandler from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter from litellm.google_genai.main import agenerate_content from litellm.llms.vertex_ai.gemini.transformation import gemini_convert_messages_with_history +from litellm.types.router import GenericLiteLLMParams + + +@pytest.mark.parametrize("carrier", ["generic", "kwargs"]) +@pytest.mark.parametrize("effort", ["high", {"effort": "high"}], ids=["string", "object"]) +def test_explicit_reasoning_effort_survives_completion_adapter( + carrier: Literal["generic", "kwargs"], effort: str | dict[str, str] +) -> None: + params: Final = GenericLiteLLMParams(**({"reasoning_effort": effort} if carrier == "generic" else {})) + extra_kwargs: Final = {"reasoning_effort": effort} if carrier == "kwargs" else {} + result: Final = GenerateContentToCompletionHandler._prepare_completion_kwargs( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + config={"temperature": 0.0}, + litellm_params=params, + extra_kwargs=extra_kwargs, + ) + assert result["reasoning_effort"] == effort + assert type(result["reasoning_effort"]) is type(effort) + assert result["temperature"] == 0.0 + + +@pytest.mark.parametrize( + ("entrypoint", "is_async", "stream"), + [ + ("generate_content", False, False), + ("agenerate_content", True, False), + ("generate_content_stream", False, True), + ("agenerate_content_stream", True, True), + ], +) +@pytest.mark.parametrize( + "effort", + ["high", {"effort": "high"}, {"effort": "high", "summary": "detailed"}, None], + ids=["string", "object", "summary", "null"], +) +@pytest.mark.asyncio +async def test_public_generate_content_preserves_explicit_reasoning_effort( + monkeypatch: pytest.MonkeyPatch, entrypoint: str, is_async: bool, stream: bool, effort: str | dict[str, str] | None +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + config: Final = {"temperature": 0.0} + contents: Final = [{"role": "user", "parts": [{"text": "Hello"}]}] + before: Final = deepcopy((contents, config, effort)) + upstream_body: Final = { + "id": "chatcmpl-effort", + "object": "chat.completion", + "created": 1234567890, + "model": "adapter-test-model", + "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Hello"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + upstream_chunk: Final = { + "id": "chatcmpl-effort", + "object": "chat.completion.chunk", + "created": 1234567890, + "model": "adapter-test-model", + "choices": [{"index": 0, "finish_reason": "stop", "delta": {"content": "Hello"}}], + } + with respx.mock() as upstream: + route: Final = upstream.post("https://upstream.test/v1/chat/completions").mock( + return_value=( + httpx.Response( + 200, + text=f"data: {json.dumps(upstream_chunk)}\n\ndata: [DONE]\n\n", + headers={"content-type": "text/event-stream"}, + ) + if stream + else httpx.Response(200, json=upstream_body) + ) + ) + call: Final = getattr(google_genai_main, entrypoint) + response: Final = call( + model="openai/adapter-test-model", + contents=contents, + config=config, + api_base="https://upstream.test/v1", + api_key="unit-test", + reasoning_effort=effort, + ) + result: Final = await response if is_async else response + output: Final = ( + tuple([json.loads(chunk.removeprefix(b"data: ").strip()) async for chunk in result]) + if stream + else (result,) + ) + assert len(route.calls) == 1 + assert json.loads(route.calls[0].request.content) == { + "model": "adapter-test-model", + "messages": [{"role": "user", "content": "Hello"}], + "temperature": 0.0, + **({"reasoning_effort": effort} if effort is not None else {}), + **({"stream": True, "stream_options": {"include_usage": True}} if stream else {}), + } + assert output[0]["candidates"][0]["content"]["parts"] == [{"text": "Hello"}] + assert (contents, config, effort) == before + + +@pytest.mark.parametrize( + "config", + [None, {"temperature": None}, {"temperature": 0.0}, {"temperature": 0.7}, {"temperature": 1.0}], + ids=["missing", "null", "zero", "positive", "one"], +) +@pytest.mark.parametrize("carrier", ["generic", "kwargs"]) +@pytest.mark.parametrize("effort", ["high", {"effort": "high", "summary": "detailed"}], ids=["string", "summary"]) +def test_explicit_effort_does_not_change_config_or_forward_unrelated_extras( + config: dict[str, object] | None, carrier: Literal["generic", "kwargs"], effort: str | dict[str, str] +) -> None: + supplied: Final = {"reasoning_effort": effort, "unrelated_adapter_extra": "sentinel"} + params: Final = GenericLiteLLMParams(**supplied) if carrier == "generic" else GenericLiteLLMParams() + extras: Final = supplied if carrier == "kwargs" else {} + before: Final = deepcopy((config, params.model_dump(), extras)) + result: Final = GenerateContentToCompletionHandler._prepare_completion_kwargs( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + config=config, + litellm_params=params, + extra_kwargs=extras, + ) + assert dict(result) == { + "model": "openai/adapter-test-model", + "messages": [{"role": "user", "content": "Hello"}], + **(config or {}), + **GenericLiteLLMParams().model_dump(exclude_none=True), + "reasoning_effort": effort, + } + assert result.get("reasoning_effort") is effort + assert (config, params.model_dump(), extras) == before + + +@pytest.mark.parametrize( + "config", + [ + None, + {"temperature": None}, + {"temperature": 0.0}, + {"temperature": 0.7}, + {"temperature": 1.0}, + {"thinkingConfig": {"thinkingLevel": "HIGH", "thinkingBudget": 1024}}, + ], + ids=["missing", "null", "zero", "positive", "one", "native-thinking"], +) +def test_completion_adapter_does_not_invent_reasoning_effort(config: dict[str, object] | None) -> None: + result: Final = GenerateContentToCompletionHandler._prepare_completion_kwargs( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + config=config, + ) + assert dict(result) == { + "model": "openai/adapter-test-model", + "messages": [{"role": "user", "content": "Hello"}], + **({"temperature": config["temperature"]} if config and "temperature" in config else {}), + } + + +@pytest.mark.parametrize(("direct", "generic"), [(None, "high"), ("high", None), (None, None)]) +def test_null_reasoning_effort_does_not_override_an_explicit_value(direct: str | None, generic: str | None) -> None: + result: Final = GenerateContentToCompletionHandler._prepare_completion_kwargs( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + litellm_params=GenericLiteLLMParams(reasoning_effort=generic), + extra_kwargs={"reasoning_effort": direct}, + ) + assert dict(result) == { + "model": "openai/adapter-test-model", + "messages": [{"role": "user", "content": "Hello"}], + **GenericLiteLLMParams().model_dump(exclude_none=True), + **({"reasoning_effort": direct or generic} if direct or generic else {}), + } + + +@pytest.mark.parametrize( + ("direct", "generic"), + [ + ("low", "high"), + ({"effort": "low"}, {"effort": "high"}), + ("high", {"effort": "high"}), + (False, 0), + ({"effort": False}, {"effort": 0}), + ], + ids=["strings", "objects", "string-object", "bool-int", "nested-bool-int"], +) +def test_conflicting_reasoning_effort_is_rejected_without_changing_inputs(direct: object, generic: object) -> None: + params: Final = GenericLiteLLMParams(reasoning_effort=generic) + extras: Final = {"reasoning_effort": direct} + before: Final = deepcopy((params.model_dump(), extras)) + with pytest.raises(ValueError, match=r"^Conflicting reasoning_effort in kwargs and litellm_params$"): + GenerateContentToCompletionHandler._prepare_completion_kwargs( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + litellm_params=params, + extra_kwargs=extras, + ) + assert (params.model_dump(), extras) == before + + +@pytest.mark.parametrize("async_mode", [False, True], ids=["sync", "async"]) +@pytest.mark.asyncio +async def test_conflicting_reasoning_effort_never_reaches_upstream( + monkeypatch: pytest.MonkeyPatch, async_mode: bool +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + params: Final = GenericLiteLLMParams( + api_base="https://upstream.test/v1", + api_key="unit-test", + reasoning_effort="high", + ) + + async def conflicting_call() -> None: + response: Final = GenerateContentToCompletionHandler.generate_content_handler( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + litellm_params=params, + reasoning_effort="low", + _is_async=async_mode, + ) + if async_mode: + await response + + with respx.mock(assert_all_called=False) as upstream: + route: Final = upstream.post("https://upstream.test/v1/chat/completions").mock(return_value=httpx.Response(500)) + with pytest.raises(ValueError, match=r"^Conflicting reasoning_effort in kwargs and litellm_params$"): + await conflicting_call() + assert len(route.calls) == 0 + + +@pytest.mark.parametrize("carrier", ["generic", "kwargs"]) +@pytest.mark.parametrize("effort", ["", 0, False, {}, {"effort": 0}], ids=["empty", "zero", "false", "object", "bad-effort"]) +def test_single_invalid_reasoning_effort_is_left_for_the_completion_validator( + carrier: Literal["generic", "kwargs"], effort: object +) -> None: + result: Final = GenerateContentToCompletionHandler._prepare_completion_kwargs( + model="openai/adapter-test-model", + contents=[{"role": "user", "parts": [{"text": "Hello"}]}], + litellm_params=GenericLiteLLMParams(**({"reasoning_effort": effort} if carrier == "generic" else {})), + extra_kwargs={"reasoning_effort": effort} if carrier == "kwargs" else {}, + ) + assert result.get("reasoning_effort") is effort + + +@pytest.mark.parametrize("generic_api_base", [None, "https://generic.test/v1"]) +def test_preserving_reasoning_effort_keeps_declared_generic_merge_precedence(generic_api_base: str | None) -> None: + result: Final = GoogleGenAIAdapter()._add_generic_litellm_params_to_request( + {"api_base": "https://translated.test/v1"}, + GenericLiteLLMParams(api_base=generic_api_base, reasoning_effort="high", unrelated_adapter_extra="sentinel"), + ) + assert result == { + **GenericLiteLLMParams().model_dump(exclude_none=True), + "api_base": generic_api_base or "https://translated.test/v1", + "reasoning_effort": "high", + } @pytest.mark.parametrize(("data_key", "mime_key"), [("inline_data", "mime_type"), ("inlineData", "mimeType")])