From d15aa0c89b2b1d28722cfddc4a47aa27486f285b Mon Sep 17 00:00:00 2001 From: woongstardev Date: Tue, 22 Sep 2026 20:41:24 +0900 Subject: [PATCH 1/4] fix(ollama): accept dict reasoning_effort from Responses API bridge Responses API clients such as Codex CLI send reasoning: {"summary": "auto"} on every request. When the Responses to chat bridge forwarded that object as reasoning_effort, OllamaChatConfig and OllamaConfig hashed it in a set membership check and raised TypeError: unhashable type: 'dict', which came back as an APIConnectionError and put Codex into a reconnect loop Unwrap the effort key when the value is a dict and skip the think mapping when no effort is present, so {"summary": ...} alone is a no-op and {"effort": "low", "summary": ...} behaves like "low" Fixes #37452 --- litellm/llms/ollama/chat/transformation.py | 10 +++-- .../llms/ollama/completion/transformation.py | 10 +++-- .../ollama/test_ollama_chat_transformation.py | 42 ++++++++++++++++++- .../test_ollama_completion_transformation.py | 40 ++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index cb3080e6534..10bf3736e64 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -171,10 +171,12 @@ class OllamaChatConfig(BaseConfig): 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"} + effort: Final = value.get("effort") if isinstance(value, dict) else value + if effort is not None: + if model.startswith("gpt-oss"): + optional_params["think"] = effort + else: + optional_params["think"] = effort in {"low", "medium", "high"} ### 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/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index b1f69220de7..e8dfb209b19 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -215,10 +215,12 @@ class OllamaConfig(BaseConfig): 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"} + effort: Final = value.get("effort") if isinstance(value, dict) else value + if effort is not None: + if model.startswith("gpt-oss"): + optional_params["think"] = effort + else: + optional_params["think"] = effort in {"low", "medium", "high"} 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..58595502357 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -1,7 +1,7 @@ import inspect import os import sys -from typing import cast +from typing import Final, cast import pytest from pydantic import BaseModel @@ -989,3 +989,43 @@ 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), + ({"effort": "none", "summary": "detailed"}, False), + ], + ) + def test_reasoning_effort_string_or_dict_maps_to_think( + self, reasoning_effort: str | dict[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..e51e5979deb 100644 --- a/tests/unit/llms/ollama/test_ollama_completion_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_completion_transformation.py @@ -2,6 +2,7 @@ import base64 import io import json import sys +from typing import Final from litellm._uuid import uuid from unittest.mock import MagicMock, patch @@ -775,3 +776,42 @@ 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), + ({"effort": "none"}, False), + ], + ) + def test_reasoning_effort_string_or_dict_maps_to_think( + self, reasoning_effort: str | dict[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 From da7ec892afcab65a6db2315a5e4b3182a456e289 Mon Sep 17 00:00:00 2001 From: woongstardev Date: Tue, 22 Sep 2026 21:00:06 +0900 Subject: [PATCH 2/4] fix(ollama): unwrap any Mapping reasoning_effort, not only dict responses_api_bridge_check accepts Mapping[str, object], so a read-only mapping reached the same set membership check and raised the same TypeError --- litellm/llms/ollama/chat/transformation.py | 2 +- litellm/llms/ollama/completion/transformation.py | 4 ++-- tests/unit/llms/ollama/test_ollama_chat_transformation.py | 5 ++++- .../llms/ollama/test_ollama_completion_transformation.py | 5 ++++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index 10bf3736e64..fb2acc94122 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -171,7 +171,7 @@ class OllamaChatConfig(BaseConfig): 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: - effort: Final = value.get("effort") if isinstance(value, dict) else value + effort: Final = value.get("effort") if isinstance(value, Mapping) else value if effort is not None: if model.startswith("gpt-oss"): optional_params["think"] = effort diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index e8dfb209b19..7d2bb726f5a 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -1,6 +1,6 @@ import json import time -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Iterator, Mapping from typing import TYPE_CHECKING, Any, Final from httpx._models import Headers, Response @@ -215,7 +215,7 @@ class OllamaConfig(BaseConfig): elif param == "stop": optional_params["stop"] = value elif param == "reasoning_effort" and value is not None: - effort: Final = value.get("effort") if isinstance(value, dict) else value + effort: Final = value.get("effort") if isinstance(value, Mapping) else value if effort is not None: if model.startswith("gpt-oss"): optional_params["think"] = effort diff --git a/tests/unit/llms/ollama/test_ollama_chat_transformation.py b/tests/unit/llms/ollama/test_ollama_chat_transformation.py index 58595502357..d1a9fe6aaa8 100644 --- a/tests/unit/llms/ollama/test_ollama_chat_transformation.py +++ b/tests/unit/llms/ollama/test_ollama_chat_transformation.py @@ -1,6 +1,8 @@ import inspect import os import sys +from collections.abc import Mapping +from types import MappingProxyType from typing import Final, cast import pytest @@ -1000,11 +1002,12 @@ class TestOllamaChatReasoningEffort: ("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 | dict[str, str], expected_think: bool + self, reasoning_effort: str | Mapping[str, str], expected_think: bool ) -> None: optional_params: Final = get_optional_params( model="ollama_chat/qwen3:8b", diff --git a/tests/unit/llms/ollama/test_ollama_completion_transformation.py b/tests/unit/llms/ollama/test_ollama_completion_transformation.py index e51e5979deb..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,8 @@ 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 @@ -784,11 +786,12 @@ class TestOllamaConfigReasoningEffort: [ ("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 | dict[str, str], expected_think: bool + 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}, From 28f8e6d3412f961c56f7e9d5454719d0114f3d17 Mon Sep 17 00:00:00 2001 From: woongstardev Date: Tue, 22 Sep 2026 21:11:25 +0900 Subject: [PATCH 3/4] refactor(ollama): move reasoning_effort unwrapping into a shared helper A Final local inside the param loop tripped basedpyright's reassignment check in both configs and pushed reportGeneralTypeIssues over budget. think_from_reasoning_effort in common_utils keeps the Final outside any loop, guards on str so non-string efforts never reach the set check, and lets both configs share one code path --- litellm/llms/ollama/chat/transformation.py | 14 ++++++-------- litellm/llms/ollama/common_utils.py | 10 ++++++++++ .../llms/ollama/completion/transformation.py | 18 ++++++++---------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index fb2acc94122..cdc0ad00786 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,13 +170,11 @@ 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: - effort: Final = value.get("effort") if isinstance(value, Mapping) else value - if effort is not None: - if model.startswith("gpt-oss"): - optional_params["think"] = effort - else: - optional_params["think"] = effort in {"low", "medium", "high"} + if ( + param == "reasoning_effort" + and (think := think_from_reasoning_effort(model, cast(object, value))) 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 7d2bb726f5a..b0cb2d4ed27 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, Mapping -from typing import TYPE_CHECKING, Any, Final +from collections.abc import AsyncIterator, Iterator +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,13 +214,11 @@ class OllamaConfig(BaseConfig): optional_params["frequency_penalty"] = value elif param == "stop": optional_params["stop"] = value - elif param == "reasoning_effort" and value is not None: - effort: Final = value.get("effort") if isinstance(value, Mapping) else value - if effort is not None: - if model.startswith("gpt-oss"): - optional_params["think"] = effort - else: - optional_params["think"] = effort in {"low", "medium", "high"} + elif ( + param == "reasoning_effort" + and (think := think_from_reasoning_effort(model, cast(object, value))) is not None + ): + optional_params["think"] = think elif param == "response_format" and isinstance(value, dict): if value["type"] == "json_object": optional_params["format"] = "json" From ee86931b0dddd23fc87ee43456254ca36e9f7af4 Mon Sep 17 00:00:00 2001 From: woongstardev Date: Tue, 22 Sep 2026 21:25:21 +0900 Subject: [PATCH 4/4] chore(ollama): mark the object cast at the helper call sites as cast-ok The loop value is untyped dict content, so widening it to object is the only way to hand it to the typed helper without a new unknown-argument report. Names the LIT006 suppression and its reason as the gate expects --- litellm/llms/ollama/chat/transformation.py | 8 +++++++- litellm/llms/ollama/completion/transformation.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/litellm/llms/ollama/chat/transformation.py b/litellm/llms/ollama/chat/transformation.py index cdc0ad00786..e63448fc0d6 100644 --- a/litellm/llms/ollama/chat/transformation.py +++ b/litellm/llms/ollama/chat/transformation.py @@ -172,7 +172,13 @@ class OllamaChatConfig(BaseConfig): optional_params["format"] = value["json_schema"]["schema"] if ( param == "reasoning_effort" - and (think := think_from_reasoning_effort(model, cast(object, value))) is not None + 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 ### diff --git a/litellm/llms/ollama/completion/transformation.py b/litellm/llms/ollama/completion/transformation.py index b0cb2d4ed27..b712bb7cc5b 100644 --- a/litellm/llms/ollama/completion/transformation.py +++ b/litellm/llms/ollama/completion/transformation.py @@ -216,7 +216,13 @@ class OllamaConfig(BaseConfig): optional_params["stop"] = value elif ( param == "reasoning_effort" - and (think := think_from_reasoning_effort(model, cast(object, value))) is not None + 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):