lewtun HF Staff OpenAI Codex commited on
Commit
e6f00a2
·
unverified ·
1 Parent(s): b103510

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 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=messages,
824
- tools=tools,
825
  tool_choice="auto",
826
  stream=True,
827
  stream_options={"include_usage": True},
828
  timeout=600,
829
- **llm_params,
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=messages,
963
- tools=tools,
964
  tool_choice="auto",
965
  stream=False,
966
  timeout=600,
967
- **llm_params,
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=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=messages,
388
- tools=tool_specs if tool_specs else None,
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=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=attempt_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