diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index cb3080e6534..e63448fc0d6 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -28,7 +28,7 @@ from litellm.types.llms.openai import ( ) from litellm.types.utils import ModelResponse, ModelResponseStream -from ..common_utils import OllamaError +from ..common_utils import OllamaError, think_from_reasoning_effort if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -170,11 +170,17 @@ class OllamaChatConfig(BaseConfig): if param == "response_format" and isinstance(value, dict) and value.get("type") == "json_schema": if value.get("json_schema") and value["json_schema"].get("schema"): optional_params["format"] = value["json_schema"]["schema"] - if param == "reasoning_effort" and value is not None: - if model.startswith("gpt-oss"): - optional_params["think"] = value - else: - optional_params["think"] = value in {"low", "medium", "high"} + if ( + param == "reasoning_effort" + and ( + think := think_from_reasoning_effort( + model, + cast(object, value), # cast-ok: [LIT006] untyped dict value, widened to object for the helper + ) + ) + is not None + ): + optional_params["think"] = think ### FUNCTION CALLING LOGIC ### # Ollama 0.4+ supports native tool calling - pass tools directly # and let Ollama handle model capability detection diff --git a/litellm/llms/ollama/common_utils.py b/litellm/llms/ollama/common_utils.py index 9f46cbc5cd5..96f28b1ed53 100644 --- a/litellm/llms/ollama/common_utils.py +++ b/litellm/llms/ollama/common_utils.py @@ -1,5 +1,6 @@ import base64 import io +from collections.abc import Mapping from typing import Any, Final import httpx @@ -8,6 +9,15 @@ from litellm import verbose_logger from litellm.llms.base_llm.chat.transformation import BaseLLMException +def think_from_reasoning_effort(model: str, reasoning_effort: object) -> str | bool | None: + effort: Final = reasoning_effort.get("effort") if isinstance(reasoning_effort, Mapping) else reasoning_effort + if not isinstance(effort, str): + return None + if model.startswith("gpt-oss"): + return effort + return effort in {"low", "medium", "high"} + + class OllamaError(BaseLLMException): def __init__(self, status_code: int, message: str, headers: dict | httpx.Headers): super().__init__(status_code=status_code, message=message, headers=headers) diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index b1f69220de7..b712bb7cc5b 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -1,7 +1,7 @@ import json import time from collections.abc import AsyncIterator, Iterator -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, cast from httpx._models import Headers, Response from pydantic import BaseModel, ConfigDict, ValidationError @@ -33,7 +33,7 @@ from litellm.types.utils import ( StreamingChoices, ) -from ..common_utils import OllamaError, OllamaModelInfo, _convert_image +from ..common_utils import OllamaError, OllamaModelInfo, _convert_image, think_from_reasoning_effort if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -214,11 +214,17 @@ class OllamaConfig(BaseConfig): optional_params["frequency_penalty"] = value elif param == "stop": optional_params["stop"] = value - elif param == "reasoning_effort" and value is not None: - if model.startswith("gpt-oss"): - optional_params["think"] = value - else: - optional_params["think"] = value in {"low", "medium", "high"} + elif ( + param == "reasoning_effort" + and ( + think := think_from_reasoning_effort( + model, + cast(object, value), # cast-ok: [LIT006] untyped dict value, widened to object for the helper + ) + ) + is not None + ): + optional_params["think"] = think elif param == "response_format" and isinstance(value, dict): if value["type"] == "json_object": optional_params["format"] = "json" diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index c7fba21d222..d1a9fe6aaa8 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -1,7 +1,9 @@ import inspect import os import sys -from typing import cast +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final, cast import pytest from pydantic import BaseModel @@ -989,3 +991,44 @@ class TestOllamaStreamingUsage: ) assert result.usage is None + + +class TestOllamaChatReasoningEffort: + @pytest.mark.parametrize( + "reasoning_effort, expected_think", + [ + ("low", True), + ("high", True), + ("none", False), + ({"effort": "medium"}, True), + ({"effort": "medium", "summary": "auto"}, True), + (MappingProxyType({"effort": "medium", "summary": "auto"}), True), + ({"effort": "none", "summary": "detailed"}, False), + ], + ) + def test_reasoning_effort_string_or_dict_maps_to_think( + self, reasoning_effort: str | Mapping[str, str], expected_think: bool + ) -> None: + optional_params: Final = get_optional_params( + model="ollama_chat/qwen3:8b", + custom_llm_provider="ollama_chat", + reasoning_effort=reasoning_effort, + ) + assert optional_params["think"] is expected_think + + def test_reasoning_dict_without_effort_sets_nothing(self) -> None: + optional_params: Final = get_optional_params( + model="ollama_chat/qwen3:8b", + custom_llm_provider="ollama_chat", + reasoning_effort={"summary": "auto"}, + ) + assert "think" not in optional_params + + def test_reasoning_dict_gpt_oss_forwards_effort_string(self) -> None: + optional_params: Final = OllamaChatConfig().map_openai_params( + non_default_params={"reasoning_effort": {"effort": "high", "summary": "auto"}}, + optional_params={}, + model="gpt-oss:20b", + drop_params=False, + ) + assert optional_params["think"] == "high" diff --git a/tests/unit/llms/ollama/test_ollama_completion_transformation.py b/tests/unit/llms/ollama/test_ollama_completion_transformation.py index d6215a742f0..531cd190e3d 100644 --- a/tests/unit/llms/ollama/test_ollama_completion_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_completion_transformation.py @@ -2,6 +2,9 @@ import base64 import io import json import sys +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final from litellm._uuid import uuid from unittest.mock import MagicMock, patch @@ -775,3 +778,43 @@ def test_transform_request_leaves_unreadable_images_untouched(payload: str) -> N data = _transform_image_request(payload, "png") assert data["images"] == [payload] + + +class TestOllamaConfigReasoningEffort: + @pytest.mark.parametrize( + "reasoning_effort, expected_think", + [ + ("medium", True), + ({"effort": "medium", "summary": "auto"}, True), + (MappingProxyType({"effort": "medium", "summary": "auto"}), True), + ({"effort": "none"}, False), + ], + ) + def test_reasoning_effort_string_or_dict_maps_to_think( + self, reasoning_effort: str | Mapping[str, str], expected_think: bool + ) -> None: + optional_params: Final = OllamaConfig().map_openai_params( + non_default_params={"reasoning_effort": reasoning_effort}, + optional_params={}, + model="qwen3:8b", + drop_params=False, + ) + assert optional_params["think"] is expected_think + + def test_reasoning_dict_gpt_oss_forwards_effort_string(self) -> None: + optional_params: Final = OllamaConfig().map_openai_params( + non_default_params={"reasoning_effort": {"effort": "high", "summary": "auto"}}, + optional_params={}, + model="gpt-oss:20b", + drop_params=False, + ) + assert optional_params["think"] == "high" + + def test_reasoning_dict_without_effort_sets_nothing(self) -> None: + optional_params: Final = OllamaConfig().map_openai_params( + non_default_params={"reasoning_effort": {"summary": "auto"}}, + optional_params={}, + model="qwen3:8b", + drop_params=False, + ) + assert "think" not in optional_params