verifiers 0.4.2.dev36__py3-none-any.whl → 0.4.2.dev44__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ from pydantic_core import from_json, to_json
9
9
 
10
10
  from verifiers.v1.clients.base import build_async_httpx, join_url
11
11
  from verifiers.v1.clients.client import SESSION_ID_HEADER, Client, RelayReply
12
+ from verifiers.v1.clients.prompt_cache import maybe_add_prompt_cache_control
12
13
  from verifiers.v1.configs.client import (
13
14
  BaseClientConfig,
14
15
  resolve_api_key,
@@ -70,6 +71,7 @@ class EvalClient(Client):
70
71
  def __init__(self, config: BaseClientConfig) -> None:
71
72
  self.base_url = config.base_url
72
73
  self.api_key = resolve_api_key(config)
74
+ self.cache_retention = getattr(config, "cache_retention", None)
73
75
  # Keep endpoint headers separate so they can override intercepted request headers before
74
76
  # the dialect's provider authentication is applied.
75
77
  self.headers = resolve_headers(config)
@@ -84,6 +86,9 @@ class EvalClient(Client):
84
86
  turn: PendingTurn | None = None,
85
87
  headers: Mapping[str, str] | None = None,
86
88
  ) -> Response:
89
+ body = maybe_add_prompt_cache_control(
90
+ dialect, body, self.base_url, self.cache_retention
91
+ )
87
92
  resp = await self._request(
88
93
  join_url(self.base_url, dialect.upstream_path),
89
94
  body,
@@ -177,6 +182,9 @@ class EvalClient(Client):
177
182
  ) -> RelayReply:
178
183
  # Relay complete SSE events so the interception server can safely insert keepalives
179
184
  # between them. Error responses are mapped before any event is handed back.
