mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* fix(caching): keep tool calls and tool results in semantic cache prompts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): keep semantic tool prompt helpers within lint budgets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): keep structured function_call_output text in semantic prompts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): split Responses text-field collection to stay within complexity budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): tag each tool result with the position of the call it answers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): encode tool result position and output together so tool text cannot forge result tags Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(caching): expect encoded tool result record in qdrant semantic prompt parity case Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(caching): cover tool result arrangements, SDK clients, concurrency and qdrant outage for semantic cache Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(caching): embed every semantic cache prompt field except volatile ones Replace the per-shape allowlist in the Python and Rust semantic cache prompt walkers with one include-by-default walker. Plain text keeps its old concatenation; any other block or message is embedded as compact JSON with call ids mapped to ordinals, cache_control dropped, and signatures, encrypted content and base64 data replaced with a short sha256 digest. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(code-quality): allow the bounded semantic cache prompt walkers in the recursion check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(rust): expect structured JSON for unknown fields in redis and valkey semantic prompts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Revert "test(rust): expect structured JSON for unknown fields in redis and valkey semantic prompts" This reverts commit86c82b949b. * Revert "test(code-quality): allow the bounded semantic cache prompt walkers in the recursion check" This reverts commit39efb5d9da. * Revert "feat(caching): embed every semantic cache prompt field except volatile ones" This reverts commit5aed3ab3de. * refactor(caching): rename get_str_from_messages_with_tools to get_semantic_cache_prompt_from_messages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): split semantic cache prompt extraction by API format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): drop TypeIs guard and register Responses prompt walker with the recursion check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): pick the Responses text field without a Final inside a loop Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): walk semantic cache prompts as plain dicts, dumping pydantic items once up front Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): write the semantic cache prompt builders as plain loops Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): skip the cache past max_messages and keep tool_result text in semantic prompts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(caching): drop formatting-only churn from the redis semantic cache tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): drop the caching group wiring that main already carries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): recurse into tool_result content in the semantic cache prompt helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): check the max_messages cap on the shared exact-cache proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): read list-form function_call_output text in semantic cache prompts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): extract nested Responses input lookup to keep walker under complexity limit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Revert "refactor(caching): extract nested Responses input lookup to keep walker under complexity limit" This reverts commit0665296bf1. * style(caching): suppress C901 on the Responses input walker instead of splitting it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: kerry <kerry@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
144 lines
5.9 KiB
Python
144 lines
5.9 KiB
Python
import json
|
|
import os
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from integration._support.anthropic_sse import message_json
|
|
from integration._support.client import Gateway, eventually, object_value, string_value
|
|
from integration._support.openai_wire import chat_reply, responses_reply
|
|
from integration._support.provider import SharedProvider
|
|
from integration._support.wire import Reply
|
|
from pydantic import JsonValue
|
|
from redis import Redis
|
|
|
|
_Turns = Callable[[str], tuple[list[JsonValue], list[JsonValue]]]
|
|
|
|
|
|
def _claude_code_turns(task: str) -> tuple[list[JsonValue], list[JsonValue]]:
|
|
"""Turns 1 and 3 of a Claude Code session on /v1/messages: 1 and 5 messages"""
|
|
first: Final[list[JsonValue]] = [{"role": "user", "content": task}]
|
|
third: Final[list[JsonValue]] = [
|
|
*first,
|
|
{
|
|
"role": "assistant",
|
|
"content": [{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "ls"}}],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "calc.py test_calc.py"}],
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [{"type": "tool_use", "id": "toolu_2", "name": "Read", "input": {"file_path": "calc.py"}}],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "tool_result", "tool_use_id": "toolu_2", "content": "def add(a, b): return a - b"}],
|
|
},
|
|
]
|
|
return first, third
|
|
|
|
|
|
def _agent_turns(task: str) -> tuple[list[JsonValue], list[JsonValue]]:
|
|
"""Turns 1 and 3 of an OpenAI tool loop on /v1/chat/completions: 2 and 6 messages"""
|
|
|
|
def call(call_id: str, path: str) -> list[JsonValue]:
|
|
function: Final[JsonValue] = {"name": "write_file", "arguments": json.dumps({"path": path})}
|
|
return [
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [{"id": call_id, "type": "function", "function": function}],
|
|
},
|
|
{"role": "tool", "tool_call_id": call_id, "content": f"wrote {path}"},
|
|
]
|
|
|
|
first: Final[list[JsonValue]] = [
|
|
{"role": "system", "content": "You are a coding agent"},
|
|
{"role": "user", "content": task},
|
|
]
|
|
return first, [*first, *call("call_1", "a.yaml"), *call("call_2", "b.yaml")]
|
|
|
|
|
|
def _responses_turns(task: str) -> tuple[list[JsonValue], list[JsonValue]]:
|
|
"""Turns 1 and 3 of an agent on /v1/responses: 1 and 5 input items"""
|
|
|
|
def call(call_id: str, path: str) -> list[JsonValue]:
|
|
return [
|
|
{
|
|
"type": "function_call",
|
|
"call_id": call_id,
|
|
"name": "write_file",
|
|
"arguments": json.dumps({"path": path}),
|
|
},
|
|
{"type": "function_call_output", "call_id": call_id, "output": f"wrote {path}"},
|
|
]
|
|
|
|
first: Final[list[JsonValue]] = [{"role": "user", "content": task}]
|
|
return first, [*first, *call("call_1", "a.yaml"), *call("call_2", "b.yaml")]
|
|
|
|
|
|
def _body(path: str, model: str, conversation: list[JsonValue]) -> dict[str, JsonValue]:
|
|
if path == "/v1/responses":
|
|
return {"model": model, "input": conversation}
|
|
return {"model": model, "max_tokens": 16, "messages": conversation}
|
|
|
|
|
|
def _reply(path: str, text: str) -> Reply:
|
|
identity: Final = f"id_{uuid.uuid4().hex}"
|
|
if path == "/v1/messages":
|
|
return Reply(body=message_json(identity, "claude-sonnet-5-5", text))
|
|
if path == "/v1/responses":
|
|
return responses_reply(identity, "gpt-5.6-sol", text, stream=False)
|
|
return chat_reply(identity, "gpt-5.4", text, stream=False)
|
|
|
|
|
|
def _answer(path: str, payload: dict[str, JsonValue]) -> str:
|
|
if path == "/v1/messages":
|
|
return string_value(_first(payload["content"])["text"])
|
|
if path == "/v1/responses":
|
|
return string_value(_first(_first(payload["output"])["content"])["text"])
|
|
return string_value(object_value(_first(payload["choices"])["message"])["content"])
|
|
|
|
|
|
def _first(value: JsonValue) -> dict[str, JsonValue]:
|
|
assert isinstance(value, list), value
|
|
return object_value(value[0])
|
|
|
|
|
|
def _cached_responses(redis: Redis) -> frozenset[bytes]:
|
|
digests: Final = tuple(key for key in redis.scan_iter() if len(key) == 64)
|
|
return frozenset(key for key in digests if b'"response"' in (redis.get(key) or b""))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "model", "turns"),
|
|
[
|
|
pytest.param("/v1/messages", "anthropic/claude-sonnet-5-5", _claude_code_turns, id="messages"),
|
|
pytest.param("/v1/chat/completions", "openai/gpt-5.4", _agent_turns, id="chat-completions"),
|
|
pytest.param("/v1/responses", "openai/responses/gpt-5.6-sol", _responses_turns, id="responses"),
|
|
],
|
|
)
|
|
def test_cache_serves_a_turn_under_max_messages_and_skips_one_past_it(
|
|
gateway: Gateway, provider: SharedProvider, path: str, model: str, turns: _Turns
|
|
) -> None:
|
|
under_cap, past_cap = turns(f"update the config {uuid.uuid4().hex}")
|
|
provider.expect(_reply(path, "first answer"), _reply(path, "second answer"), _reply(path, "third answer"))
|
|
|
|
with Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) as redis:
|
|
cached_before: Final = _cached_responses(redis)
|
|
gateway.post(path, _body(path, model, under_cap))
|
|
eventually(lambda: _cached_responses(redis) - cached_before, lambda written: len(written) == 1)
|
|
repeated: Final = gateway.post(path, _body(path, model, under_cap))
|
|
past_cap_twice: Final = (
|
|
gateway.post(path, _body(path, model, past_cap)),
|
|
gateway.post(path, _body(path, model, past_cap)),
|
|
)
|
|
|
|
assert _answer(path, repeated) == "first answer", "a repeated turn under max_messages was not served from the cache"
|
|
assert tuple(_answer(path, answer) for answer in past_cap_twice) == ("second answer", "third answer"), (
|
|
"a turn past max_messages was served from the cache"
|
|
)
|
|
assert len(provider.received()) == 3
|