Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Restore prompt caching for FAL router models (#286)
Browse files* Restore prompt caching for FAL router models
Co-authored-by: OpenAI Codex <codex@openai.com>
* Tune prompt caching hints for GPT-5.5
Co-authored-by: OpenAI Codex <codex@openai.com>
* Address backlog prompt caching import review
Co-authored-by: OpenAI Codex <codex@openai.com>
---------
Co-authored-by: OpenAI Codex <codex@openai.com>
- agent/context_manager/manager.py +8 -0
- agent/core/agent_loop.py +19 -6
- agent/core/prompt_caching.py +186 -0
- agent/tools/research_tool.py +13 -4
- scripts/prioritize_backlog.py +8 -1
- tests/unit/test_prompt_caching.py +220 -0
agent/context_manager/manager.py
CHANGED
|
@@ -13,6 +13,8 @@ import yaml
|
|
| 13 |
from jinja2 import Template
|
| 14 |
from litellm import Message, acompletion
|
| 15 |
|
|
|
|
|
|
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
_HF_WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
|
|
@@ -145,6 +147,12 @@ async def summarize_messages(
|
|
| 145 |
reasoning_effort="high",
|
| 146 |
bill_to_user=getattr(session, "premium_user_billed", False),
|
| 147 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
_t0 = time.monotonic()
|
| 149 |
response = await acompletion(
|
| 150 |
messages=prompt_messages,
|
|
|
|
| 13 |
from jinja2 import Template
|
| 14 |
from litellm import Message, acompletion
|
| 15 |
|
| 16 |
+
from agent.core.prompt_caching import with_prompt_cache_params, with_prompt_caching
|
| 17 |
+
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
_HF_WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
|
|
|
|
| 147 |
reasoning_effort="high",
|
| 148 |
bill_to_user=getattr(session, "premium_user_billed", False),
|
| 149 |
)
|
| 150 |
+
llm_params = with_prompt_cache_params(
|
| 151 |
+
llm_params, session_id=getattr(session, "session_id", None)
|
| 152 |
+
)
|
| 153 |
+
prompt_messages, tool_specs = with_prompt_caching(
|
| 154 |
+
prompt_messages, tool_specs, llm_params
|
| 155 |
+
)
|
| 156 |
_t0 = time.monotonic()
|
| 157 |
response = await acompletion(
|
| 158 |
messages=prompt_messages,
|
agent/core/agent_loop.py
CHANGED
|
@@ -27,6 +27,7 @@ from agent.messaging.gateway import NotificationGateway
|
|
| 27 |
from agent.core import telemetry
|
| 28 |
from agent.core.doom_loop import check_for_doom_loop
|
| 29 |
from agent.core.llm_params import _resolve_llm_params
|
|
|
|
| 30 |
from agent.core.session import DEFAULT_SESSION_LOG_DIR, Event, OpType, Session
|
| 31 |
from agent.core.tools import ToolRouter
|
| 32 |
from agent.tools.jobs_tool import CPU_FLAVORS
|
|
@@ -819,14 +820,20 @@ async def _call_llm_streaming(
|
|
| 819 |
t_start = time.monotonic()
|
| 820 |
for _llm_attempt in range(_MAX_LLM_RETRIES):
|
| 821 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
response = await acompletion(
|
| 823 |
-
messages=
|
| 824 |
-
tools=
|
| 825 |
tool_choice="auto",
|
| 826 |
stream=True,
|
| 827 |
stream_options={"include_usage": True},
|
| 828 |
timeout=600,
|
| 829 |
-
**
|
| 830 |
)
|
| 831 |
break
|
| 832 |
except ContextWindowExceededError:
|
|
@@ -958,13 +965,19 @@ async def _call_llm_non_streaming(
|
|
| 958 |
t_start = time.monotonic()
|
| 959 |
for _llm_attempt in range(_MAX_LLM_RETRIES):
|
| 960 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
response = await acompletion(
|
| 962 |
-
messages=
|
| 963 |
-
tools=
|
| 964 |
tool_choice="auto",
|
| 965 |
stream=False,
|
| 966 |
timeout=600,
|
| 967 |
-
**
|
| 968 |
)
|
| 969 |
break
|
| 970 |
except ContextWindowExceededError:
|
|
|
|
| 27 |
from agent.core import telemetry
|
| 28 |
from agent.core.doom_loop import check_for_doom_loop
|
| 29 |
from agent.core.llm_params import _resolve_llm_params
|
| 30 |
+
from agent.core.prompt_caching import with_prompt_cache_params, with_prompt_caching
|
| 31 |
from agent.core.session import DEFAULT_SESSION_LOG_DIR, Event, OpType, Session
|
| 32 |
from agent.core.tools import ToolRouter
|
| 33 |
from agent.tools.jobs_tool import CPU_FLAVORS
|
|
|
|
| 820 |
t_start = time.monotonic()
|
| 821 |
for _llm_attempt in range(_MAX_LLM_RETRIES):
|
| 822 |
try:
|
| 823 |
+
request_llm_params = with_prompt_cache_params(
|
| 824 |
+
llm_params, session_id=getattr(session, "session_id", None)
|
| 825 |
+
)
|
| 826 |
+
cached_messages, cached_tools = with_prompt_caching(
|
| 827 |
+
messages, tools, request_llm_params
|
| 828 |
+
)
|
| 829 |
response = await acompletion(
|
| 830 |
+
messages=cached_messages,
|
| 831 |
+
tools=cached_tools,
|
| 832 |
tool_choice="auto",
|
| 833 |
stream=True,
|
| 834 |
stream_options={"include_usage": True},
|
| 835 |
timeout=600,
|
| 836 |
+
**request_llm_params,
|
| 837 |
)
|
| 838 |
break
|
| 839 |
except ContextWindowExceededError:
|
|
|
|
| 965 |
t_start = time.monotonic()
|
| 966 |
for _llm_attempt in range(_MAX_LLM_RETRIES):
|
| 967 |
try:
|
| 968 |
+
request_llm_params = with_prompt_cache_params(
|
| 969 |
+
llm_params, session_id=getattr(session, "session_id", None)
|
| 970 |
+
)
|
| 971 |
+
cached_messages, cached_tools = with_prompt_caching(
|
| 972 |
+
messages, tools, request_llm_params
|
| 973 |
+
)
|
| 974 |
response = await acompletion(
|
| 975 |
+
messages=cached_messages,
|
| 976 |
+
tools=cached_tools,
|
| 977 |
tool_choice="auto",
|
| 978 |
stream=False,
|
| 979 |
timeout=600,
|
| 980 |
+
**request_llm_params,
|
| 981 |
)
|
| 982 |
break
|
| 983 |
except ContextWindowExceededError:
|
agent/core/prompt_caching.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt-cache helpers for HF Router FAL requests.
|
| 2 |
+
|
| 3 |
+
The HF Router/OpenRouter path uses provider-native prompt caching. Anthropic
|
| 4 |
+
models need explicit JSON ``cache_control`` content blocks; OpenAI models cache
|
| 5 |
+
eligible prefixes automatically and accept routing/retention hints in the body.
|
| 6 |
+
Headers like ``X-OpenRouter-Cache`` control response caching, not prompt
|
| 7 |
+
caching through this route.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from agent.core.model_ids import HF_ROUTER_BASE_URL
|
| 13 |
+
|
| 14 |
+
_CACHE_CONTROL = {"type": "ephemeral"}
|
| 15 |
+
_CACHEABLE_ROLES = {"system", "user"}
|
| 16 |
+
_OPENROUTER_SESSION_ID_MAX_LENGTH = 256
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _is_fal_router_request(llm_params: dict[str, Any]) -> bool:
|
| 20 |
+
api_base = str(llm_params.get("api_base") or "").rstrip("/")
|
| 21 |
+
return api_base == HF_ROUTER_BASE_URL and ":fal" in _router_model(llm_params)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _router_model(llm_params: dict[str, Any]) -> str:
|
| 25 |
+
model = str(llm_params.get("model") or "")
|
| 26 |
+
return model.removeprefix("openai/")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _uses_explicit_cache_control(llm_params: dict[str, Any]) -> bool:
|
| 30 |
+
if not _is_fal_router_request(llm_params):
|
| 31 |
+
return False
|
| 32 |
+
return _router_model(llm_params).startswith("anthropic/")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _is_openai_gpt55(llm_params: dict[str, Any]) -> bool:
|
| 36 |
+
if not _is_fal_router_request(llm_params):
|
| 37 |
+
return False
|
| 38 |
+
return _router_model(llm_params).startswith("openai/gpt-5.5")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _merge_extra_body(
|
| 42 |
+
llm_params: dict[str, Any], updates: dict[str, Any]
|
| 43 |
+
) -> dict[str, Any]:
|
| 44 |
+
if not updates:
|
| 45 |
+
return llm_params
|
| 46 |
+
|
| 47 |
+
cached_params = dict(llm_params)
|
| 48 |
+
extra_body = dict(cached_params.get("extra_body") or {})
|
| 49 |
+
extra_body.update(updates)
|
| 50 |
+
cached_params["extra_body"] = extra_body
|
| 51 |
+
return cached_params
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def with_prompt_cache_params(
|
| 55 |
+
llm_params: dict[str, Any],
|
| 56 |
+
*,
|
| 57 |
+
session_id: str | None = None,
|
| 58 |
+
) -> dict[str, Any]:
|
| 59 |
+
"""Return LiteLLM params with provider-native prompt-cache body hints."""
|
| 60 |
+
if not _is_fal_router_request(llm_params):
|
| 61 |
+
return llm_params
|
| 62 |
+
|
| 63 |
+
updates: dict[str, Any] = {}
|
| 64 |
+
if session_id:
|
| 65 |
+
stable_session_id = session_id[:_OPENROUTER_SESSION_ID_MAX_LENGTH]
|
| 66 |
+
updates["session_id"] = stable_session_id
|
| 67 |
+
if _is_openai_gpt55(llm_params):
|
| 68 |
+
updates["prompt_cache_key"] = stable_session_id
|
| 69 |
+
|
| 70 |
+
if _is_openai_gpt55(llm_params):
|
| 71 |
+
updates["prompt_cache_retention"] = "24h"
|
| 72 |
+
|
| 73 |
+
return _merge_extra_body(llm_params, updates)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _message_role(message: Any) -> str | None:
|
| 77 |
+
if isinstance(message, dict):
|
| 78 |
+
role = message.get("role")
|
| 79 |
+
else:
|
| 80 |
+
role = getattr(message, "role", None)
|
| 81 |
+
return role if isinstance(role, str) else None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _message_content(message: Any) -> Any:
|
| 85 |
+
if isinstance(message, dict):
|
| 86 |
+
return message.get("content")
|
| 87 |
+
return getattr(message, "content", None)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _message_to_dict(message: Any) -> dict[str, Any]:
|
| 91 |
+
if isinstance(message, dict):
|
| 92 |
+
return dict(message)
|
| 93 |
+
if hasattr(message, "model_dump"):
|
| 94 |
+
return message.model_dump(exclude_none=True)
|
| 95 |
+
raise TypeError(f"Unsupported message type for prompt caching: {type(message)!r}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _has_cacheable_text(content: Any) -> bool:
|
| 99 |
+
if isinstance(content, str):
|
| 100 |
+
return bool(content)
|
| 101 |
+
if not isinstance(content, list):
|
| 102 |
+
return False
|
| 103 |
+
return any(
|
| 104 |
+
isinstance(block, dict)
|
| 105 |
+
and block.get("type") == "text"
|
| 106 |
+
and isinstance(block.get("text"), str)
|
| 107 |
+
and bool(block.get("text"))
|
| 108 |
+
for block in content
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _cache_target_index(messages: list[Any]) -> int | None:
|
| 113 |
+
if len(messages) < 2:
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
for idx in range(len(messages) - 2, -1, -1):
|
| 117 |
+
message = messages[idx]
|
| 118 |
+
if _message_role(message) not in _CACHEABLE_ROLES:
|
| 119 |
+
continue
|
| 120 |
+
if _has_cacheable_text(_message_content(message)):
|
| 121 |
+
return idx
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _content_with_cache_control(content: Any) -> list[dict[str, Any]]:
|
| 126 |
+
if isinstance(content, str):
|
| 127 |
+
return [
|
| 128 |
+
{"type": "text", "text": content, "cache_control": dict(_CACHE_CONTROL)}
|
| 129 |
+
]
|
| 130 |
+
|
| 131 |
+
blocks = [dict(block) if isinstance(block, dict) else block for block in content]
|
| 132 |
+
for idx in range(len(blocks) - 1, -1, -1):
|
| 133 |
+
block = blocks[idx]
|
| 134 |
+
if (
|
| 135 |
+
isinstance(block, dict)
|
| 136 |
+
and block.get("type") == "text"
|
| 137 |
+
and isinstance(block.get("text"), str)
|
| 138 |
+
and bool(block.get("text"))
|
| 139 |
+
):
|
| 140 |
+
cached = dict(block)
|
| 141 |
+
cached["cache_control"] = dict(_CACHE_CONTROL)
|
| 142 |
+
blocks[idx] = cached
|
| 143 |
+
break
|
| 144 |
+
return blocks
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _tools_with_cache_control(tools: list[dict] | None) -> list[dict] | None:
|
| 148 |
+
if not tools:
|
| 149 |
+
return tools
|
| 150 |
+
|
| 151 |
+
cached_tools = list(tools)
|
| 152 |
+
last_tool = dict(cached_tools[-1])
|
| 153 |
+
last_tool["cache_control"] = dict(_CACHE_CONTROL)
|
| 154 |
+
cached_tools[-1] = last_tool
|
| 155 |
+
return cached_tools
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def with_prompt_caching(
|
| 159 |
+
messages: list[Any],
|
| 160 |
+
tools: list[dict] | None,
|
| 161 |
+
llm_params: dict[str, Any],
|
| 162 |
+
) -> tuple[list[Any], list[dict] | None]:
|
| 163 |
+
"""Return outgoing messages with explicit cache breakpoints when needed.
|
| 164 |
+
|
| 165 |
+
The newest message is treated as dynamic. For Anthropic FAL models, the
|
| 166 |
+
cache breakpoint is placed on the closest earlier system/user text block so
|
| 167 |
+
provider-side caching covers the stable prefix without changing persisted
|
| 168 |
+
conversation history. The final tool spec is also marked so stable tool
|
| 169 |
+
definitions are cached.
|
| 170 |
+
"""
|
| 171 |
+
if not _uses_explicit_cache_control(llm_params):
|
| 172 |
+
return messages, tools
|
| 173 |
+
|
| 174 |
+
cached_tools = _tools_with_cache_control(tools)
|
| 175 |
+
idx = _cache_target_index(messages)
|
| 176 |
+
if idx is None:
|
| 177 |
+
return messages, cached_tools
|
| 178 |
+
|
| 179 |
+
cached_message = _message_to_dict(messages[idx])
|
| 180 |
+
cached_message["content"] = _content_with_cache_control(
|
| 181 |
+
cached_message.get("content")
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
cached_messages = list(messages)
|
| 185 |
+
cached_messages[idx] = cached_message
|
| 186 |
+
return cached_messages, cached_tools
|
agent/tools/research_tool.py
CHANGED
|
@@ -18,6 +18,7 @@ from agent.core import telemetry
|
|
| 18 |
from agent.core.doom_loop import check_for_doom_loop
|
| 19 |
from agent.core.llm_params import _resolve_llm_params
|
| 20 |
from agent.core.model_ids import strip_huggingface_model_prefix
|
|
|
|
| 21 |
from agent.core.session import Event
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
@@ -259,6 +260,9 @@ async def research_handler(
|
|
| 259 |
reasoning_effort=_capped,
|
| 260 |
bill_to_user=getattr(session, "premium_user_billed", False),
|
| 261 |
)
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
# Get read-only tool specs from the session's tool router
|
| 264 |
tool_specs = [
|
|
@@ -336,8 +340,9 @@ async def research_handler(
|
|
| 336 |
)
|
| 337 |
try:
|
| 338 |
_t0 = time.monotonic()
|
|
|
|
| 339 |
response = await acompletion(
|
| 340 |
-
messages=
|
| 341 |
tools=None, # no tools — force text response
|
| 342 |
stream=False,
|
| 343 |
timeout=120,
|
|
@@ -383,9 +388,12 @@ async def research_handler(
|
|
| 383 |
|
| 384 |
try:
|
| 385 |
_t0 = time.monotonic()
|
|
|
|
|
|
|
|
|
|
| 386 |
response = await acompletion(
|
| 387 |
-
messages=
|
| 388 |
-
tools=
|
| 389 |
tool_choice="auto",
|
| 390 |
stream=False,
|
| 391 |
timeout=120,
|
|
@@ -499,8 +507,9 @@ async def research_handler(
|
|
| 499 |
)
|
| 500 |
try:
|
| 501 |
_t0 = time.monotonic()
|
|
|
|
| 502 |
response = await acompletion(
|
| 503 |
-
messages=
|
| 504 |
tools=None,
|
| 505 |
stream=False,
|
| 506 |
timeout=120,
|
|
|
|
| 18 |
from agent.core.doom_loop import check_for_doom_loop
|
| 19 |
from agent.core.llm_params import _resolve_llm_params
|
| 20 |
from agent.core.model_ids import strip_huggingface_model_prefix
|
| 21 |
+
from agent.core.prompt_caching import with_prompt_cache_params, with_prompt_caching
|
| 22 |
from agent.core.session import Event
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
|
|
|
| 260 |
reasoning_effort=_capped,
|
| 261 |
bill_to_user=getattr(session, "premium_user_billed", False),
|
| 262 |
)
|
| 263 |
+
llm_params = with_prompt_cache_params(
|
| 264 |
+
llm_params, session_id=getattr(session, "session_id", None)
|
| 265 |
+
)
|
| 266 |
|
| 267 |
# Get read-only tool specs from the session's tool router
|
| 268 |
tool_specs = [
|
|
|
|
| 340 |
)
|
| 341 |
try:
|
| 342 |
_t0 = time.monotonic()
|
| 343 |
+
cached_messages, _ = with_prompt_caching(messages, None, llm_params)
|
| 344 |
response = await acompletion(
|
| 345 |
+
messages=cached_messages,
|
| 346 |
tools=None, # no tools — force text response
|
| 347 |
stream=False,
|
| 348 |
timeout=120,
|
|
|
|
| 388 |
|
| 389 |
try:
|
| 390 |
_t0 = time.monotonic()
|
| 391 |
+
cached_messages, cached_tools = with_prompt_caching(
|
| 392 |
+
messages, tool_specs if tool_specs else None, llm_params
|
| 393 |
+
)
|
| 394 |
response = await acompletion(
|
| 395 |
+
messages=cached_messages,
|
| 396 |
+
tools=cached_tools,
|
| 397 |
tool_choice="auto",
|
| 398 |
stream=False,
|
| 399 |
timeout=120,
|
|
|
|
| 507 |
)
|
| 508 |
try:
|
| 509 |
_t0 = time.monotonic()
|
| 510 |
+
cached_messages, _ = with_prompt_caching(messages, None, llm_params)
|
| 511 |
response = await acompletion(
|
| 512 |
+
messages=cached_messages,
|
| 513 |
tools=None,
|
| 514 |
stream=False,
|
| 515 |
timeout=120,
|
scripts/prioritize_backlog.py
CHANGED
|
@@ -33,6 +33,11 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
| 33 |
if str(PROJECT_ROOT) not in sys.path:
|
| 34 |
sys.path.insert(0, str(PROJECT_ROOT))
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
GITHUB_API = "https://api.github.com"
|
| 37 |
DEFAULT_GITHUB_REPO = "huggingface/ml-intern"
|
| 38 |
DEFAULT_HF_SPACE = "smolagents/ml-intern"
|
|
@@ -1297,10 +1302,12 @@ async def _call_json_llm(
|
|
| 1297 |
completion_func = acompletion
|
| 1298 |
|
| 1299 |
attempt_messages = list(messages)
|
|
|
|
| 1300 |
last_error: Exception | None = None
|
| 1301 |
for attempt in range(retries + 1):
|
|
|
|
| 1302 |
response = await completion_func(
|
| 1303 |
-
messages=
|
| 1304 |
max_completion_tokens=max_completion_tokens,
|
| 1305 |
temperature=_temperature_for_params(llm_params),
|
| 1306 |
**llm_params,
|
|
|
|
| 33 |
if str(PROJECT_ROOT) not in sys.path:
|
| 34 |
sys.path.insert(0, str(PROJECT_ROOT))
|
| 35 |
|
| 36 |
+
from agent.core.prompt_caching import ( # noqa: E402
|
| 37 |
+
with_prompt_cache_params,
|
| 38 |
+
with_prompt_caching,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
GITHUB_API = "https://api.github.com"
|
| 42 |
DEFAULT_GITHUB_REPO = "huggingface/ml-intern"
|
| 43 |
DEFAULT_HF_SPACE = "smolagents/ml-intern"
|
|
|
|
| 1302 |
completion_func = acompletion
|
| 1303 |
|
| 1304 |
attempt_messages = list(messages)
|
| 1305 |
+
llm_params = with_prompt_cache_params(llm_params)
|
| 1306 |
last_error: Exception | None = None
|
| 1307 |
for attempt in range(retries + 1):
|
| 1308 |
+
cached_messages, _ = with_prompt_caching(attempt_messages, None, llm_params)
|
| 1309 |
response = await completion_func(
|
| 1310 |
+
messages=cached_messages,
|
| 1311 |
max_completion_tokens=max_completion_tokens,
|
| 1312 |
temperature=_temperature_for_params(llm_params),
|
| 1313 |
**llm_params,
|
tests/unit/test_prompt_caching.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from litellm import Message
|
| 2 |
+
|
| 3 |
+
from agent.core.model_ids import HF_ROUTER_BASE_URL
|
| 4 |
+
from agent.core.prompt_caching import with_prompt_cache_params, with_prompt_caching
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _anthropic_fal_params() -> dict:
|
| 8 |
+
return {
|
| 9 |
+
"model": "openai/anthropic/claude-sonnet-4-6:fal-ai",
|
| 10 |
+
"api_base": HF_ROUTER_BASE_URL,
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _gpt55_fal_params() -> dict:
|
| 15 |
+
return {
|
| 16 |
+
"model": "openai/openai/gpt-5.5:fal-ai",
|
| 17 |
+
"api_base": HF_ROUTER_BASE_URL,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_prompt_caching_marks_system_prefix_and_tools_for_fal_router_model():
|
| 22 |
+
messages = [
|
| 23 |
+
Message(role="system", content="stable system prompt"),
|
| 24 |
+
Message(role="user", content="current question"),
|
| 25 |
+
]
|
| 26 |
+
tools = [
|
| 27 |
+
{"type": "function", "function": {"name": "read"}},
|
| 28 |
+
{"type": "function", "function": {"name": "write"}},
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
cached_messages, cached_tools = with_prompt_caching(
|
| 32 |
+
messages, tools, _anthropic_fal_params()
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
assert cached_tools is not tools
|
| 36 |
+
assert cached_tools == [
|
| 37 |
+
{"type": "function", "function": {"name": "read"}},
|
| 38 |
+
{
|
| 39 |
+
"type": "function",
|
| 40 |
+
"function": {"name": "write"},
|
| 41 |
+
"cache_control": {"type": "ephemeral"},
|
| 42 |
+
},
|
| 43 |
+
]
|
| 44 |
+
assert "cache_control" not in tools[-1]
|
| 45 |
+
assert cached_messages is not messages
|
| 46 |
+
assert cached_messages[0] == {
|
| 47 |
+
"role": "system",
|
| 48 |
+
"content": [
|
| 49 |
+
{
|
| 50 |
+
"type": "text",
|
| 51 |
+
"text": "stable system prompt",
|
| 52 |
+
"cache_control": {"type": "ephemeral"},
|
| 53 |
+
}
|
| 54 |
+
],
|
| 55 |
+
}
|
| 56 |
+
assert messages[0].content == "stable system prompt"
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_prompt_caching_marks_last_stable_user_before_current_message():
|
| 60 |
+
messages = [
|
| 61 |
+
{"role": "system", "content": "stable system"},
|
| 62 |
+
{"role": "user", "content": "stable reference"},
|
| 63 |
+
{"role": "assistant", "content": "previous answer"},
|
| 64 |
+
{"role": "user", "content": "current question"},
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
cached_messages, _ = with_prompt_caching(messages, None, _anthropic_fal_params())
|
| 68 |
+
|
| 69 |
+
assert cached_messages[0]["content"] == "stable system"
|
| 70 |
+
assert cached_messages[1]["content"] == [
|
| 71 |
+
{
|
| 72 |
+
"type": "text",
|
| 73 |
+
"text": "stable reference",
|
| 74 |
+
"cache_control": {"type": "ephemeral"},
|
| 75 |
+
}
|
| 76 |
+
]
|
| 77 |
+
assert messages[1]["content"] == "stable reference"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_prompt_caching_marks_last_text_block_in_content_list():
|
| 81 |
+
messages = [
|
| 82 |
+
{
|
| 83 |
+
"role": "user",
|
| 84 |
+
"content": [
|
| 85 |
+
{"type": "text", "text": "stable part one"},
|
| 86 |
+
{
|
| 87 |
+
"type": "image_url",
|
| 88 |
+
"image_url": {"url": "https://example.test/i.png"},
|
| 89 |
+
},
|
| 90 |
+
{"type": "text", "text": "stable part two"},
|
| 91 |
+
],
|
| 92 |
+
},
|
| 93 |
+
{"role": "user", "content": "current question"},
|
| 94 |
+
]
|
| 95 |
+
|
| 96 |
+
cached_messages, _ = with_prompt_caching(messages, None, _anthropic_fal_params())
|
| 97 |
+
|
| 98 |
+
assert cached_messages[0]["content"][0] == {
|
| 99 |
+
"type": "text",
|
| 100 |
+
"text": "stable part one",
|
| 101 |
+
}
|
| 102 |
+
assert cached_messages[0]["content"][1] == {
|
| 103 |
+
"type": "image_url",
|
| 104 |
+
"image_url": {"url": "https://example.test/i.png"},
|
| 105 |
+
}
|
| 106 |
+
assert cached_messages[0]["content"][2] == {
|
| 107 |
+
"type": "text",
|
| 108 |
+
"text": "stable part two",
|
| 109 |
+
"cache_control": {"type": "ephemeral"},
|
| 110 |
+
}
|
| 111 |
+
assert "cache_control" not in messages[0]["content"][2]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_prompt_caching_marks_tools_without_message_prefix():
|
| 115 |
+
messages = [{"role": "user", "content": "current question"}]
|
| 116 |
+
tools = [{"type": "function", "function": {"name": "read"}}]
|
| 117 |
+
|
| 118 |
+
cached_messages, cached_tools = with_prompt_caching(
|
| 119 |
+
messages, tools, _anthropic_fal_params()
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
assert cached_messages is messages
|
| 123 |
+
assert cached_tools == [
|
| 124 |
+
{
|
| 125 |
+
"type": "function",
|
| 126 |
+
"function": {"name": "read"},
|
| 127 |
+
"cache_control": {"type": "ephemeral"},
|
| 128 |
+
}
|
| 129 |
+
]
|
| 130 |
+
assert "cache_control" not in tools[0]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_prompt_caching_is_noop_for_non_fal_router_model():
|
| 134 |
+
messages = [
|
| 135 |
+
{"role": "system", "content": "stable system"},
|
| 136 |
+
{"role": "user", "content": "current question"},
|
| 137 |
+
]
|
| 138 |
+
llm_params = {
|
| 139 |
+
"model": "openai/moonshotai/Kimi-K2.6",
|
| 140 |
+
"api_base": HF_ROUTER_BASE_URL,
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
cached_messages, cached_tools = with_prompt_caching(messages, None, llm_params)
|
| 144 |
+
|
| 145 |
+
assert cached_messages is messages
|
| 146 |
+
assert cached_tools is None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_prompt_caching_is_noop_for_gpt55_fal_router_model():
|
| 150 |
+
messages = [
|
| 151 |
+
{"role": "system", "content": "stable system"},
|
| 152 |
+
{"role": "user", "content": "current question"},
|
| 153 |
+
]
|
| 154 |
+
tools = [{"type": "function", "function": {"name": "read"}}]
|
| 155 |
+
|
| 156 |
+
cached_messages, cached_tools = with_prompt_caching(
|
| 157 |
+
messages, tools, _gpt55_fal_params()
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
assert cached_messages is messages
|
| 161 |
+
assert cached_tools is tools
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_prompt_caching_is_noop_for_non_router_fal_model():
|
| 165 |
+
messages = [
|
| 166 |
+
{"role": "system", "content": "stable system"},
|
| 167 |
+
{"role": "user", "content": "current question"},
|
| 168 |
+
]
|
| 169 |
+
llm_params = {
|
| 170 |
+
"model": "openai/anthropic/claude-sonnet-4-6:fal-ai",
|
| 171 |
+
"api_base": "http://localhost:8000/v1",
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
cached_messages, _ = with_prompt_caching(messages, None, llm_params)
|
| 175 |
+
|
| 176 |
+
assert cached_messages is messages
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_prompt_cache_params_add_session_id_for_fal_router_model():
|
| 180 |
+
llm_params = _anthropic_fal_params()
|
| 181 |
+
|
| 182 |
+
cached_params = with_prompt_cache_params(llm_params, session_id="session-1")
|
| 183 |
+
|
| 184 |
+
assert cached_params is not llm_params
|
| 185 |
+
assert cached_params["extra_body"] == {"session_id": "session-1"}
|
| 186 |
+
assert "extra_body" not in llm_params
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_prompt_cache_params_merges_gpt55_cache_hints():
|
| 190 |
+
llm_params = {
|
| 191 |
+
**_gpt55_fal_params(),
|
| 192 |
+
"extra_body": {"reasoning_effort": "high"},
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
cached_params = with_prompt_cache_params(llm_params, session_id="session-1")
|
| 196 |
+
|
| 197 |
+
assert cached_params["extra_body"] == {
|
| 198 |
+
"reasoning_effort": "high",
|
| 199 |
+
"session_id": "session-1",
|
| 200 |
+
"prompt_cache_key": "session-1",
|
| 201 |
+
"prompt_cache_retention": "24h",
|
| 202 |
+
}
|
| 203 |
+
assert llm_params["extra_body"] == {"reasoning_effort": "high"}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_prompt_cache_params_adds_gpt55_retention_without_session():
|
| 207 |
+
cached_params = with_prompt_cache_params(_gpt55_fal_params())
|
| 208 |
+
|
| 209 |
+
assert cached_params["extra_body"] == {"prompt_cache_retention": "24h"}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_prompt_cache_params_is_noop_for_non_router_model():
|
| 213 |
+
llm_params = {
|
| 214 |
+
"model": "openai/openai/gpt-5.5:fal-ai",
|
| 215 |
+
"api_base": "http://localhost:8000/v1",
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
cached_params = with_prompt_cache_params(llm_params, session_id="session-1")
|
| 219 |
+
|
| 220 |
+
assert cached_params is llm_params
|