fix(ollama): emit streamed tool calls instead of raw JSON content#34769
Conversation
Greptile SummaryThis PR adds request-scoped streamed tool-call detection for the Ollama completion adapter.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the current code scopes detection to requests with prompted tools and validates returned function names against the functions offered by that request.
|
| Filename | Overview |
|---|---|
| litellm/llms/ollama/completion/transformation.py | Adds request-scoped buffering and validation that converts completed Ollama JSON responses into streamed tool calls only for functions offered by the request. |
| litellm/utils.py | Preserves the tool schemas rewritten into the Ollama prompt as internal request metadata for response validation. |
| tests/test_litellm/llms/ollama/test_ollama_completion_transformation.py | Adds regression and guard coverage for streamed tool conversion, ordinary content, JSON content, offered-name scoping, and request-marker removal. |
Reviews (5): Last reviewed commit: "fix(ollama): emit streamed tool calls in..." | Re-trigger Greptile
PR overviewThis pull request updates Ollama completion transformation so streamed JSON tool-call responses are emitted as structured tool calls instead of raw content. One issue has already been addressed, but streamed JSON output can still grow without a byte limit and incur quadratic copying. An API caller able to request a large response could cause excessive memory and CPU consumption in the proxy, creating a limited denial-of-service risk. Open issues (1)
Fixed/addressed: 1 · PR risk: 5/10 |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
92443fe to
cb6b173
Compare
|
Reworked to address the review. The conversion is now gated on the exact object litellm's own tool prompt asks ollama for: a string Added two guards for the cases raised: a JSON body carrying extra keys alongside |
cb6b173 to
439accf
Compare
|
Pushed
Within JSON mode the accepted shape is exactly what New guards: a tool-shaped body streams untouched when the request never set |
| if self.tool_call_buffer is None: | ||
| return content | ||
|
|
||
| self.tool_call_buffer += content |
There was a problem hiding this comment.
Low: Unbounded streamed-output buffer
An API caller can prompt Ollama to generate a large JSON object and select a large max_tokens; because the output continues to start with {, this concatenation retains the entire response and repeatedly copies the growing string, consuming unbounded memory and quadratic CPU in the proxy. Accumulate fragments without repeated copying and enforce a byte limit, releasing the buffered value as ordinary content once that limit is reached.
439accf to
225c000
Compare
|
Pushed By the time a request reaches the provider its tools survive only as prompt text, so nothing downstream could tell a tool request apart from any other JSON-mode request. A request that offered no tools never buffers a byte, New guards: detection armed only for rewritten requests, the marker never appearing in the ollama request body, and a tool-shaped body streaming untouched when no tools were offered. |
Tools are not a supported OpenAI param on the ollama/ route, so get_optional_params rewrites them into a JSON-only prompt and sets format=json. transform_response closes that loop on the non-streaming path: it parses the finished body and turns a name/arguments object into a real tool_calls message with finish_reason tool_calls. OllamaTextCompletionResponseIterator.chunk_parser never had the second half, so streaming sent the JSON object out as content deltas and ended the turn with stop. The tool was never invoked and the raw JSON was rendered to the end user. chunk_parser only ever sees fragments, so it cannot parse mid-stream. Hold response text back while the accumulated text is still consistent with a JSON object, release everything held the moment it cannot be one, and decide at done. Prose fails that check on its first fragment, so plain text streaming is unaffected. By the time the request reaches the provider the tools survive only as prompt text, so get_optional_params passes on the functions it rewrote. transform_request consumes that, so it never reaches ollama, and it is what arms the iterator. A request that offered no tools never buffers a byte, json_object mode included, and a body naming a function the request did not offer stays content. The object itself must match what litellm asks ollama for in function_call_prompt: name, arguments, and nothing else.
225c000 to
db1cc73
Compare
|
Pushed
Verified against a live proxy in front of a real ollama: the tool call streams as a proper |
TLDR
Problem this solves:
ollama/silently drops every tool call whenstreamis true{"name": ..., "arguments": ...}JSON is streamed to the user as contentfinish_reasoncomes back"stop", so callers never run the toolHow it solves it:
transform_responsealready hasdoneRelevant issues
Fixes #19742
That issue reports the raw JSON blob rendered in the chat window with an
ollama/model configured to stream. It was closed by the stale bot rather than fixed, and it is reproducible on currentlitellm_internal_staging; the reason it read as unreproducible is that it only happens whenstreamis true, and the same request without streaming looks fine.Linear ticket
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Screenshots / Proof of Fix
Live proxy on
localhost:4000in front of a local ollama servingqwen2.5:3b. No mocks anywhere; the requests below hit the real model. Ollama is free to run, so this cost time rather than dollars, but it is a real provider call over HTTP end to end.The request, identical for both runs, at
temperature: 0and a fixed seed:Before, at
24123269cc(base of this branch). The tool call arrives as 21 content chunks and the turn ends withfinish_reason: "stop". Trimmed to the first and last chunks:Reassembling that stream:
The caller sees a plain text answer, the tool is never invoked, and that JSON is what the end user reads.
After, at
db1cc736ad(this branch), same proxy, same config, same request:That now matches what the same request has always returned with
"stream": false, which is the behaviour this is being brought in line with:Plain streaming is unaffected. Same proxy at
db1cc736ad, no tools, still token by token:JSON mode without tools is also untouched, still fragment by fragment:
Type
🐛 Bug Fix
Changes
Tools are not a supported OpenAI param on the
ollama/route, soget_optional_paramsrewrites them into a JSON-only system prompt and setsformat: "json"on the request.OllamaConfig.transform_responsecompletes that round trip: it parses the finished body and, when the object carriesnameandarguments, turns it into a realtool_callsmessage withfinish_reason: "tool_calls".OllamaTextCompletionResponseIterator.chunk_parsernever had the second half, so on the streaming path the JSON object was emitted verbatim as content deltas and the turn closed with"stop".chunk_parseronly ever sees fragments, so it cannot parse mid-stream._hold_back_or_releaseholds response text back while the accumulated text is still consistent with a JSON object, and releases everything held the moment it cannot be one. Prose fails that check on its first fragment, so plain text streaming keeps flowing exactly as before. Atdone,_build_tool_callvalidates the held text and either emits a chunk carryingtool_useandfinish_reason: "tool_calls", or flushes the held text as content withfinish_reason: "stop"so nothing is ever dropped.Detection is not on by default, and it is keyed off the functions the request actually offered. By the time a request reaches the provider the tools survive only as prompt text, so
get_optional_paramspasses on the schemas it rewrote asprompted_tool_calls;transform_requestconsumes that, so it never reaches ollama, and hands the function names throughget_model_response_iteratorto the iterator. A request that offered no tools never buffers a byte and cannot have its content orfinish_reasonchanged. That includesresponse_format: {"type": "json_object"}, which streams fragment by fragment exactly as it does today. A body naming a function the request did not offer also stays content, so a model that invents a name cannot have it synthesised into a call.Within a request that did offer tools, what counts as a tool call is deliberately stricter than the non-streaming test.
_OllamaJsonModeToolCallrequires exactly the objectfactory.function_call_promptasks ollama to produce: a stringname, anargumentsobject, and no other keys.transform_responseonly checks thatnameandargumentsare present, so any body this converts is a body that path already converts today; the streaming path cannot reinterpret content the non-streaming path would have left alone. A JSON response that merely happens to carry aname, or one whoseargumentsis not an object, stays content and keepsfinish_reason: "stop".Within such a request, a JSON body that is not a tool call arrives in the final chunk rather than fragment by fragment, because a JSON object cannot be told apart from a tool call until it is complete. The content itself is unchanged.
ollama_chat/is untouched. It getstool_callsnatively from/api/chatand needs none of this.Ten tests in
tests/test_litellm/llms/ollama/test_ollama_completion_transformation.pycover this. Two reproduce the bug: one driveslitellm.completion(..., stream=True)with tools against a transport that fragments the body the way ollama does, and one exerciseschunk_parserdirectly. The rest are guards: prose arriving one delta per fragment, a non-tool JSON body delivered in full as content, a JSON object with extra keys alongsidename/argumentsstaying content,argumentsthat is not an object staying content, a tool-shaped body streaming untouched when the request offered no tools, a body naming a function the request never offered staying content, detection being armed only with the functionsget_optional_paramsactually rewrote, and the marker never reaching ollama.Reverting
transformation.pyto base with the tests kept fails the end-to-end one on exactly the reported symptom, while the guard that a tool-less request is left alone keeps passing:The other two failures there are the gating tests, which cannot run at base because the marker and the parameter they assert on do not exist yet.
test_detection_is_off_for_a_request_that_offered_no_toolspasses at base, which is the point of it.Final Attestation