Skip to content

feat(guardrails/headroom): add CCR (compress-cache-retrieve) via agentic loop - #31681

Merged
krrish-berri-2 merged 18 commits into
litellm_internal_stagingfrom
litellm_headroom_ccr
Jul 1, 2026
Merged

feat(guardrails/headroom): add CCR (compress-cache-retrieve) via agentic loop#31681
krrish-berri-2 merged 18 commits into
litellm_internal_stagingfrom
litellm_headroom_ccr

Conversation

@krrish-berri-2

@krrish-berri-2 krrish-berri-2 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting `@greptileai` and received a Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Headroom sidecar running at https://headroom-mock.onrender.com.

Run the proxy:

python litellm/proxy/proxy_cli.py --config headroom_ccr_test.yaml --port 4000 --detailed_debug

Config (headroom_ccr_test.yaml):

model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY
  - model_name: claude-sonnet-4-5
    litellm_params:
      model: anthropic/claude-sonnet-4-5-20250929
      api_key: os.environ/ANTHROPIC_API_KEY

guardrails:
  - guardrail_name: headroom
    litellm_params:
      guardrail: headroom
      api_base: https://headroom-mock.onrender.com
      mode: pre_call
      default_on: true

/v1/chat/completions

curl -s http://localhost:4000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-1234" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [{"role":"user","content":"<27000 char system context...> What is 2+2?"}],
    "max_tokens": 30
  }'

Proxy logs showing the full CCR loop:

19:42:05 - LiteLLM Proxy:DEBUG: headroom.py:305 - Headroom: compressed 5078 tokens -> 519 tokens (ratio 0.10)
# ^^ apply_guardrail calls POST /v1/compress, gets compressed messages with CCR hash marker,
#    injects headroom_retrieve tool into the LLM request

19:42:05 - Final returned optional params: {'tools': [{'function': {'name': 'headroom_retrieve', ...}}]}
# ^^ LLM receives compressed message + headroom_retrieve tool

19:42:06 - LiteLLM Proxy:DEBUG: headroom.py:437 - Headroom CCR: retrieved hash=7636d481e56ee5e3d855a6fd (18313 chars)
# ^^ LLM called headroom_retrieve; async_build_agentic_loop_plan called
#    GET /v1/retrieve/7636d481e56ee5e3d855a6fd and got back 18313 chars of original content

19:42:06 - litellm.acompletion(model='openai/gpt-4o-mini', messages=[...full context injected as tool_result...])
# ^^ LLM replayed with full context as tool_result message

Response (LLM answered correctly with retrieved full context):

{"choices":[{"message":{"content":"2 + 2 equals 4."}}]}

The full CCR round-trip: 5078 tokens compressed to 519, LLM retrieved 18313 chars of original content via the agentic loop, answered correctly.

/v1/messages (Anthropic Messages API)

A secret string was placed past the compression truncation point (padded with filler text) so the model can only answer by retrieving the compressed content:

curl -s http://localhost:4000/v1/messages \
  -H "Content-Type: application/json" \
  -H "x-api-key: sk-1234" \
  -d '{
    "model": "claude-sonnet-4-5",
    "max_tokens": 500,
    "messages": [{"role":"user","content":"<filler text padding past truncation> The secret launch code is ECHO-FOUR-ROMEO-NINE. What is the secret launch code?"}],
    "tool_choice": {"type": "tool", "name": "headroom_retrieve"}
  }'

Proxy logs showing the full CCR loop against the real Anthropic API:

17:54:43 - LiteLLM Proxy:DEBUG: headroom.py:339 - Headroom: compressed 4526 tokens -> 519 tokens (ratio 0.11)
# ^^ apply_guardrail compresses the request, injects the headroom_retrieve tool
#    (in Anthropic's native tool shape: {"type": "custom", "name": ..., "input_schema": ...})

17:54:48 - LiteLLM Proxy:DEBUG: headroom.py:494 - Headroom CCR: retrieved hash=3778113138d30000f1359c81 (16107 chars)
# ^^ Claude called headroom_retrieve with the compression hash; async_build_agentic_loop_plan
#    validated the hash against this call's issued-hash set and called GET /v1/retrieve/{hash}