185
+ body = maybe_add_prompt_cache_control(
186
+ dialect, body, self.base_url, self.cache_retention
187
+ )
180
188
  resp = await self._request(
181
189
  join_url(self.base_url, dialect.upstream_path),
182
190
  body,
@@ -0,0 +1,350 @@
1
+ """Gateway-side prompt-cache markers for Anthropic models.
2
+
3
+ Anthropic has no implicit prompt cache: a request only caches content when a
4
+ block carries ``cache_control``. Verifiers relays harness-native bodies, and
5
+ many harnesses send no marker, so Claude traffic through Prime Inference or
6
+ OpenRouter pays full input price. When the caller sends no marker, mark the
7
+ static prefix (last custom tool and last system/developer block) that repeats
8
+ across rollouts; multi-turn requests also mark the latest message.
9
+
10
+ Markers default to the five-minute cache (no ``ttl``). Set
11
+ ``cache_retention = "long"`` on the eval client, or ``PI_CACHE_RETENTION=long``,
12
+ to emit ``ttl: "1h"`` instead; one-hour writes cost twice as much. ``"none"``
13
+ disables injection entirely. Any caller marker wins over generation here.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ from collections.abc import Mapping
20
+ from typing import Any, Literal
21
+ from urllib.parse import urlsplit
22
+
23
+ from verifiers.v1.dialects.base import Dialect
24
+
25
+ CacheRetention = Literal["none", "short", "long"]
26
+
27
+ _CACHE_MARKERS = frozenset({"cache_control", "prompt_cache_breakpoint"})
28
+ _SCHEMA_KEYS = frozenset({"input_schema", "parameters", "json_schema"})
29
+ _ANTHROPIC_BLOCK_TYPES = frozenset({"text", "image", "tool_result", "document"})
30
+
31
+
32
+ def _has_marker(value: Any) -> bool:
33
+ return isinstance(value, Mapping) and not _CACHE_MARKERS.isdisjoint(value)
34
+
35
+
36
+ def _content_has_marker(content: Any) -> bool:
37
+ if isinstance(content, Mapping):
38
+ return _has_marker(content)
39
+ if not isinstance(content, list):
40
+ return False
41
+ return any(isinstance(block, Mapping) and _has_marker(block) for block in content)
42
+
43
+
44
+ def _has_caller_marker(body: Mapping[str, Any]) -> bool:
45
+ """Only scan positions where Anthropic honors cache markers.
46
+
47
+ Tool-use inputs, tool arguments, metadata, and JSON schemas can contain a
48
+ ``cache_control`` key without being prompt-cache markers.
49
+ """
50
+ if _has_marker(body):
51
+ return True
52
+ if _content_has_marker(body.get("system")):
53
+ return True
54
+ messages = body.get("messages")
55
+ if isinstance(messages, list):
56
+ for message in messages:
57
+ if not isinstance(message, Mapping):
58
+ continue
59
+ if _has_marker(message):
60
+ return True
61
+ content = message.get("content")
62
+ if _content_has_marker(content):
63
+ return True
64
+ if isinstance(content, list):
65
+ for block in content:
66
+ if (
67
+ isinstance(block, Mapping)
68
+ and block.get("type") == "tool_result"
69
+ and _content_has_marker(block.get("content"))
70
+ ):
71
+ return True
72
+ tools = body.get("tools")
73
+ if isinstance(tools, list):
74
+ for tool in tools:
75
+ if _has_marker(tool):
76
+ return True
77
+ return False
78
+
79
+
80
+ def resolve_cache_retention(
81
+ cache_retention: CacheRetention | None,
82
+ ) -> CacheRetention:
83
+ """Explicit setting wins, then ``PI_CACHE_RETENTION=long``, then short."""
84
+ if cache_retention is not None:
85
+ return cache_retention
86
+ env = os.environ.get("PI_CACHE_RETENTION", "").strip().casefold()
87
+ if env in {"none", "short", "long"}:
88
+ return env
89
+ return "short"
90
+
91
+
92
+ def _cache_marker(retention: CacheRetention) -> dict[str, str]:
93
+ marker = {"type": "ephemeral"}
94
+ if retention == "long":
95
+ marker["ttl"] = "1h"
96
+ return marker
97
+
98
+
99
+ def _is_anthropic_model(body: Mapping[str, Any]) -> bool:
100
+ model = body.get("model")
101
+ return isinstance(model, str) and model.casefold().startswith("anthropic/")
102
+
103
+
104
+ def _is_gateway(base_url: str) -> bool:
105
+ hostname = urlsplit(base_url).hostname
106
+ if hostname is None:
107
+ return False
108
+ host = hostname.casefold()
109
+ return host in {"openrouter.ai", "pinference.ai"} or host.endswith(
110
+ (".openrouter.ai", ".pinference.ai")
111
+ )
112
+
113
+
114
+ def _mark_last_tool(
115
+ body: dict[str, Any], *, anthropic: bool, marker: dict[str, str]
116
+ ) -> bool:
117
+ tools = body.get("tools")
118
+ if not isinstance(tools, list) or not tools:
119
+ return False
120
+ for index in range(len(tools) - 1, -1, -1):
121
+ tool = tools[index]
122
+ if not isinstance(tool, dict):
123
+ continue
124
+ if anthropic:
125
+ # Server tools (web search, computer use, ...) do not accept a
126
+ # cache marker; fall back to the last custom tool.
127
+ tool_type = tool.get("type")
128
+ if (tool_type is not None and tool_type != "custom") or "name" not in tool:
129
+ continue
130
+ elif tool.get("type") != "function":
131
+ continue
132
+ updated = list(tools)
133
+ updated[index] = {**tool, "cache_control": dict(marker)}
134
+ body["tools"] = updated
135
+ return True
136
+ return False
137
+
138
+
139
+ def _mark_last_text_block(
140
+ message: dict[str, Any], marker: dict[str, str]
141
+ ) -> dict | None:
142
+ content = message.get("content")
143
+ if isinstance(content, str):
144
+ if not content:
145
+ return None
146
+ return {
147
+ **message,
148
+ "content": [
149
+ {
150
+ "type": "text",
151
+ "text": content,
152
+ "cache_control": dict(marker),
153
+ }
154
+ ],
155
+ }
156
+ if not isinstance(content, list):
157
+ return None
158
+ for index in range(len(content) - 1, -1, -1):
159
+ block = content[index]
160
+ if (
161
+ isinstance(block, dict)
162
+ and block.get("type") == "text"
163
+ and isinstance(block.get("text"), str)
164
+ and block["text"]
165
+ ):
166
+ updated = list(content)
167
+ updated[index] = {**block, "cache_control": dict(marker)}
168
+ return {**message, "content": updated}
169
+ return None
170
+
171
+
172
+ def _mark_last_anthropic_block(
173
+ message: dict[str, Any], marker: dict[str, str]
174
+ ) -> dict | None:
175
+ content = message.get("content")
176
+ if isinstance(content, str):
177
+ if not content:
178
+ return None
179
+ return {
180
+ **message,
181
+ "content": [
182
+ {
183
+ "type": "text",
184
+ "text": content,
185
+ "cache_control": dict(marker),
186
+ }
187
+ ],
188
+ }
189
+ if not isinstance(content, list):
190
+ return None
191
+ for index in range(len(content) - 1, -1, -1):
192
+ block = content[index]
193
+ if not isinstance(block, dict):
194
+ continue
195
+ block_type = block.get("type")
196
+ if not isinstance(block_type, str) or block_type not in _ANTHROPIC_BLOCK_TYPES:
197
+ continue
198
+ if block_type == "text" and not (
199
+ isinstance(block.get("text"), str) and block["text"]
200
+ ):
201
+ continue
202
+ updated = list(content)
203
+ updated[index] = {**block, "cache_control": dict(marker)}
204
+ return {**message, "content": updated}
205
+ return None
206
+
207
+
208
+ def _leading_instruction_indices(messages: list[Any]) -> list[int]:
209
+ indices: list[int] = []
210
+ for index, message in enumerate(messages):
211
+ if not isinstance(message, dict):
212
+ break
213
+ role = message.get("role")
214
+ if not isinstance(role, str) or role not in {"system", "developer"}:
215
+ break
216
+ indices.append(index)
217
+ return indices
218
+
219
+
220
+ def _has_chat_history(messages: list[Any]) -> bool:
221
+ user_turns = 0
222
+ for message in messages:
223
+ if not isinstance(message, dict):
224
+ continue
225
+ role = message.get("role")
226
+ if not isinstance(role, str):
227
+ continue
228
+ if role == "user":
229
+ user_turns += 1
230
+ elif role in {"assistant", "tool"}:
231
+ return True
232
+ return user_turns > 1
233
+
234
+
235
+ def _has_messages_history(messages: list[Any]) -> bool:
236
+ if len([message for message in messages if isinstance(message, dict)]) <= 1:
237
+ return False
238
+ return isinstance(messages[-1], dict) and messages[-1].get("role") == "user"
239
+
240
+
241
+ def _mark_chat(body: dict[str, Any], marker: dict[str, str]) -> bool:
242
+ applied = _mark_last_tool(body, anthropic=False, marker=marker)
243
+ messages = body.get("messages")
244
+ if not isinstance(messages, list) or not messages:
245
+ return applied
246
+
247
+ updated_messages = list(messages)
248
+ messages_changed = False
249
+ for index in reversed(_leading_instruction_indices(messages)):
250
+ marked = _mark_last_text_block(messages[index], marker)
251
+ if marked is not None:
252
+ updated_messages[index] = marked
253
+ messages_changed = True
254
+ applied = True
255
+ break
256
+
257
+ last_message = messages[-1] if messages else None
258
+ last_role = last_message.get("role") if isinstance(last_message, dict) else None
259
+ if (
260
+ _has_chat_history(messages)
261
+ and isinstance(last_message, dict)
262
+ and isinstance(last_role, str)
263
+ and last_role in {"user", "tool"}
264
+ ):
265
+ marked = _mark_last_text_block(last_message, marker)
266
+ if marked is not None:
267
+ updated_messages[-1] = marked
268
+ messages_changed = True
269
+ applied = True
270
+
271
+ if messages_changed:
272
+ body["messages"] = updated_messages
273
+ return applied
274
+
275
+
276
+ def _mark_anthropic_messages(body: dict[str, Any], marker: dict[str, str]) -> bool:
277
+ applied = _mark_last_tool(body, anthropic=True, marker=marker)
278
+
279
+ system = body.get("system")
280
+ if isinstance(system, str):
281
+ if system:
282
+ body["system"] = [
283
+ {
284
+ "type": "text",
285
+ "text": system,
286
+ "cache_control": dict(marker),
287
+ }
288
+ ]
289
+ applied = True
290
+ elif isinstance(system, list):
291
+ for index in range(len(system) - 1, -1, -1):
292
+ block = system[index]
293
+ if (
294
+ isinstance(block, dict)
295
+ and block.get("type") == "text"
296
+ and isinstance(block.get("text"), str)
297
+ and block["text"]
298
+ ):
299
+ updated = list(system)
300
+ updated[index] = {**block, "cache_control": dict(marker)}
301
+ body["system"] = updated
302
+ applied = True
303
+ break
304
+
305
+ messages = body.get("messages")
306
+ if isinstance(messages, list) and messages and _has_messages_history(messages):
307
+ marked = _mark_last_anthropic_block(messages[-1], marker)
308
+ if marked is not None:
309
+ body["messages"] = [*messages[:-1], marked]
310
+ applied = True
311
+ return applied
312
+
313
+
314
+ def maybe_add_prompt_cache_control(
315
+ dialect: Dialect,
316
+ body: dict[str, Any],
317
+ base_url: str,
318
+ cache_retention: CacheRetention | None = None,
319
+ ) -> dict[str, Any]:
320
+ """Return a request body with prompt-cache markers when eligible.
321
+
322
+ Only Anthropic models on Prime Inference/OpenRouter are touched, and only
323
+ when the caller has not already set any cache marker. The original body is
324
+ left untouched; edited lists and blocks are copied.
325
+ """
326
+ if dialect.name not in {"anthropic_messages", "chat_completions"}:
327
+ return body
328
+ if not _is_gateway(base_url) or not _is_anthropic_model(body):
329
+ return body
330
+ retention = resolve_cache_retention(cache_retention)
331
+ if retention == "none":
332
+ return body
333
+ if _has_caller_marker(body):
334
+ return body
335
+
336
+ marker = _cache_marker(retention)
337
+ updated = dict(body)
338
+ if isinstance(body.get("tools"), list):
339
+ updated["tools"] = list(body["tools"])
340
+ if isinstance(body.get("messages"), list):
341
+ updated["messages"] = list(body["messages"])
342
+ if isinstance(body.get("system"), list):
343
+ updated["system"] = list(body["system"])
344
+
345
+ applied = (
346
+ _mark_anthropic_messages(updated, marker)
347
+ if dialect.name == "anthropic_messages"
348
+ else _mark_chat(updated, marker)
349
+ )
350
+ return updated if applied else body
@@ -75,6 +75,9 @@ class EvalClientConfig(BaseClientConfig):
75
75
  """The default (eval): forward each request to a matching endpoint via `EvalClient`."""
76
76
 
77
77
  type: Literal["eval"] = "eval"
78
+ cache_retention: Literal["none", "short", "long"] | None = None
79
+ """Anthropic prompt-cache lifetime. ``None`` follows ``PI_CACHE_RETENTION``
80
+ (short by default); ``long`` emits ``ttl: "1h"``; ``none`` disables markers."""
78
81
 
79
82
 
80
83
  class TrainClientConfig(BaseClientConfig):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: verifiers
3
- Version: 0.4.2.dev36
3
+ Version: 0.4.2.dev44
4
4
  Summary: Verifiers: Environments for LLM Reinforcement Learning
5
5
  Project-URL: Homepage, https://github.com/primeintellect-ai/verifiers
6
6
  Project-URL: Documentation, https://github.com/primeintellect-ai/verifiers
@@ -29,11 +29,12 @@ verifiers/v1/cli/validate.py,sha256=IXvY9Ey7snPc6pJCX9QlGO_t9KFYRwcIVj0G5TFDanU,
29
29
  verifiers/v1/clients/__init__.py,sha256=Ysig0tE_0E4Jsfgfes1XHN-fK1s_RCXdqZD6E7lK4PU,507
30
30
  verifiers/v1/clients/base.py,sha256=C0G5lRm3nxt1xmPoA2cweBgCnN2KcLepmCYq1vdDrnU,1691
31
31
  verifiers/v1/clients/client.py,sha256=HT3aQiOyYYjvz_cKEXchtUSD07-4VehebWUJL9AyON8,3166
32
- verifiers/v1/clients/eval.py,sha256=pWHgnRU8QQE6jnjJdwJNdvR8BeJ-z_OfjIB1tcwLpkc,8340
32
+ verifiers/v1/clients/eval.py,sha256=GgpHZGh4IW3gC-tOZiFPp62MCL4aHik0b4afz2g3-Qo,8729
33
+ verifiers/v1/clients/prompt_cache.py,sha256=shJhbTUwKwao6m3DsYDyHPWmhXSJJnXk3wYQhzXFsnY,11849
33
34
  verifiers/v1/clients/train.py,sha256=v1YCVNCEXw2FNQw4lpbLoq18u8TQeo9TnsYOoMWr4zE,20277
34
35
  verifiers/v1/configs/__init__.py,sha256=X7u6X7B3ieD1HZl79Tv1kg-yPHbT0QLvAebaGqxVtvA,317
35
36
  verifiers/v1/configs/agent.py,sha256=xn9j1WcBIGz7LjgW8WOWDvSvk7vPLztisSLVcT2foxk,7530
36
- verifiers/v1/configs/client.py,sha256=IvYQuPLVWr8qZNdMbQ9lRXK0wZ4vOjK67lO1lgD2fmM,6149
37
+ verifiers/v1/configs/client.py,sha256=nFUI5oCih6L7by6M2l3wevghzpct8E_grmabT8lYWrA,6381
37
38
  verifiers/v1/configs/deployment.py,sha256=Eggd8phUNVcq_f56Haw-OT5U6fTBLDhBnXFJ5AEb7MI,389
38
39
  verifiers/v1/configs/env.py,sha256=DgjIbU5NxLGV7h4v-2lcDJF-y4S_VqnEqNNrNt7Ztrk,7532
39
40
  verifiers/v1/configs/harness.py,sha256=I9DcHzml17T7SYHobP7E6jHwNSL2eSqEtckioQ2pGNQ,3356
@@ -186,8 +187,8 @@ verifiers/v1/utils/scope.py,sha256=zbs4pj6AVk4jywlAJ3AX0Q0_kzB9dBB02nY-9H4ELdQ,8
186
187
  verifiers/v1/utils/score.py,sha256=493yJVMw8teCu9JxapxMFPFzI0hNUqdo0Y2nGW4kckk,6200
187
188
  verifiers/v1/utils/trace_store.py,sha256=Qo9qvsJAl8PoRUavjQAfUB2VNxmUtYQtU2ejLKPzY9c,3382
188
189
  verifiers/v1/utils/version.py,sha256=-obEo_-l9-D8FLef4hYxncOe-uJpxrM1g2Hig_37Sgs,1607
189
- verifiers-0.4.2.dev36.dist-info/METADATA,sha256=rDjstLcPqgBJzJiro5gbMxVFQpKQKkRDdXSSdkGgqlM,5115
190
- verifiers-0.4.2.dev36.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
191
- verifiers-0.4.2.dev36.dist-info/entry_points.txt,sha256=qdMg7ajiFpDsHf7tuPMRclJXizu1J6LTWawS5YS0B0I,137
192
- verifiers-0.4.2.dev36.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
193
- verifiers-0.4.2.dev36.dist-info/RECORD,,
190
+ verifiers-0.4.2.dev44.dist-info/METADATA,sha256=1QsclZmVbRWmAKlpEy0XS6It5xZpqx0mfwtAzwiH7VA,5115
191
+ verifiers-0.4.2.dev44.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
192
+ verifiers-0.4.2.dev44.dist-info/entry_points.txt,sha256=qdMg7ajiFpDsHf7tuPMRclJXizu1J6LTWawS5YS0B0I,137
193
+ verifiers-0.4.2.dev44.dist-info/licenses/LICENSE,sha256=v0RrUsdV3IDoZhrRce297IXS3xMHNJ-_LdLpFAUWb9k,1072
194
+ verifiers-0.4.2.dev44.dist-info/RECORD,,