<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Litellm 1.106.5 on RockB</title><link>https://baeseokjae.github.io/tags/litellm-1.106.5/</link><description>Recent content in Litellm 1.106.5 on RockB</description><image><title>RockB</title><url>https://baeseokjae.github.io/images/og-default.png</url><link>https://baeseokjae.github.io/images/og-default.png</link></image><generator>Hugo</generator><language>en-us</language><lastBuildDate>Wed, 28 Oct 2026 16:00:00 +0000</lastBuildDate><atom:link href="https://baeseokjae.github.io/tags/litellm-1.106.5/index.xml" rel="self" type="application/rss+xml"/><item><title>LiteLLM Rewrites Your Tool Call IDs: Debugging Broken Multi-Turn Tool Loops Across Ollama, Gemini and Responses Bridges</title><link>https://baeseokjae.github.io/posts/litellm-tool-call-id-translation-2026/</link><pubDate>Wed, 28 Oct 2026 16:00:00 +0000</pubDate><guid>https://baeseokjae.github.io/posts/litellm-tool-call-id-translation-2026/</guid><description>A litellm 1.106.5 probe with recorded upstream fixtures: ollama_chat/ drops assistant tool-call ids, streaming replaces the provider&amp;#39;s id with a UUID, and a dict-shaped delta silently erases the call. Three reproducible failures, one case that did not reproduce, and a guard that keeps call identity stable.</description><content:encoded><![CDATA[<p>Your agent calls a tool through a LiteLLM proxy. Turn one returns a tool call, the tool runs, and turn two comes back with an empty tool result or a 400 from the provider. The model never saw a different question — it saw a conversation whose tool call and tool result no longer share an identifier. That single broken invariant produces a whole family of symptoms that look like model failures, and the code paths responsible sit in the proxy, not in the model.</p>
<p>This article separates three failures that reproduce against the current release, one claim that did not reproduce in the harness used here, and one reported case that is kept at &ldquo;reported, not verified&rdquo;. It ends with a guard you can put in front of the proxy so the next broken loop is detectable instead of mysterious.</p>
<h2 id="what-was-actually-run-and-what-that-is-worth">What was actually run, and what that is worth</h2>
<p>Every runtime observation below comes from feeding recorded upstream payloads into litellm&rsquo;s own code paths at version 1.106.5 — the release uploaded 2026-10-27 according to the <a href="https://pypi.org/pypi/litellm/1.106.5/json">PyPI project metadata</a>. The fixtures are ordinary JSON: an OpenAI-shaped chunk stream and two native Ollama chunks. They are handed to <code>CustomStreamWrapper</code>, <code>stream_chunk_builder()</code>, <code>OllamaChatConfig.transform_request()</code> and <code>OllamaChatCompletionResponseIterator.chunk_parser()</code>. No proxy process was started, no socket was opened, no provider was called, and no key was used.</p>
<p>That design is deliberate: because the fixture <em>is</em> the upstream, any difference between the fixture and the emitted result is attributable to the proxy hop. It is a private local author verification, not a vendor measurement and not a published dataset — the probe script, fixtures and captured outputs live in the site repository&rsquo;s review evidence directory and are not offered as a reader download. The rest of the evidence is primary and public: the shipped source of the two releases, the open issue reports, and the <a href="https://pypi.org/pypi/litellm/1.95.0/json">1.95.0 release metadata</a> that fixes the version named in the original bug report.</p>
<p>One scope correction matters before anything else. The topic was scoped to 1.95.0, released 2026-08-02. The id-destroying sites described below are unchanged in 1.106.5, so this is not a historical bug that a <code>pip install -U</code> has already closed.</p>
<h2 id="three-failures-that-reproduce-at-11065">Three failures that reproduce at 1.106.5</h2>
<h3 id="1-ollama_chat-rebuilds-the-assistant-call-without-its-id-then-forwards-the-results-id">1. ollama_chat/ rebuilds the assistant call without its id, then forwards the result&rsquo;s id</h3>
<p>The request transform for the Ollama chat provider rebuilds every assistant <code>tool_calls</code> entry as a fresh <code>OllamaToolCall(function=OllamaToolCallFunction(name=..., arguments=...))</code> — a structure with no id field at all — while the tool-result message keeps its <code>tool_call_id</code> verbatim. The two sites are adjacent in the <a href="https://github.com/BerriAI/litellm/blob/main/litellm/llms/ollama/chat/transformation.py">shipped transformation module</a>: the rebuild discards the id, the passthrough keeps it.</p>
<p>Fed a three-message conversation — user, assistant call <code>call_up_F1</code>, tool result <code>call_up_F1</code> — the transform returned an outgoing assistant message whose first tool call had no id, and a tool message whose <code>tool_call_id</code> was still <code>call_up_F1</code>. The provider is therefore asked to match a result to a call it was never told the name of. In practice that surfaces as empty tool output, or as a model that re-requests the same tool because it cannot see the answer. The open report <a href="https://github.com/BerriAI/litellm/issues/48457">BerriAI/litellm#48457</a>, created 2026-10-26, describes exactly this and notes that it reproduces through the Chat Completions, Responses and Anthropic Messages bridges, which is what makes it a proxy-layer problem rather than one bad integration.</p>
<h3 id="2-streaming-replaces-an-id-the-provider-actually-sent-with-a-uuid">2. Streaming replaces an id the provider actually sent with a UUID</h3>
<p>Ollama&rsquo;s own streaming protocol does emit tool-call ids. The provider&rsquo;s chunk parser still overwrites them. In the streaming parser, whenever the arguments parse as a complete call, the code executes <code>tool_call[&quot;id&quot;] = str(uuid.uuid4())</code> with no check for an id already present — the same statement in both releases, and visible in the <a href="https://github.com/BerriAI/litellm/blob/main/litellm/llms/ollama/chat/transformation.py">current transformation module</a>. The open report is <a href="https://github.com/BerriAI/litellm/issues/46543">BerriAI/litellm#46543</a>, created 2026-10-14.</p>
<p>Replaying a native Ollama chunk that carried <code>id: &quot;call_8uaqob2r&quot;</code> through the chunk parser at 1.106.5 produced <code>id: &quot;cce1e8a6-e2ef-4364-a9e4-87db6c7825a6&quot;</code> in the emitted delta. The function name and arguments survived; the identity did not. Any client that matches a later tool result by id — which is the correct thing to do — now holds an id the proxy will never emit again.</p>
<h3 id="3-one-object-instead-of-a-list-and-the-call-disappears-without-a-word">3. One object instead of a list, and the call disappears without a word</h3>
<p>The stream assembly path normalises tool calls only when <code>delta.tool_calls</code> is a list. A delta that carries a single object is never normalised, and nothing raises. Assembling a two-chunk stream whose first delta held <code>tool_calls</code> as an object, rather than a one-element list, returned a message with <code>tool_calls == []</code> and no exception, no warning, and no log line at the default level in this harness. The reported behaviour and the localisation are in <a href="https://github.com/BerriAI/litellm/issues/46202">BerriAI/litellm#46202</a>, created 2026-10-13.</p>
<p>This is the worst failure mode of the three because it is silent. A dropped call and a model that chose not to call a tool are indistinguishable from the client side, so the obvious next step — retry, raise the temperature, change the model — is wasted effort.</p>
<h2 id="what-did-not-reproduce">What did not reproduce</h2>
<p>Two related claims are worth separating from the failures, because repeating them would be wrong.</p>
<p>First, the whole-call-in-one-delta case. <a href="https://github.com/BerriAI/litellm/issues/39796">BerriAI/litellm#39796</a>, open since 2026-09-04, reports that a proxy in passthrough mode drops <code>tool_calls[].id</code> and <code>function.name</code> when the upstream sends a complete call in a single SSE delta, leaving only <code>arguments</code>. Replaying that exact shape through <code>CustomStreamWrapper</code> at 1.106.5 emitted a fragment that still carried <code>id: &quot;call_up_F2&quot;</code> and <code>name: &quot;get_ticket_status&quot;</code>. The split-delta shape also kept its identity on the fragment that opens the call. That does not refute the report — the reporter&rsquo;s configuration is the proxy passthrough route with <code>modify_params: false</code>, which is a different code path from the wrapper exercised here — but it does mean the claim stays &ldquo;reported&rdquo; in this article and you should test your own route rather than assume it.</p>
<p>Second, the Gemini case. <a href="https://github.com/BerriAI/litellm/issues/41534">BerriAI/litellm#41534</a>, created 2026-09-17, reports that with a router fallback chain a thought signature embedded in <code>tool_call.id</code> is forwarded into the Responses API <code>input[N].id</code> and OpenAI rejects the request with a 400 string-too-long. A grep for <code>thought_signature</code> across the responses transformation, the Gemini chat transformation and <code>router.py</code> in the 1.106.5 wheel returned zero hits, so the handling either lives elsewhere or the signature enters through provider-specific fields. It is left out of the pass/fail set rather than softened into a guess.</p>
<h2 id="prove-the-corruption-is-in-the-proxy-not-the-model">Prove the corruption is in the proxy, not the model</h2>
<p>The method that settles these arguments is cheap: capture both sides of the hop and compare identities per call.</p>
<ol>
<li>Log the raw upstream SSE bytes before the proxy touches them — the provider&rsquo;s own <code>id</code>, <code>function.name</code> and <code>index</code> for each tool call fragment.</li>
<li>Log what your client receives, or what <code>stream_chunk_builder()</code> assembles.</li>
<li>Key the comparison on id, name and index, not on the arguments string, which legitimately changes between fragments.</li>
<li>On the next turn, assert that every assistant <code>tool_calls[].id</code> has a matching tool-result <code>tool_call_id</code> in the request body you are about to send.</li>
</ol>
<p>If step 1 shows an id and step 3 shows a different one, the proxy rewrote it. If step 1 shows no id at all, the provider never sent one and the proxy is synthesising — which is legal only if it synthesises once and reuses the same value for the result. Keeping that boundary explicit is the same discipline you need when auditing a recorded trajectory after the fact, which is what <a href="https://baeseokjae.github.io/posts/toolreplay-agent-tool-call-audit-2026/">ToolReplay&rsquo;s session replay approach</a> does, and when watching tool calls and token spend together, as in <a href="https://baeseokjae.github.io/posts/agent-trajectory-monitor-tool-calls-token-spend/">agent trajectory monitoring</a>.</p>
<h2 id="a-guard-that-keeps-call-identity-stable">A guard that keeps call identity stable</h2>
<p>The following wrapper sits in front of the provider call and fixes the three reproducible failures without patching litellm. It preserves ids that exist, synthesises one only when the provider omitted it, normalises a dict-shaped <code>tool_calls</code> into a list, and refuses to send a conversation whose tool results reference ids no assistant message contains.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> json<span style="color:#f92672">,</span> uuid
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">normalize_tool_calls</span>(raw):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> raw <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> []
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> [raw] <span style="color:#66d9ef">if</span> isinstance(raw, dict) <span style="color:#66d9ef">else</span> list(raw)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">guard_request</span>(body):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;body: OpenAI-shaped request dict with messages[]. Fail closed on identity breaks.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    seen <span style="color:#f92672">=</span> set()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> msg <span style="color:#f92672">in</span> body<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;messages&#34;</span>, []):
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;role&#34;</span>) <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;assistant&#34;</span>:
</span></span><span style="display:flex;"><span>            calls <span style="color:#f92672">=</span> normalize_tool_calls(msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;tool_calls&#34;</span>))
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> call <span style="color:#f92672">in</span> calls:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">if</span> <span style="color:#f92672">not</span> call<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;id&#34;</span>):                 <span style="color:#75715e"># provider omitted the id</span>
</span></span><span style="display:flex;"><span>                    call[<span style="color:#e6db74">&#34;id&#34;</span>] <span style="color:#f92672">=</span> <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;call_</span><span style="color:#e6db74">{</span>uuid<span style="color:#f92672">.</span>uuid4()<span style="color:#f92672">.</span>hex[:<span style="color:#ae81ff">16</span>]<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>            msg[<span style="color:#e6db74">&#34;tool_calls&#34;</span>] <span style="color:#f92672">=</span> calls
</span></span><span style="display:flex;"><span>            seen<span style="color:#f92672">.</span>update(c[<span style="color:#e6db74">&#34;id&#34;</span>] <span style="color:#66d9ef">for</span> c <span style="color:#f92672">in</span> calls)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">elif</span> msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;role&#34;</span>) <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;tool&#34;</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#34;tool_call_id&#34;</span>) <span style="color:#f92672">not</span> <span style="color:#f92672">in</span> seen:
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(
</span></span><span style="display:flex;"><span>                    <span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;orphan tool result </span><span style="color:#e6db74">{</span>msg<span style="color:#f92672">.</span>get(<span style="color:#e6db74">&#39;tool_call_id&#39;</span>)<span style="color:#e6db74">!r}</span><span style="color:#e6db74">: no assistant call carries that id&#34;</span>
</span></span><span style="display:flex;"><span>                )
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> body
</span></span></code></pre></div><p>Use it with two rules the probe justifies. Synthesise ids at the point of creation, never at the point of forwarding, so the same value reaches both the assistant message and the tool result. And treat a missing <code>tool_calls</code> entry after assembly as an error, not as an empty list, because failure 3 above produces exactly that and stays quiet about it.</p>
<h2 id="before-you-upgrade-or-blame-the-model">Before you upgrade or blame the model</h2>
<p>Check the version first. Both <code>ollama_chat/</code> id sites are unchanged between 1.95.0 and 1.106.5, so an upgrade alone does not close failures 1 and 2; the version is still worth pinning in any report you file. Then check the route: the passthrough streaming path and the standard wrapper path differ, and the difference decides whether failure 3 in this article is your failure too. Finally, if your architecture puts a gateway in front of several providers, the wire-format question and the routing question are separate decisions, and the <a href="https://baeseokjae.github.io/posts/llm-gateway-comparison-2026/">LLM gateway comparison</a> covers the second while this article covers the first. If the hop is direct to Ollama with no proxy in between, the failure belongs to a different layer and the <a href="https://baeseokjae.github.io/posts/ollama-api-local-llm-integration-developer-guide-2026/">Ollama API integration guide</a> is the closer reference.</p>
<p>The narrow conclusion: at 1.106.5 a recorded upstream call with id <code>call_8uaqob2r</code> is re-emitted with a freshly generated UUID, an assistant call loses its id while its result keeps one, and a single-object delta erases the call without an error. Those are three testable statements, each with a fixture, a captured output and an open upstream report behind it — enough to stop blaming the model for a broken identifier.</p>
]]></content:encoded></item></channel></rss>