fix(ollama): read the JSON thinking field on non-streaming completions (#42838)

* fix(ollama): read the JSON thinking field on non-streaming completions

Ollama's /api/generate returns reasoning in a top-level `thinking` field, but
the completion transport only looked for inline <think> tags. reasoning_content
was therefore always null, and a model that spent its whole turn reasoning
returned an empty assistant message with tokens billed.

Port the precedence the ollama_chat transport already uses: the field wins and
inline tags stay the fallback. Applied to both non-streaming paths, including
the JSON-mode text fallback. The two fields are read through a small validated
model rather than off the untyped JSON, so absent and explicitly null
`response` stay distinct exactly as before.

* fix(ollama): keep the thinking field on JSON-mode completions

The first pass read `thinking` for plain replies and for JSON-mode text that
failed to parse, but the three JSON-mode branches that succeed still dropped
it: an empty `response`, a valid JSON object, and a function-call shaped one.
A model that spent its whole turn reasoning under `format: json` therefore
still came back blank with the tokens billed.

Carry the field on all three, type the new test helper's parameters, and cover
the null and malformed `response` fallbacks.

---------

Co-authored-by: Pawan-Shahane <shahanepawan511@gmail.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-23 18:26:02 -07:00 • committed by GitHub
parent eef80535ec
commit ca86898633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 195 additions and 17 deletions

View file

@ -4,6 +4,7 @@ from collections.abc import AsyncIterator, Iterator
from typing import TYPE_CHECKING, Any, Final
from httpx._models import Headers, Response
from pydantic import BaseModel, ConfigDict, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
@ -43,6 +44,37 @@ else:
LiteLLMLoggingObj = Any
class _OllamaGenerateReasoning(BaseModel):
"""The two `/api/generate` fields a reply's reasoning can arrive in."""
model_config = ConfigDict(extra="ignore")
# Absent and explicitly null are distinct here: Ollama omits `response` where it sends
# no text, and sends null where the reply carries none, which stay "" and None downstream.
response: str | None = ""
thinking: str | None = None
@classmethod
def from_response(cls, response_json: object) -> "_OllamaGenerateReasoning":
try:
return cls.model_validate(response_json)
except ValidationError:
return cls()
def split(self) -> tuple[str | None, str | None]:
"""Reasoning reaches `/api/generate` either in the top-level `thinking` field or
inline in `<think>` tags, never both. The field wins, matching `ollama_chat`."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_parse_content_for_reasoning,
)
if self.thinking:
return self.thinking, self.response
if self.response is None:
return None, None
return _parse_content_for_reasoning(self.response)
class OllamaConfig(BaseConfig):
"""
Reference: https://github.com/ollama/ollama/blob/main/docs/api.md#parameters
@ -255,20 +287,17 @@ class OllamaConfig(BaseConfig):
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
from litellm.litellm_core_utils.prompt_templates.common_utils import (
_parse_content_for_reasoning,
)
response_json: Final = raw_response.json()
## RESPONSE OBJECT
model_response.choices[0].finish_reason = "stop"
if request_data.get("format", "") == "json":
# Check if response field exists and is not empty before parsing JSON
response_text = response_json.get("response", "")
thinking: Final = _OllamaGenerateReasoning.from_response(response_json).thinking or None
if not response_text or not response_text.strip():
# Handle empty response gracefully - set empty content
message = litellm.Message(content="")
message = litellm.Message(content="", reasoning_content=thinking)
model_response.choices[0].message = message
model_response.choices[0].finish_reason = "stop"
else:
@ -285,6 +314,7 @@ class OllamaConfig(BaseConfig):
function_call: Final = response_content
message = litellm.Message(
content=None,
reasoning_content=thinking,
tool_calls=[
{
"id": f"call_{uuid.uuid4()}",
@ -302,27 +332,18 @@ class OllamaConfig(BaseConfig):
# Handle as regular JSON (new behavior)
message = litellm.Message(
content=json.dumps(response_content),
reasoning_content=thinking,
)
model_response.choices[0].message = message
model_response.choices[0].finish_reason = "stop"
except json.JSONDecodeError:
# If JSON parsing fails, treat as regular text response
## output parse reasoning content from response_text
reasoning_content: str | None = None
content: str | None = None
if response_text is not None:
reasoning_content, content = _parse_content_for_reasoning(response_text)
reasoning_content, content = _OllamaGenerateReasoning.from_response(response_json).split()
message = litellm.Message(content=content, reasoning_content=reasoning_content)
model_response.choices[0].message = message
model_response.choices[0].finish_reason = "stop"
else:
response_text = response_json.get("response", "")
content = None
reasoning_content = None
if response_text is not None and isinstance(response_text, str):
reasoning_content, content = _parse_content_for_reasoning(response_text)
else:
content = response_text
reasoning_content, content = _OllamaGenerateReasoning.from_response(response_json).split()
model_response.choices[0].message.content = content
model_response.choices[0].message.reasoning_content = reasoning_content
model_response.created = int(time.time())

View file

@ -414,6 +414,163 @@ class TestOllamaConfig:
)
assert result.choices[0]["finish_reason"] == "stop"
def _transform(
self, response_json: dict[str, object], request_data: dict[str, object] | None = None
) -> ModelResponse:
config = OllamaConfig()
raw_response = MagicMock()
raw_response.json.return_value = response_json
mock_encoding = MagicMock()
mock_encoding.encode.return_value = [1, 2, 3]
return config.transform_response(
model="gpt-oss:120b",
raw_response=raw_response,
model_response=ModelResponse(
id="test_id",
choices=[{"message": Message(content="")}],
),
logging_obj=MagicMock(),
request_data=request_data or {},
messages=[],
optional_params={},
litellm_params={},
encoding=mock_encoding,
)
def test_transform_response_with_thinking_field(self):
"""`/api/generate` returns reasoning in a top-level `thinking` field, which must
reach `reasoning_content` instead of being dropped."""
result = self._transform(
{
"response": "OK",
"thinking": 'We need to reply with exactly "OK".',
"prompt_eval_count": 15,
"eval_count": 8,
}
)
assert result.choices[0]["message"].reasoning_content == 'We need to reply with exactly "OK".'
assert result.choices[0]["message"].content == "OK"
assert result.choices[0]["finish_reason"] == "stop"
def test_transform_response_with_thinking_field_and_empty_response(self):
"""A model that spends its whole turn reasoning leaves `response` empty; the
reasoning still has to be surfaced rather than billed and discarded."""
result = self._transform(
{
"response": "",
"thinking": "Entire turn went into reasoning.",
"eval_count": 96,
}
)
assert result.choices[0]["message"].reasoning_content == "Entire turn went into reasoning."
assert result.choices[0]["message"].content == ""
def test_transform_response_thinking_field_wins_over_inline_tags(self):
"""When both shapes are present the field wins, matching the `ollama_chat` transport."""
result = self._transform(
{
"response": "<think>inline</think>Answer",
"thinking": "from field",
}
)
assert result.choices[0]["message"].reasoning_content == "from field"
assert result.choices[0]["message"].content == "<think>inline</think>Answer"
def test_transform_response_json_mode_non_json_text_with_thinking_field(self):
"""JSON mode falls back to text handling when the payload is not JSON, so the
`thinking` field has to be picked up on that path too."""
result = self._transform(
{
"response": "not valid json",
"thinking": "reasoning in json mode",
},
request_data={"format": "json"},
)
assert result.choices[0]["message"].reasoning_content == "reasoning in json mode"
assert result.choices[0]["message"].content == "not valid json"
def test_transform_response_empty_thinking_field_falls_back_to_tags(self):
"""An empty `thinking` field must not mask inline `<think>` tags."""
result = self._transform(
{
"response": "<think>inline reasoning</think>Answer",
"thinking": "",
}
)
assert result.choices[0]["message"].reasoning_content == "inline reasoning"
assert result.choices[0]["message"].content == "Answer"
def test_transform_response_json_mode_valid_json_keeps_thinking_field(self):
"""A valid JSON `response` is returned as content, and the reasoning that came
with it must not be dropped."""
result = self._transform(
{
"response": '{"answer": 42}',
"thinking": "reasoned before answering in json",
},
request_data={"format": "json"},
)
assert result.choices[0]["message"].content == '{"answer": 42}'
assert result.choices[0]["message"].reasoning_content == "reasoned before answering in json"
assert result.choices[0]["finish_reason"] == "stop"
def test_transform_response_json_mode_function_call_keeps_thinking_field(self):
"""A JSON `response` shaped like a function call becomes a tool call, and the
reasoning behind the call must survive alongside it."""
result = self._transform(
{
"response": '{"name": "get_weather", "arguments": {"city": "Paris"}}',
"thinking": "the user wants weather, so call the tool",
},
request_data={"format": "json"},
)
message = result.choices[0]["message"]
assert message.tool_calls is not None
assert message.tool_calls[0].function.name == "get_weather"
assert message.reasoning_content == "the user wants weather, so call the tool"
assert result.choices[0]["finish_reason"] == "tool_calls"
def test_transform_response_json_mode_empty_response_keeps_thinking_field(self):
"""In JSON mode a model that spends its whole turn reasoning leaves `response`
empty; the reasoning must still come back instead of a blank message."""
result = self._transform(
{
"response": "",
"thinking": "all of the tokens went into reasoning",
},
request_data={"format": "json"},
)
assert result.choices[0]["message"].content == ""
assert result.choices[0]["message"].reasoning_content == "all of the tokens went into reasoning"
def test_transform_response_null_response_keeps_content_null(self):
"""Ollama sends `response: null` when the reply carries no text; that stays null
rather than becoming an empty string, while `thinking` is still surfaced."""
result = self._transform({"response": None, "thinking": "reasoning only"})
assert result.choices[0]["message"].content is None
assert result.choices[0]["message"].reasoning_content == "reasoning only"
def test_transform_response_malformed_reasoning_fields_do_not_crash(self):
"""A reply whose `response` and `thinking` are not strings must still produce a
response instead of raising, with no reasoning invented."""
result = self._transform({"response": 5, "thinking": ["not", "a", "string"]})
assert result.choices[0]["message"].reasoning_content is None
assert result.choices[0]["message"].content == ""
assert result.choices[0]["finish_reason"] == "stop"
class TestOllamaTextCompletionResponseIterator:
def test_chunk_parser_with_thinking_field(self):