This surfaced two real bugs that mocked unit tests (which used MagicMock responses) had been masking:

  • has_headroom_retrieve_tool only recognized OpenAI-shaped function tools; by the time a Messages API response reaches the gate, the injected tool has already been transformed into Anthropic's native {"type": "custom", "name": ...} shape, so the gate never fired for real Anthropic traffic.
  • AnthropicMessagesResponse is a TypedDict, so real responses are plain dicts at runtime, not objects with attribute access — the extractors used bare getattr(), which silently returns nothing for dict responses.

Both are fixed, with regression tests using plain-dict responses (not MagicMock) so this can't regress silently again.

/v1/responses

Routes through the same shared get_tool_calls_from_response/has_tool_with_name helpers and the dedicated function_call/function_call_output replay branch; covered by unit tests (test_async_should_run_agentic_loop_detects_responses_api_output_format, test_async_build_agentic_loop_plan_builds_responses_api_function_call_items) since ResponsesAPIResponse is a real Pydantic model at runtime (not a TypedDict), so it wasn't affected by the dict-vs-object bug above.

Type

New Feature

Changes

Extends the existing HeadroomGuardrail with CCR (Compress-Cache-Retrieve) support via LiteLLM's agentic loop infrastructure.

How it works:

  1. apply_guardrail calls /v1/compress. If the compressed response contains hash=([a-f0-9]{24}) markers, it injects a headroom_retrieve tool into the outbound request and records the issued hashes in a cache keyed by litellm_call_id.
  2. async_should_run_agentic_loop detects when the LLM calls headroom_retrieve, across chat completions, the Responses API, and the Anthropic Messages API, via a shared cross-surface tool-call extractor.
  3. async_build_agentic_loop_plan validates the requested hash against the issued-hash set for that exact call_id (not just presence in message text, which is attacker-forgeable), calls GET {api_base}/v1/retrieve/{hash} on the Headroom sidecar, and returns an AgenticLoopPlan that replays the LLM with the retrieved content in the correct shape for whichever API surface produced the response. All transparent to the caller.

