Skip to content

Commit e896070

Browse files
fix(compiler): strip cache_control for non-Anthropic providers (#154)
The compiler tags reusable prompt context with an Anthropic ephemeral `cache_control` marker (`_cached_text`). The docstring assumed providers that don't support it would simply ignore it — but LiteLLM translates the marker into a provider-native cached-content object for Gemini, which then conflicts with `system_instruction`/`tools` and fails every request with `400 CachedContent can not be used with ...`. As a result, *all* Gemini compiles fail out of the box. Strip the marker at the single request egress (`_llm_call` / `_llm_call_async`) for any non-Anthropic provider, keeping it for Anthropic direct and Claude via OpenRouter/Bedrock/Vertex. Anthropic prompt caching is unchanged; Gemini's implicit caching still applies to the plain text blocks. Provider detection uses litellm.get_llm_provider, imported locally so it stays correct even when tests patch the module-level `litellm` reference. Adds TestCacheControlStripping covering provider gating, marker removal (non-mutating), and the sync stripping/keeping paths. Co-authored-by: Aldominguez12 <191896285+Aldominguez12@users.noreply.github.com>
1 parent 6194c5e commit e896070

2 files changed

Lines changed: 129 additions & 2 deletions

File tree

openkb/agent/compiler.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,75 @@ def _cached_text(text: str) -> list[dict]:
262262
ephemeral cache_control marker.
263263
264264
LiteLLM passes the marker through to Anthropic (and OpenRouter →
265-
Anthropic). For providers that ignore cache_control, the list-of-blocks
266-
payload remains a valid OpenAI-compatible content shape.
265+
Anthropic). For other providers the marker is stripped at the request
266+
egress (see :func:`_strip_cache_control`, applied in :func:`_llm_call`),
267+
because not every provider merely *ignores* it — Gemini in particular
268+
turns it into a 400. The list-of-blocks payload that remains is a valid
269+
OpenAI-compatible content shape.
267270
"""
268271
return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}]
269272

270273

274+
def _accepts_cache_control(model: str) -> bool:
275+
"""Whether ``model`` honours Anthropic-style ``cache_control`` markers.
276+
277+
The markers emitted by :func:`_cached_text` are an Anthropic feature.
278+
LiteLLM forwards them to Anthropic directly, and to Anthropic (Claude)
279+
models served via OpenRouter, Bedrock and Vertex. For other providers —
280+
notably Gemini — LiteLLM instead translates the marker into a
281+
provider-native cached-content object that conflicts with
282+
``system_instruction``/``tools`` and makes *every* request fail with
283+
``400 CachedContent can not be used with ...``. Detect the provider so the
284+
marker can be dropped before it reaches such a backend.
285+
"""
286+
# Import the real symbol rather than going through the module-level
287+
# ``litellm`` reference: provider detection must stay correct even when a
288+
# caller patches ``openkb.agent.compiler.litellm`` to stub out completion.
289+
from litellm import get_llm_provider
290+
291+
try:
292+
provider = get_llm_provider(model)[1]
293+
except Exception:
294+
provider = ""
295+
lowered = model.lower()
296+
if provider == "anthropic":
297+
return True
298+
if provider in ("openrouter", "bedrock", "vertex_ai") and (
299+
"claude" in lowered or "anthropic" in lowered
300+
):
301+
return True
302+
return False
303+
304+
305+
def _strip_cache_control(messages: list[dict]) -> list[dict]:
306+
"""Return ``messages`` with every ``cache_control`` key removed.
307+
308+
Only list-of-blocks contents (see :func:`_cached_text`) can carry the
309+
marker; plain-string contents pass through untouched. The input is not
310+
mutated.
311+
"""
312+
cleaned: list[dict] = []
313+
for msg in messages:
314+
content = msg.get("content")
315+
if isinstance(content, list):
316+
blocks = [
317+
{k: v for k, v in block.items() if k != "cache_control"}
318+
if isinstance(block, dict)
319+
else block
320+
for block in content
321+
]
322+
msg = {**msg, "content": blocks}
323+
cleaned.append(msg)
324+
return cleaned
325+
326+
327+
def _prepare_messages(model: str, messages: list[dict]) -> list[dict]:
328+
"""Drop cache_control markers when ``model`` would reject them."""
329+
if _accepts_cache_control(model):
330+
return messages
331+
return _strip_cache_control(messages)
332+
333+
271334
class _Spinner:
272335
"""Animated dots spinner that runs in a background thread."""
273336

@@ -328,6 +391,7 @@ def _fmt_messages(messages: list[dict], max_content: int = 200) -> str:
328391

329392
def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str:
330393
"""Single LLM call with animated progress and debug logging."""
394+
messages = _prepare_messages(model, messages)
331395
extra_headers = get_extra_headers()
332396
if extra_headers:
333397
kwargs.setdefault("extra_headers", extra_headers)
@@ -353,6 +417,7 @@ def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str
353417

354418
async def _llm_call_async(model: str, messages: list[dict], step_name: str, **kwargs) -> str:
355419
"""Async LLM call with timing output and debug logging."""
420+
messages = _prepare_messages(model, messages)
356421
extra_headers = get_extra_headers()
357422
if extra_headers:
358423
kwargs.setdefault("extra_headers", extra_headers)

tests/test_compiler.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2260,6 +2260,68 @@ async def test_llm_call_async_injects_extra_headers(self):
22602260
assert kwargs["extra_headers"] == {"Copilot-Integration-Id": "vscode-chat"}
22612261

22622262

2263+
class TestCacheControlStripping:
2264+
"""cache_control markers must only reach providers that honour them.
2265+
2266+
``_cached_text`` tags payloads with an Anthropic ``cache_control`` marker.
2267+
LiteLLM turns that marker into a hard 400 for Gemini ("CachedContent can not
2268+
be used with system_instruction/tools") and silently wastes it on other
2269+
non-Anthropic providers, so ``_llm_call``/``_llm_call_async`` strip it for
2270+
every non-Anthropic model. Regression for the all-Gemini-compiles-fail bug.
2271+
"""
2272+
2273+
def test_accepts_for_anthropic_providers(self):
2274+
from openkb.agent.compiler import _accepts_cache_control
2275+
2276+
assert _accepts_cache_control("anthropic/claude-sonnet-4-6")
2277+
assert _accepts_cache_control("claude-opus-4-6")
2278+
# Claude served via OpenRouter still honours the marker.
2279+
assert _accepts_cache_control("openrouter/anthropic/claude-3.5-sonnet")
2280+
2281+
def test_rejects_for_non_anthropic_providers(self):
2282+
from openkb.agent.compiler import _accepts_cache_control
2283+
2284+
assert not _accepts_cache_control("gemini/gemini-2.5-pro")
2285+
assert not _accepts_cache_control("gpt-4o")
2286+
2287+
def test_strip_removes_marker_without_mutating_input(self):
2288+
from openkb.agent.compiler import _cached_text, _strip_cache_control
2289+
2290+
messages = [
2291+
{"role": "system", "content": "plain string stays"},
2292+
{"role": "user", "content": _cached_text("doc")},
2293+
]
2294+
cleaned = _strip_cache_control(messages)
2295+
# Plain-string content passes through untouched.
2296+
assert cleaned[0]["content"] == "plain string stays"
2297+
# Marker gone, text preserved.
2298+
assert cleaned[1]["content"] == [{"type": "text", "text": "doc"}]
2299+
# Original input is not mutated.
2300+
assert "cache_control" in messages[1]["content"][0]
2301+
2302+
def test_llm_call_strips_marker_for_gemini(self):
2303+
from openkb.agent.compiler import _cached_text, _llm_call
2304+
2305+
with patch("openkb.agent.compiler.litellm.completion",
2306+
MagicMock(side_effect=_mock_completion(["ok"]))) as mock_completion:
2307+
_llm_call("gemini/gemini-2.5-pro",
2308+
[{"role": "user", "content": _cached_text("doc")}], "step")
2309+
sent = mock_completion.call_args.kwargs["messages"]
2310+
block = sent[0]["content"][0]
2311+
assert "cache_control" not in block
2312+
assert block["text"] == "doc"
2313+
2314+
def test_llm_call_keeps_marker_for_anthropic(self):
2315+
from openkb.agent.compiler import _cached_text, _llm_call
2316+
2317+
with patch("openkb.agent.compiler.litellm.completion",
2318+
MagicMock(side_effect=_mock_completion(["ok"]))) as mock_completion:
2319+
_llm_call("anthropic/claude-sonnet-4-6",
2320+
[{"role": "user", "content": _cached_text("doc")}], "step")
2321+
sent = mock_completion.call_args.kwargs["messages"]
2322+
assert sent[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
2323+
2324+
22632325
class TestFrontmatterDashBoundary:
22642326
"""Regression: description containing '---' must not truncate frontmatter."""
22652327

0 commit comments

Comments
 (0)