The Headroom sidecar must run on the same host as LiteLLM (Headroom's loopback-only restriction on /v1/retrieve in production). Configure it as a guardrail:

guardrails:
  - guardrail_name: headroom
    litellm_params:
      guardrail: headroom
      api_base: http://localhost:8787
      mode: pre_call
      default_on: true

API surface coverage (verified live against real OpenAI and Anthropic APIs, plus unit tests for the Responses API replay shape):

  • /v1/chat/completions: full support, verified live
  • /v1/messages (Anthropic Messages API): full support, verified live
  • /v1/responses: full support, unit tested

New shared helpers (litellm/litellm_core_utils/prompt_templates/factory.py):

  • get_tool_calls_from_response(response) — extracts normalized tool calls from a response regardless of API surface (chat completions, Responses API, or Anthropic Messages)
  • has_tool_with_name(tools, name) — checks whether a tools list includes a given tool, regardless of shape (OpenAI function tools or Anthropic's native tool shape)

These exist so future guardrails/integrations that need to detect tool calls across API surfaces don't have to re-derive this parsing per module (see the new CLAUDE.md convention note).

Security: the CCR retrieval loop validates that a requested hash was actually issued by this exact request's own compression call (scoped by litellm_call_id, matching the pattern used by compression_interception), not just that the hash string appears somewhere in the request's messages, which would be forgeable via prompt injection.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d643a0a571

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +91 to +95
function = tool.get("function")
if tool.get("type") == "function" and isinstance(function, dict):
if function.get("name") == HEADROOM_RETRIEVE_TOOL_NAME:
return True
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recognize Anthropic's injected retrieve tool

For /v1/messages requests, the Anthropic guardrail translation converts guardrailed_tools back from OpenAI format into Anthropic tools (litellm/llms/anthropic/chat/guardrail_translation/handler.py lines 167-175), which makes the injected retrieve tool look like {"type": "custom", "name": "headroom_retrieve", ...}. This predicate only accepts OpenAI type=function tools, so async_should_run_agentic_loop returns false even when Claude emits the headroom_retrieve tool_use; Headroom CCR never retrieves the compressed content on the Anthropic Messages path.

Useful? React with 👍 / 👎.

Comment on lines +438 to +444
tool_results.append(
{
"role": "tool",
"tool_call_id": tc.get("id"),
"content": content,
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Build Anthropic tool-result messages for Anthropic loops

When the Anthropic tool_use branch in _extract_headroom_tool_calls is used, this plan still appends OpenAI chat-completion tool messages (role: "tool" with tool_call_id). _execute_anthropic_agentic_plan sends patch.messages to anthropic_messages.acreate, whose existing interception implementations build an assistant tool_use content block followed by a user tool_result content block; with these OpenAI-shaped messages the follow-up is not a valid Anthropic Messages tool-result turn, so the retrieval loop will fail instead of producing the final answer.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR extends HeadroomGuardrail with a full CCR (Compress-Cache-Retrieve) agentic loop: when Headroom's /v1/compress returns hash markers, a headroom_retrieve tool is injected into the outbound request; if the LLM calls that tool, async_build_agentic_loop_plan validates the hash against a per-call-id allowlist, fetches the original content from the Headroom sidecar, and replays the LLM with the retrieved content in the correct shape for whichever API surface the response came from.

  • Two new shared helpers in factory.pyget_tool_calls_from_response and has_tool_with_name — extract and detect tool calls in a surface-agnostic way (chat completions, Responses API, Anthropic Messages), replacing the brittle per-surface attribute access that caused the previously reported Anthropic plain-dict and responses-detection bugs.
  • async_build_agentic_loop_plan correctly builds distinct follow-up message shapes for chat completions (assistant + tool-role messages), Responses API (function_call / function_call_output items), and Anthropic Messages (tool_use in an assistant content block + tool_result in a user content block).
  • Hash validation is scoped per litellm_call_id (not by scanning message text) to prevent prompt-injection attacks that plant a hash-shaped string in user input.

Confidence Score: 5/5

Safe to merge. The three API-surface bugs identified in the previous review are all fixed with correct implementations and regression tests using plain-dict responses rather than MagicMock objects.

The core CCR logic is well-structured: hash validation is scoped per litellm_call_id, each API surface gets a distinct and correctly shaped follow-up, and the shared helpers in factory.py centralise format detection so it cannot silently regress per-surface. Both findings are edge-case robustness concerns with no correctness bugs on the happy path.

The response-type detection helpers in headroom.py use single-attribute heuristics that could misfire if a future response type carries both output and content as lists.

Important Files Changed

Filename Overview
litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py Adds CCR agentic loop to HeadroomGuardrail: injects a retrieve tool on compression, validates hashes per-call-id, and builds correct follow-up messages for chat, Responses API, and Anthropic shapes. One edge-case gap in the UUID fallback propagation.
litellm/litellm_core_utils/prompt_templates/factory.py Adds shared cross-surface helpers: NormalizedToolCall TypedDict, get_tool_calls_from_response (chat/responses/Anthropic), has_tool_with_name (OpenAI and Anthropic native shapes). Clean, well-abstracted additions with correct fallthrough behavior.
tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py Comprehensive new test coverage for CCR: tool injection, agentic loop detection across all three API surfaces, hash validation/rejection, per-call-id security scoping, and correct follow-up message shapes for Anthropic and Responses API. Tests use plain-dict responses (not just MagicMock) to catch TypedDict runtime bugs.
tests/llm_translation/test_prompt_factory.py New unit tests for get_tool_calls_from_response and has_tool_with_name covering chat, responses API, Anthropic (MagicMock and plain-dict), empty, and non-list tool inputs.
CLAUDE.md Adds one coding convention bullet: prefer shared helpers in factory.py when branching on API surface, rather than duplicating format-detection logic per module.

Reviews (12): Last reviewed commit: "fix(guardrails/headroom): match Anthropi..." | Re-trigger Greptile

Comment thread litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py Outdated
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.44444% with 69 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...llm/litellm_core_utils/prompt_templates/factory.py 56.03% 51 Missing ⚠️
...xy/guardrails/guardrail_hooks/headroom/headroom.py 88.31% 18 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py Outdated
@veria-ai

veria-ai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

…via agentic loop

When Headroom's /v1/compress returns messages containing hash markers
(hash=[a-f0-9]{24}), inject a headroom_retrieve tool into the request.
When the LLM calls that tool, intercept via async_should_run_agentic_loop
and async_build_agentic_loop_plan, call GET /v1/retrieve/{hash} on the
Headroom sidecar, and replay the LLM with the original content as a tool
result -- all transparent to the caller.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py Outdated
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment thread litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py Outdated
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

…urrent request

Previously any LLM-supplied hash in a headroom_retrieve tool call was
forwarded to the Headroom retrieve API, letting a crafted tool call
fetch arbitrary cached content. Validate the hash against the set
produced by compressing the current request's messages before calling
retrieve.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

@codspeed-hq

codspeed-hq Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will improve performance by 14.45%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 29 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
test_completion_simple_message 4.6 ms 4 ms +14.45%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing litellm_headroom_ccr (57b37c9) with litellm_internal_staging (be4d0d8)

Open in CodSpeed

@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

…ses API replay shape

Hash validation now also checks an in-memory cache of hashes actually
returned by /v1/compress, not just whether the hash text appears
somewhere in the request's messages. The message-text check alone is
forgeable: an attacker can plant a hash-shaped string in their own
prompt and have it treated as valid.

Responses API follow-up now emits function_call/function_call_output
items keyed by call_id instead of chat-style assistant/tool messages,
since the Responses API does not accept the latter as input. Also
fixes call_id/id field priority when extracting tool calls from
Responses API output, since call_id (not id) is what must match
between the function_call and its output.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

UP037 flags quotes on annotations that are already lazily evaluated
via `from __future__ import annotations`.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

Functional under asyncio_mode=auto, but every other async test in the
file has the decorator for consistency.
@yucheng-berri

Copy link
Copy Markdown
Contributor

Code review result from my claude agent:

Requesting changes for three reasons:

  • Anthropic /v1/messages is claimed as supported, but the replay path is broken.

    • Detection can find Anthropic tool_use blocks.
    • But async_build_agentic_loop_plan falls into the OpenAI-chat branch.
    • _build_assistant_message_from_response only reads response.choices, so Anthropic responses become:
      • {"role": "assistant", "content": None, "tool_calls": []}
    • That drops the original tool_use.
    • The follow-up then emits:
      • {"role": "tool", ...}
    • Anthropic does not accept that shape.
    • Anthropic needs:
      • assistant content=[{"type":"tool_use",...}]
      • user content=[{"type":"tool_result",...}]
  • Hash scoping is not actually request-scoped.

    • _issued_hashes is an instance-global LRU shared across requests.
    • That only proves some request previously issued the hash.
    • The other check, hash in message_hashes, is caller-controlled because the user can put that hash in their own prompt.
    • Example:
      • request A issues hash X
      • request B includes hash X in its own message
      • both gates pass
      • B may retrieve A's content
    • This should be keyed by litellm_call_id with TTL, like compression_interception.
  • This duplicates the existing compression_interception compress/retrieve agentic loop.

    • That existing implementation already has:
      • the correct Anthropic message shape
      • the per-call cache pattern
    • Headroom reimplements the same pipeline and even copies the max_tokens / optional_params_without_max_tokens / full_model_name tail.
    • But it misses the correctness/security details above.

Required fixes:

  • Fix Anthropic build_plan and add a regression test, or remove the Anthropic support claim.
  • Make hash retrieval per-call scoped.
  • Factor/reuse the existing compression_interception helpers, or explicitly document why Headroom needs a separate implementation.

… replay shape

Two real gaps found in review:

1. The instance-wide issued-hash cache combined with a message-text
   check did not actually scope retrieval to the request that produced
   the hash. A hash issued for request A stays in the shared cache
   until TTL expiry, and the message-text check is satisfied by any
   request whose own messages happen to echo that hash string. Request
   B could plant A's hash in its own prompt and retrieve A's content.

   Fixed by keying the issued-hash cache by litellm_call_id, matching
   the pattern already used in compression_interception: a hash is
   only honored when it was issued under the exact call_id resolving
   for the current request.

2. The Anthropic Messages replay path fell through to the chat-style
   assistant/tool-message builder, which Anthropic does not accept.
   Anthropic requires the tool_use block echoed in an assistant message
   paired with a tool_result block in a user message, keyed by
   tool_use_id. Added a dedicated branch for this shape.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

Confirmed both correctness/security points against the existing compression_interception implementation, and fixed both in 16b0c4a:

  1. Hash scoping: the issued-hash cache is now keyed by litellm_call_id (resolved the same way compression_interception does: logging_obj.litellm_call_id first, falling back to kwargs), with TTL-based pruning. A hash issued for one call_id is no longer honored under a different call_id, closing the cross-request leak. Added a regression test that reproduces the exact scenario described (hash issued for call-A, requested under call-B, asserts the retrieve HTTP call is never made).

  2. Anthropic replay shape: added a dedicated branch that echoes the tool_use block in an assistant message paired with a tool_result block in a user message keyed by tool_use_id, instead of falling through to the chat-style builder. Added a regression test asserting this shape.

  3. On the duplication point: agreed this pipeline is structurally similar to compression_interception's compress/retrieve loop. Headroom intentionally stays a separate implementation because it's a guardrail (not a CustomLogger interceptor) calling an external Headroom service over HTTP rather than litellm's in-process compression, which mirrors how code_interpreter_interception, websearch_interception, and compression_interception already exist as separate self-contained modules rather than sharing a base beyond CustomLogger. Open to extracting the call_id-scoped-cache pattern into a shared helper if a third consumer shows up, but didn't think it was worth the abstraction for two.

@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

Add a bullet to the coding-conventions list: look for or add a shared
helper when logic branches on API surface (chat completions vs
Anthropic Messages vs Responses API), instead of duplicating
format-detection per module.
… shared cross-API tool util

Live e2e testing against the real Anthropic API surfaced two bugs the
mocked unit tests couldn't catch because they used MagicMock responses
instead of realistic response shapes:

1. has_headroom_retrieve_tool only recognized OpenAI-shaped function
   tools. By the time an Anthropic Messages response reaches the
   agentic-loop gate, the tool this guardrail injected has already been
   transformed into Anthropic's native shape (type: "custom", top-level
   "name"), so the gate never fired for real Anthropic requests.

2. AnthropicMessagesResponse is a TypedDict, so real responses are
   plain dicts at runtime, not objects with attribute access. The
   extractors and format detectors used bare getattr(), which silently
   returns nothing for dict responses instead of reading the actual
   key.

Extracted the cross-API-surface tool-call extraction and tool-presence
check into litellm/litellm_core_utils/prompt_templates/factory.py
(get_tool_calls_from_response, has_tool_with_name) so this format
fragmentation is handled in one place instead of being duplicated
per-guardrail, and reused the existing repair-aware
parse_tool_call_arguments from common_utils instead of a naive
json.loads. headroom.py now delegates to these shared helpers.

Confirmed live against the real Anthropic API: the retrieve loop now
fires and successfully retrieves the correct hash's content through
the full compress -> tool-call -> retrieve -> replay round-trip.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

Follow-up: while validating the /v1/messages fix live against the real Anthropic API (not just mocked unit tests), found and fixed two more real bugs in the same area:

  1. has_headroom_retrieve_tool only recognized OpenAI-shaped function tools. By the time a Messages API response reaches the agentic-loop gate, the tool this guardrail injected has already been transformed into Anthropic's native shape (type: "custom", top-level "name"), so the gate never actually fired for real Anthropic traffic, even though the tool itself was correctly offered to the model.

  2. AnthropicMessagesResponse is a TypedDict, so real responses are plain dicts at runtime, not objects with attribute access. The extractors used bare getattr(), which silently returns nothing for dict responses instead of reading the actual key. This affected both the detection gate and the replay-branch selector, meaning even the tool_use/tool_result replay shape fix from the previous commit was unreachable for real responses.

Both are confirmed fixed by re-running the same live test against the real API: the retrieve loop now fires and successfully retrieves content through the full round-trip. Extracted the cross-surface tool-call detection into litellm_core_utils/prompt_templates/factory.py (get_tool_calls_from_response, has_tool_with_name) so this doesn't get re-broken per-guardrail, and added regression tests using plain dict responses (not MagicMock) so the shape bug specifically can't regress silently again.

Use lowercase list/dict generics in the new factory.py tool-call
helpers instead of typing.List/Dict, drop the now-unused Tuple import
in headroom.py, and reorder the new factory import ahead of the
llms.custom_httpx import to satisfy import sorting.
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

if tool.get("type") == "function" and isinstance(function, dict):
if function.get("name") == tool_name:
return True
if tool.get("type") == "custom" and tool.get("name") == tool_name:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocker:

Anthropic official tool-use docs define client tools by name + input_schema; type: "custom" is not required, so has_tool_with_name should treat no-type Anthropic tools as valid matches too.

Anthropic's documented client tool format is just name + input_schema;
type: "custom" is only one possible value, not a requirement. Match
any non-OpenAI-shaped tool on its top-level name instead of requiring
type == "custom".
@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

Fixed in 5bc3838. has_tool_with_name now matches any non-OpenAI-shaped tool on its top-level name regardless of the type field (or its absence), instead of requiring type == "custom". Added a regression test for the no-type-field case.

@krrish-berri-2

Copy link
Copy Markdown
Contributor Author

@greptileai review

@krrish-berri-2
krrish-berri-2 enabled auto-merge (squash) July 1, 2026 02:11
@krrish-berri-2
krrish-berri-2 merged commit 50b936c into litellm_internal_staging Jul 1, 2026
122 of 123 checks passed
@krrish-berri-2
krrish-berri-2 deleted the litellm_headroom_ccr branch July 1, 2026 02:19
duanhongyi pushed a commit to duanhongyi/litellm that referenced this pull request Jul 2, 2026
…tic loop (BerriAI#31681)

* feat(guardrails/headroom): add CCR (compress-cache-retrieve) support via agentic loop

When Headroom's /v1/compress returns messages containing hash markers
(hash=[a-f0-9]{24}), inject a headroom_retrieve tool into the request.
When the LLM calls that tool, intercept via async_should_run_agentic_loop
and async_build_agentic_loop_plan, call GET /v1/retrieve/{hash} on the
Headroom sidecar, and replay the LLM with the original content as a tool
result -- all transparent to the caller.

* style: run ruff format on headroom guardrail and tests

* fix(guardrails/headroom): detect headroom_retrieve calls in both OpenAI and Anthropic response formats

* test(guardrails/headroom): add test for Anthropic content block format detection in CCR loop

* ci: trigger CI checks

* fix(guardrails/headroom): replace List/Dict with list/dict to fix UP006 ruff violations

* fix(guardrails/headroom): replace except Exception with except ValueError to fix BLE001

* fix(guardrails/headroom): add Responses API output format detection for CCR tool calls

* refactor(guardrails/headroom): extract format-specific helpers to fix C901 complexity

* fix(guardrails/headroom): scope CCR retrieval to hashes produced by current request

Previously any LLM-supplied hash in a headroom_retrieve tool call was
forwarded to the Headroom retrieve API, letting a crafted tool call
fetch arbitrary cached content. Validate the hash against the set
produced by compressing the current request's messages before calling
retrieve.

* fix(guardrails/headroom): track issued hashes server-side, fix Responses API replay shape

Hash validation now also checks an in-memory cache of hashes actually
returned by /v1/compress, not just whether the hash text appears
somewhere in the request's messages. The message-text check alone is
forgeable: an attacker can plant a hash-shaped string in their own
prompt and have it treated as valid.

Responses API follow-up now emits function_call/function_call_output
items keyed by call_id instead of chat-style assistant/tool messages,
since the Responses API does not accept the latter as input. Also
fixes call_id/id field priority when extracting tool calls from
Responses API output, since call_id (not id) is what must match
between the function_call and its output.

* fix(guardrails/headroom): drop redundant quoted type annotations

UP037 flags quotes on annotations that are already lazily evaluated
via `from __future__ import annotations`.

* test(guardrails/headroom): add missing pytest.mark.asyncio decorators

Functional under asyncio_mode=auto, but every other async test in the
file has the decorator for consistency.

* fix(guardrails/headroom): scope CCR hashes per call_id, fix Anthropic replay shape

Two real gaps found in review:

1. The instance-wide issued-hash cache combined with a message-text
   check did not actually scope retrieval to the request that produced
   the hash. A hash issued for request A stays in the shared cache
   until TTL expiry, and the message-text check is satisfied by any
   request whose own messages happen to echo that hash string. Request
   B could plant A's hash in its own prompt and retrieve A's content.

   Fixed by keying the issued-hash cache by litellm_call_id, matching
   the pattern already used in compression_interception: a hash is
   only honored when it was issued under the exact call_id resolving
   for the current request.

2. The Anthropic Messages replay path fell through to the chat-style
   assistant/tool-message builder, which Anthropic does not accept.
   Anthropic requires the tool_use block echoed in an assistant message
   paired with a tool_result block in a user message, keyed by
   tool_use_id. Added a dedicated branch for this shape.

* docs: note proactive API-fragmentation helper convention

Add a bullet to the coding-conventions list: look for or add a shared
helper when logic branches on API surface (chat completions vs
Anthropic Messages vs Responses API), instead of duplicating
format-detection per module.

* fix(guardrails/headroom): fix Anthropic tool-shape detection, extract shared cross-API tool util

Live e2e testing against the real Anthropic API surfaced two bugs the
mocked unit tests couldn't catch because they used MagicMock responses
instead of realistic response shapes:

1. has_headroom_retrieve_tool only recognized OpenAI-shaped function
   tools. By the time an Anthropic Messages response reaches the
   agentic-loop gate, the tool this guardrail injected has already been
   transformed into Anthropic's native shape (type: "custom", top-level
   "name"), so the gate never fired for real Anthropic requests.

2. AnthropicMessagesResponse is a TypedDict, so real responses are
   plain dicts at runtime, not objects with attribute access. The
   extractors and format detectors used bare getattr(), which silently
   returns nothing for dict responses instead of reading the actual
   key.

Extracted the cross-API-surface tool-call extraction and tool-presence
check into litellm/litellm_core_utils/prompt_templates/factory.py
(get_tool_calls_from_response, has_tool_with_name) so this format
fragmentation is handled in one place instead of being duplicated
per-guardrail, and reused the existing repair-aware
parse_tool_call_arguments from common_utils instead of a naive
json.loads. headroom.py now delegates to these shared helpers.

Confirmed live against the real Anthropic API: the retrieve loop now
fires and successfully retrieves the correct hash's content through
the full compress -> tool-call -> retrieve -> replay round-trip.

* fix(guardrails/headroom): fix ruff-strict UP006/I001 budget violations

Use lowercase list/dict generics in the new factory.py tool-call
helpers instead of typing.List/Dict, drop the now-unused Tuple import
in headroom.py, and reorder the new factory import ahead of the
llms.custom_httpx import to satisfy import sorting.

* fix(guardrails/headroom): match Anthropic tools without a type field

Anthropic's documented client tool format is just name + input_schema;
type: "custom" is only one possible value, not a requirement. Match
any non-OpenAI-shaped tool on its top-level name instead of requiring
type == "custom".
yvgude added a commit to yvgude/lean-ctx that referenced this pull request Jul 5, 2026
… /v1/retrieve/{hash} (#702)

Lossy /v1/compress rewrites now advertise their retrieval hash in the
guardrail's regex-locked hash=<24hex> form (first 24 hex of the blake3
content address; the first 16 are the existing tee name). The new
/v1/retrieve/{hash} endpoint resolves it back to the verbatim original
as {"original_content": ...} — the exact contract LiteLLM's headroom
guardrail CCR loop (BerriAI/litellm#31681) speaks. Marker shape is
pinned by contract tests so drift fails CI; hashes are pure functions
of content, so stubs stay byte-stable (#498). Local handles unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
yvgude added a commit to yvgude/lean-ctx that referenced this pull request Jul 29, 2026
… /v1/retrieve/{hash} (#702)

Lossy /v1/compress rewrites now advertise their retrieval hash in the
guardrail's regex-locked hash=<24hex> form (first 24 hex of the blake3
content address; the first 16 are the existing tee name). The new
/v1/retrieve/{hash} endpoint resolves it back to the verbatim original
as {"original_content": ...} — the exact contract LiteLLM's headroom
guardrail CCR loop (BerriAI/litellm#31681) speaks. Marker shape is
pinned by contract tests so drift fails CI; hashes are pure functions
of content, so stubs stay byte-stable (#498). Local handles unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants