compresr 2.6.5__tar.gz → 2.7.2__tar.gz
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.
- {compresr-2.6.5 → compresr-2.7.2}/PKG-INFO +38 -2
- {compresr-2.6.5 → compresr-2.7.2}/README.md +36 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/__init__.py +7 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/engine.py +249 -9
- {compresr-2.6.5 → compresr-2.7.2}/compresr/services/compression.py +40 -2
- {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/PKG-INFO +38 -2
- {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/requires.txt +1 -1
- {compresr-2.6.5 → compresr-2.7.2}/pyproject.toml +2 -2
- {compresr-2.6.5 → compresr-2.7.2}/LICENSE +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/anthropic.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/native.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/facades/openai.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/normalized.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/schemas/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/schemas/anthropic.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/schemas/openai.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/tools/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/agents/tools/web_search.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/clients.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/config.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/exceptions/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/exceptions/exceptions.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/client.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/compress.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/errors.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/filters.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/kernel.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/policy.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/query.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/_shared/tokens.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/middleware.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/retriever.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langchain/wrappers.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/checkpoint.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/handoff.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/nodes.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/langgraph/store.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/guardrail.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/initializer.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/litellm/types.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/memory.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/postprocessor.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/integrations/llamaindex/wrappers.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/base.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/inference.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/schemas/usage.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/services/__init__.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/services/base.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr/services/proxy.py +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/SOURCES.txt +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/dependency_links.txt +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/compresr.egg-info/top_level.txt +0 -0
- {compresr-2.6.5 → compresr-2.7.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compresr
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.2
|
|
4
4
|
Summary: Python SDK for Compresr - Intelligent prompt compression service
|
|
5
5
|
Author-email: Compresr Team <founders@compresr.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -26,7 +26,7 @@ Requires-Dist: httpx>=0.27.0
|
|
|
26
26
|
Requires-Dist: pydantic>=2.10.0
|
|
27
27
|
Requires-Dist: langchain>=1.0
|
|
28
28
|
Requires-Dist: langchain-core>=0.3
|
|
29
|
-
Requires-Dist: langchain-anthropic>=0
|
|
29
|
+
Requires-Dist: langchain-anthropic>=1.0
|
|
30
30
|
Requires-Dist: langchain-openai>=0.3
|
|
31
31
|
Requires-Dist: langchain-google-genai>=4.0
|
|
32
32
|
Requires-Dist: langchain-tavily>=0.1
|
|
@@ -216,6 +216,42 @@ OpenAI's `web_search_preview`, and Gemini's `google_search` run server-side
|
|
|
216
216
|
and return encrypted/opaque content that Compresr cannot read or compress.
|
|
217
217
|
Use Tavily or Brave so the result is plaintext we can compress.
|
|
218
218
|
|
|
219
|
+
## Research agent
|
|
220
|
+
|
|
221
|
+
`client.research.run(question)` runs a multi-step web-research loop with
|
|
222
|
+
per-snippet `latte_v1` compression on tool results and multi-provider prompt
|
|
223
|
+
caching. Loop structure adapted from Perplexity `search_evals` (MIT).
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from compresr import CompressionClient
|
|
227
|
+
|
|
228
|
+
client = CompressionClient(
|
|
229
|
+
api_key="cmp_...",
|
|
230
|
+
llm="anthropic:claude-sonnet-4-6",
|
|
231
|
+
llm_api_key="sk-ant-...",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
result = client.research.run(
|
|
235
|
+
"What was the latest stable Python version released in 2025?",
|
|
236
|
+
search="tavily", # "tavily" | "brave" | a LangChain BaseTool
|
|
237
|
+
max_steps=10,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
print(result.answer) # parsed Exact Answer field
|
|
241
|
+
print(result.explanation) # parsed Explanation field
|
|
242
|
+
print(result.confidence) # parsed 0-1 confidence
|
|
243
|
+
print(result.citations) # list[Citation(url=...)]
|
|
244
|
+
print(result.usage.cache_read_tokens, result.usage.calls)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Single-shot mode: `client.research.search(question)` runs one search + a
|
|
248
|
+
forced final answer (equivalent to `run(..., max_steps=2)`).
|
|
249
|
+
|
|
250
|
+
The agent respects all the prompt-cache options on `CompressionClient`
|
|
251
|
+
(`enable_prompt_cache`, `prompt_cache_ttl`, `openai_prompt_cache_key`).
|
|
252
|
+
Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
|
|
253
|
+
(falls back to `BRAVE_API_KEY`).
|
|
254
|
+
|
|
219
255
|
## Compression options
|
|
220
256
|
|
|
221
257
|
| Param | Purpose |
|
|
@@ -153,6 +153,42 @@ OpenAI's `web_search_preview`, and Gemini's `google_search` run server-side
|
|
|
153
153
|
and return encrypted/opaque content that Compresr cannot read or compress.
|
|
154
154
|
Use Tavily or Brave so the result is plaintext we can compress.
|
|
155
155
|
|
|
156
|
+
## Research agent
|
|
157
|
+
|
|
158
|
+
`client.research.run(question)` runs a multi-step web-research loop with
|
|
159
|
+
per-snippet `latte_v1` compression on tool results and multi-provider prompt
|
|
160
|
+
caching. Loop structure adapted from Perplexity `search_evals` (MIT).
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from compresr import CompressionClient
|
|
164
|
+
|
|
165
|
+
client = CompressionClient(
|
|
166
|
+
api_key="cmp_...",
|
|
167
|
+
llm="anthropic:claude-sonnet-4-6",
|
|
168
|
+
llm_api_key="sk-ant-...",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
result = client.research.run(
|
|
172
|
+
"What was the latest stable Python version released in 2025?",
|
|
173
|
+
search="tavily", # "tavily" | "brave" | a LangChain BaseTool
|
|
174
|
+
max_steps=10,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
print(result.answer) # parsed Exact Answer field
|
|
178
|
+
print(result.explanation) # parsed Explanation field
|
|
179
|
+
print(result.confidence) # parsed 0-1 confidence
|
|
180
|
+
print(result.citations) # list[Citation(url=...)]
|
|
181
|
+
print(result.usage.cache_read_tokens, result.usage.calls)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Single-shot mode: `client.research.search(question)` runs one search + a
|
|
185
|
+
forced final answer (equivalent to `run(..., max_steps=2)`).
|
|
186
|
+
|
|
187
|
+
The agent respects all the prompt-cache options on `CompressionClient`
|
|
188
|
+
(`enable_prompt_cache`, `prompt_cache_ttl`, `openai_prompt_cache_key`).
|
|
189
|
+
Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
|
|
190
|
+
(falls back to `BRAVE_API_KEY`).
|
|
191
|
+
|
|
156
192
|
## Compression options
|
|
157
193
|
|
|
158
194
|
| Param | Purpose |
|
|
@@ -56,6 +56,9 @@ __all__ = [
|
|
|
56
56
|
"MODELS",
|
|
57
57
|
"WebSearchTool",
|
|
58
58
|
"CompressionPolicy",
|
|
59
|
+
"ResearchResult",
|
|
60
|
+
"ResearchAgent",
|
|
61
|
+
"Citation",
|
|
59
62
|
]
|
|
60
63
|
|
|
61
64
|
|
|
@@ -69,4 +72,8 @@ def __getattr__(name: str) -> Any:
|
|
|
69
72
|
from .integrations._shared import CompressionPolicy as _P
|
|
70
73
|
|
|
71
74
|
return _P
|
|
75
|
+
if name in {"ResearchResult", "ResearchAgent", "Citation"}:
|
|
76
|
+
from .agents import research as _r
|
|
77
|
+
|
|
78
|
+
return getattr(_r, name)
|
|
72
79
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -13,7 +13,7 @@ public surface. Only the normalized result type leaks out.
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import logging
|
|
16
|
-
from typing import Any, Optional, Sequence
|
|
16
|
+
from typing import Any, Literal, Optional, Sequence, cast
|
|
17
17
|
|
|
18
18
|
from compresr.exceptions import CompresrError
|
|
19
19
|
from compresr.integrations._shared import CompressionPolicy
|
|
@@ -64,6 +64,83 @@ def _CompresrToolMiddleware(*args: Any, **kwargs: Any) -> Any:
|
|
|
64
64
|
_KNOWN_PROVIDERS = {"anthropic", "openai", "google_genai"}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Solo-WebSearchTool auto-route
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
#
|
|
71
|
+
# When a generic-agent facade (``client.messages.create`` / ``client.chat
|
|
72
|
+
# .completions.create`` / ``client.run``) is invoked with exactly the
|
|
73
|
+
# Compresr ``WebSearchTool`` and nothing else, the engine silently routes the
|
|
74
|
+
# call through the deep-research loop instead of the standard ``create_agent``
|
|
75
|
+
# middleware path. The caller still gets back their facade's expected shape
|
|
76
|
+
# (Anthropic ``Message`` / OpenAI ``ChatCompletion`` / ``NormalizedResult``),
|
|
77
|
+
# but the loop semantics are research-agent: ``tool_choice="none"`` on the
|
|
78
|
+
# final step, per-snippet compression against the live query, 10-step cap,
|
|
79
|
+
# manual Anthropic ``cache_control`` stamping.
|
|
80
|
+
#
|
|
81
|
+
# Detection is by tool name because ``WebSearchTool.__new__`` returns a
|
|
82
|
+
# ``StructuredTool`` (not a ``WebSearchTool`` instance), so ``isinstance``
|
|
83
|
+
# would always be False.
|
|
84
|
+
_WEB_SEARCH_TOOL_NAMES = frozenset({"tavily_search", "brave_search"})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_solo_web_search(tools: Sequence) -> bool:
|
|
88
|
+
"""True when ``tools`` is exactly one Compresr-built web-search tool."""
|
|
89
|
+
if len(tools) != 1:
|
|
90
|
+
return False
|
|
91
|
+
name = getattr(tools[0], "name", None)
|
|
92
|
+
return isinstance(name, str) and name in _WEB_SEARCH_TOOL_NAMES
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_last_user_text(messages: Sequence) -> str:
|
|
96
|
+
"""Pull the last user/human message text out of a facade-shaped chain.
|
|
97
|
+
|
|
98
|
+
Supports the message shapes that the facades feed the engine — plain
|
|
99
|
+
dicts (Anthropic/OpenAI style) and LangChain ``BaseMessage`` instances.
|
|
100
|
+
Falls back to the first message's content when no explicit user message
|
|
101
|
+
is found, and to ``""`` on empty input.
|
|
102
|
+
"""
|
|
103
|
+
for msg in reversed(list(messages)):
|
|
104
|
+
role = None
|
|
105
|
+
content: Any = None
|
|
106
|
+
if isinstance(msg, dict):
|
|
107
|
+
role = msg.get("role")
|
|
108
|
+
content = msg.get("content")
|
|
109
|
+
else:
|
|
110
|
+
role = getattr(msg, "role", None) or getattr(msg, "type", None)
|
|
111
|
+
content = getattr(msg, "content", None)
|
|
112
|
+
if role in ("user", "human") and content is not None:
|
|
113
|
+
return _content_text_any(content)
|
|
114
|
+
if messages:
|
|
115
|
+
first = messages[0]
|
|
116
|
+
content = (
|
|
117
|
+
first.get("content") if isinstance(first, dict) else getattr(first, "content", None)
|
|
118
|
+
)
|
|
119
|
+
return _content_text_any(content)
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _content_text_any(content: Any) -> str:
|
|
124
|
+
"""Stringify the ``content`` field of a chat message.
|
|
125
|
+
|
|
126
|
+
Handles strings, lists of text blocks (Anthropic/OpenAI structured form),
|
|
127
|
+
and arbitrary fallbacks via ``str()``.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(content, str):
|
|
130
|
+
return content
|
|
131
|
+
if isinstance(content, list):
|
|
132
|
+
parts: list[str] = []
|
|
133
|
+
for block in content:
|
|
134
|
+
if isinstance(block, dict):
|
|
135
|
+
text = block.get("text")
|
|
136
|
+
if isinstance(text, str):
|
|
137
|
+
parts.append(text)
|
|
138
|
+
elif isinstance(block, str):
|
|
139
|
+
parts.append(block)
|
|
140
|
+
return "".join(parts)
|
|
141
|
+
return str(content) if content is not None else ""
|
|
142
|
+
|
|
143
|
+
|
|
67
144
|
# Kwargs that propagate to the underlying chat model via ``.bind(...)`` per
|
|
68
145
|
# call. Covers Anthropic / OpenAI / Gemini. LangChain's chat models normalize
|
|
69
146
|
# most of these across providers; unsupported keys are silently ignored
|
|
@@ -266,6 +343,15 @@ _USAGE_NUMERIC_KEYS = (
|
|
|
266
343
|
"cache_creation_input_tokens",
|
|
267
344
|
)
|
|
268
345
|
|
|
346
|
+
# Provider-specific cache-read aliases under ``input_token_details``.
|
|
347
|
+
_INPUT_DETAILS_CACHE_READ_ALIASES = (
|
|
348
|
+
"cached_tokens",
|
|
349
|
+
"cache_read",
|
|
350
|
+
"priority_cache_read",
|
|
351
|
+
"flex_cache_read",
|
|
352
|
+
"cached_content_token_count",
|
|
353
|
+
)
|
|
354
|
+
|
|
269
355
|
|
|
270
356
|
def _aggregate_usage(messages: list) -> dict:
|
|
271
357
|
"""Sum ``usage_metadata`` across every AIMessage in the conversation.
|
|
@@ -305,8 +391,26 @@ def _aggregate_usage(messages: list) -> dict:
|
|
|
305
391
|
if not has_top_cache:
|
|
306
392
|
details = per.get("input_token_details")
|
|
307
393
|
if isinstance(details, dict):
|
|
308
|
-
|
|
309
|
-
|
|
394
|
+
msg_cache_read = 0
|
|
395
|
+
msg_cache_create = 0
|
|
396
|
+
for alias in _INPUT_DETAILS_CACHE_READ_ALIASES:
|
|
397
|
+
v = details.get(alias)
|
|
398
|
+
if isinstance(v, (int, float)):
|
|
399
|
+
msg_cache_read += int(v)
|
|
400
|
+
# When cache_control sets a ttl, langchain-anthropic writes
|
|
401
|
+
# the count to the TTL-specific keys and zeroes ``cache_creation``.
|
|
402
|
+
ttl_writes = int(details.get("ephemeral_5m_input_tokens", 0) or 0) + int(
|
|
403
|
+
details.get("ephemeral_1h_input_tokens", 0) or 0
|
|
404
|
+
)
|
|
405
|
+
msg_cache_create = ttl_writes or int(details.get("cache_creation", 0) or 0)
|
|
406
|
+
total["cache_read_input_tokens"] += msg_cache_read
|
|
407
|
+
total["cache_creation_input_tokens"] += msg_cache_create
|
|
408
|
+
# langchain-anthropic 1.x inflates ``input_tokens`` to
|
|
409
|
+
# fresh + read + create; subtract so it means fresh-only.
|
|
410
|
+
this_input = per.get("input_tokens")
|
|
411
|
+
if isinstance(this_input, (int, float)) and (msg_cache_read or msg_cache_create):
|
|
412
|
+
overcount = msg_cache_read + msg_cache_create
|
|
413
|
+
total["input_tokens"] -= min(overcount, int(this_input))
|
|
310
414
|
# Carry through any other numeric extras (reasoning_tokens, …).
|
|
311
415
|
for k, v in per.items():
|
|
312
416
|
if k in _USAGE_NUMERIC_KEYS or k == "input_token_details":
|
|
@@ -317,6 +421,22 @@ def _aggregate_usage(messages: list) -> dict:
|
|
|
317
421
|
return out
|
|
318
422
|
|
|
319
423
|
|
|
424
|
+
def _tool_name(t: Any) -> str:
|
|
425
|
+
name = getattr(t, "name", None)
|
|
426
|
+
if isinstance(name, str):
|
|
427
|
+
return name
|
|
428
|
+
if isinstance(t, dict):
|
|
429
|
+
return str(t.get("name") or "")
|
|
430
|
+
return ""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _sorted_tools_for_cache(tools: Sequence) -> list:
|
|
434
|
+
"""Stable tool order so the Anthropic tool-block cache hash doesn't drift."""
|
|
435
|
+
indexed = list(enumerate(tools))
|
|
436
|
+
indexed.sort(key=lambda p: (_tool_name(p[1]), p[0]))
|
|
437
|
+
return [t for _, t in indexed]
|
|
438
|
+
|
|
439
|
+
|
|
320
440
|
def _last_ai_message(messages: list) -> Any:
|
|
321
441
|
"""Return the final assistant message from an agent invoke result."""
|
|
322
442
|
for msg in reversed(messages or []):
|
|
@@ -354,11 +474,19 @@ class _Engine:
|
|
|
354
474
|
llm: str,
|
|
355
475
|
llm_api_key: Optional[str] = None,
|
|
356
476
|
policy: Optional[CompressionPolicy] = None,
|
|
477
|
+
enable_prompt_cache: bool = True,
|
|
478
|
+
prompt_cache_ttl: str = "5m",
|
|
479
|
+
prompt_cache_min_messages: int = 2,
|
|
480
|
+
openai_prompt_cache_key: Optional[str] = None,
|
|
357
481
|
) -> None:
|
|
358
482
|
self._compresr_client = compresr_client
|
|
359
483
|
self._provider, self._default_model_name = _parse_llm(llm)
|
|
360
484
|
self._policy = policy or CompressionPolicy()
|
|
361
485
|
self._llm_api_key = llm_api_key
|
|
486
|
+
self._enable_prompt_cache = bool(enable_prompt_cache)
|
|
487
|
+
self._prompt_cache_ttl = prompt_cache_ttl
|
|
488
|
+
self._prompt_cache_min_messages = int(prompt_cache_min_messages)
|
|
489
|
+
self._openai_prompt_cache_key = openai_prompt_cache_key
|
|
362
490
|
# Chat-model cache keyed by effective model name. Construction is
|
|
363
491
|
# deferred to the first ``run()`` so a provider-only client
|
|
364
492
|
# (``llm="anthropic"``) doesn't try to resolve a model at init time.
|
|
@@ -416,6 +544,15 @@ class _Engine:
|
|
|
416
544
|
kwargs: dict = dict(extra_kwargs)
|
|
417
545
|
if self._llm_api_key is not None:
|
|
418
546
|
kwargs.setdefault("api_key", self._llm_api_key)
|
|
547
|
+
# OpenAI caching is server-side; we only pass ``prompt_cache_key``
|
|
548
|
+
# and ``prompt_cache_retention`` via ``model_kwargs`` so they survive
|
|
549
|
+
# ``bind_tools``.
|
|
550
|
+
if self._provider == "openai" and self._enable_prompt_cache:
|
|
551
|
+
openai_extra = self._openai_cache_model_kwargs()
|
|
552
|
+
if openai_extra:
|
|
553
|
+
mk = dict(kwargs.get("model_kwargs") or {})
|
|
554
|
+
mk.update(openai_extra)
|
|
555
|
+
kwargs["model_kwargs"] = mk
|
|
419
556
|
# OpenAI Responses output_version is a constructor kwarg in newer
|
|
420
557
|
# langchain-openai; older versions reject unknown kwargs. Try both.
|
|
421
558
|
if self._provider == "openai":
|
|
@@ -429,17 +566,57 @@ class _Engine:
|
|
|
429
566
|
logger.debug("init_chat_model rejected output_version; falling back without it")
|
|
430
567
|
return init_chat_model(f"{self._provider}:{model_name}", **kwargs)
|
|
431
568
|
|
|
569
|
+
def _openai_cache_model_kwargs(self) -> dict:
|
|
570
|
+
"""Build the OpenAI-specific cache controls. ``prompt_cache_retention``
|
|
571
|
+
is only set for ``ttl="1h"`` so older langchain-openai doesn't reject
|
|
572
|
+
the unknown field on the default path."""
|
|
573
|
+
out: dict = {}
|
|
574
|
+
if self._openai_prompt_cache_key:
|
|
575
|
+
out["prompt_cache_key"] = self._openai_prompt_cache_key
|
|
576
|
+
if self._prompt_cache_ttl == "1h":
|
|
577
|
+
out["prompt_cache_retention"] = "24h"
|
|
578
|
+
return out
|
|
579
|
+
|
|
432
580
|
def _build_middleware(self) -> list:
|
|
433
581
|
"""Build the middleware stack used for every run.
|
|
434
582
|
|
|
435
583
|
The compression knobs come straight from
|
|
436
584
|
:meth:`CompressionPolicy.tool_kwargs`.
|
|
437
585
|
"""
|
|
438
|
-
mw =
|
|
439
|
-
|
|
440
|
-
|
|
586
|
+
mw: list = [
|
|
587
|
+
_CompresrToolMiddleware(
|
|
588
|
+
client=self._compresr_client,
|
|
589
|
+
**self._policy.tool_kwargs(),
|
|
590
|
+
)
|
|
591
|
+
]
|
|
592
|
+
cache_mw = self._maybe_build_cache_middleware()
|
|
593
|
+
if cache_mw is not None:
|
|
594
|
+
# AFTER CompresrToolMiddleware so cache markers stamp the
|
|
595
|
+
# post-compression content.
|
|
596
|
+
mw.append(cache_mw)
|
|
597
|
+
return mw
|
|
598
|
+
|
|
599
|
+
def _maybe_build_cache_middleware(self) -> Optional[Any]:
|
|
600
|
+
"""Anthropic prompt-cache middleware when enabled, else None. Silently
|
|
601
|
+
degrades on older ``langchain-anthropic`` without the middleware module."""
|
|
602
|
+
if not self._enable_prompt_cache or self._provider != "anthropic":
|
|
603
|
+
return None
|
|
604
|
+
try:
|
|
605
|
+
from langchain_anthropic.middleware import ( # type: ignore[import-not-found]
|
|
606
|
+
AnthropicPromptCachingMiddleware,
|
|
607
|
+
)
|
|
608
|
+
except ImportError:
|
|
609
|
+
logger.debug(
|
|
610
|
+
"langchain_anthropic.middleware.AnthropicPromptCachingMiddleware "
|
|
611
|
+
"unavailable; prompt caching disabled"
|
|
612
|
+
)
|
|
613
|
+
return None
|
|
614
|
+
ttl = cast(Literal["5m", "1h"], self._prompt_cache_ttl)
|
|
615
|
+
return AnthropicPromptCachingMiddleware(
|
|
616
|
+
ttl=ttl,
|
|
617
|
+
min_messages_to_cache=self._prompt_cache_min_messages,
|
|
618
|
+
unsupported_model_behavior="ignore",
|
|
441
619
|
)
|
|
442
|
-
return [mw]
|
|
443
620
|
|
|
444
621
|
def _build_agent(
|
|
445
622
|
self,
|
|
@@ -454,7 +631,7 @@ class _Engine:
|
|
|
454
631
|
If a future/older version rejects it we fall back to prepending a
|
|
455
632
|
system message at invoke time instead.
|
|
456
633
|
"""
|
|
457
|
-
tools_list =
|
|
634
|
+
tools_list = _sorted_tools_for_cache(tools)
|
|
458
635
|
middleware = self._build_middleware()
|
|
459
636
|
try:
|
|
460
637
|
return create_agent(
|
|
@@ -566,9 +743,21 @@ class _Engine:
|
|
|
566
743
|
``stop_sequences``, etc.) are peeled off and forwarded to the
|
|
567
744
|
underlying chat model via ``chat.bind(...)`` so they reach the
|
|
568
745
|
provider without mutating the cached chat model.
|
|
746
|
+
|
|
747
|
+
When ``tools`` is exactly one Compresr ``WebSearchTool``, the call
|
|
748
|
+
is auto-routed through the deep-research loop — see
|
|
749
|
+
:data:`_WEB_SEARCH_TOOL_NAMES` and :meth:`_run_via_research_loop`.
|
|
569
750
|
"""
|
|
570
751
|
effective_model = self._resolve_model(model)
|
|
571
752
|
bind_kwargs = self._collect_bind_kwargs(max_tokens=max_tokens, kw=kw)
|
|
753
|
+
if _is_solo_web_search(tools):
|
|
754
|
+
return self._run_via_research_loop(
|
|
755
|
+
messages=messages,
|
|
756
|
+
search_tool=tools[0],
|
|
757
|
+
system=system,
|
|
758
|
+
model=effective_model,
|
|
759
|
+
bind_kwargs=bind_kwargs or None,
|
|
760
|
+
)
|
|
572
761
|
chat = self._get_or_build_chat(effective_model, extra_kwargs=bind_kwargs or None)
|
|
573
762
|
agent = self._build_agent(chat=chat, tools=tools, system=system)
|
|
574
763
|
prepared = self._prepare_messages(agent=agent, messages=messages, system=system)
|
|
@@ -588,9 +777,26 @@ class _Engine:
|
|
|
588
777
|
model: Optional[str] = None,
|
|
589
778
|
**kw: Any,
|
|
590
779
|
) -> NormalizedResult:
|
|
591
|
-
"""Async version of :meth:`run`.
|
|
780
|
+
"""Async version of :meth:`run`.
|
|
781
|
+
|
|
782
|
+
Solo-WebSearchTool calls are auto-routed through the research loop;
|
|
783
|
+
because the loop itself is synchronous (LangChain ``bind_tools``
|
|
784
|
+
+ ``invoke``), the async path runs it via :func:`asyncio.to_thread`
|
|
785
|
+
to avoid blocking the event loop.
|
|
786
|
+
"""
|
|
592
787
|
effective_model = self._resolve_model(model)
|
|
593
788
|
bind_kwargs = self._collect_bind_kwargs(max_tokens=max_tokens, kw=kw)
|
|
789
|
+
if _is_solo_web_search(tools):
|
|
790
|
+
import asyncio
|
|
791
|
+
|
|
792
|
+
return await asyncio.to_thread(
|
|
793
|
+
self._run_via_research_loop,
|
|
794
|
+
messages=messages,
|
|
795
|
+
search_tool=tools[0],
|
|
796
|
+
system=system,
|
|
797
|
+
model=effective_model,
|
|
798
|
+
bind_kwargs=bind_kwargs or None,
|
|
799
|
+
)
|
|
594
800
|
chat = self._get_or_build_chat(effective_model, extra_kwargs=bind_kwargs or None)
|
|
595
801
|
agent = self._build_agent(chat=chat, tools=tools, system=system)
|
|
596
802
|
prepared = self._prepare_messages(agent=agent, messages=messages, system=system)
|
|
@@ -600,6 +806,40 @@ class _Engine:
|
|
|
600
806
|
raise CompresrError(f"Agent execution failed: {e}") from e
|
|
601
807
|
return self._normalize(result)
|
|
602
808
|
|
|
809
|
+
def _run_via_research_loop(
|
|
810
|
+
self,
|
|
811
|
+
*,
|
|
812
|
+
messages: Sequence,
|
|
813
|
+
search_tool: Any,
|
|
814
|
+
system: Optional[Any],
|
|
815
|
+
model: str,
|
|
816
|
+
bind_kwargs: Optional[dict],
|
|
817
|
+
) -> NormalizedResult:
|
|
818
|
+
"""Auto-route handler for solo-WebSearchTool calls.
|
|
819
|
+
|
|
820
|
+
Builds a :class:`ResearchAgent`, runs its strict-output loop against
|
|
821
|
+
the last user message, then feeds the resulting message chain through
|
|
822
|
+
:meth:`_normalize` so each facade still gets its expected shape.
|
|
823
|
+
|
|
824
|
+
``system`` from the caller wins when supplied (string); otherwise
|
|
825
|
+
the research-agent default prompt is used.
|
|
826
|
+
"""
|
|
827
|
+
from compresr.agents.research.agent import ResearchAgent
|
|
828
|
+
|
|
829
|
+
question = _extract_last_user_text(messages)
|
|
830
|
+
system_prompt = system if isinstance(system, str) and system.strip() else None
|
|
831
|
+
agent = ResearchAgent(
|
|
832
|
+
engine=self,
|
|
833
|
+
search_tool=search_tool,
|
|
834
|
+
system_prompt=system_prompt,
|
|
835
|
+
)
|
|
836
|
+
state = agent._run_loop(
|
|
837
|
+
question,
|
|
838
|
+
model=model,
|
|
839
|
+
extra_chat_kwargs=bind_kwargs,
|
|
840
|
+
)
|
|
841
|
+
return self._normalize({"messages": state.messages})
|
|
842
|
+
|
|
603
843
|
# ------------------------------------------------------------------
|
|
604
844
|
# Normalization
|
|
605
845
|
# ------------------------------------------------------------------
|
|
@@ -34,6 +34,18 @@ class CompressionClient(BaseCompressionClient):
|
|
|
34
34
|
llm_api_key: Optional provider API key forwarded to the LLM.
|
|
35
35
|
compression: Optional dict of :class:`CompressionPolicy` kwargs
|
|
36
36
|
(e.g. ``{"target_compression_ratio": 0.7, "min_tokens": 1000}``).
|
|
37
|
+
enable_prompt_cache: Provider-aware prompt-cache control. Default ``True``.
|
|
38
|
+
Anthropic → wires ``AnthropicPromptCachingMiddleware``.
|
|
39
|
+
OpenAI → attaches ``prompt_cache_key`` (when set) and maps
|
|
40
|
+
``prompt_cache_ttl="1h"`` to ``prompt_cache_retention="24h"``.
|
|
41
|
+
Gemini → no-op for now (implicit caching always on at the API).
|
|
42
|
+
prompt_cache_ttl: ``"5m"`` or ``"1h"``. Anthropic uses it as the
|
|
43
|
+
ephemeral cache TTL. OpenAI maps ``"1h"`` to the 24h retention tier.
|
|
44
|
+
prompt_cache_min_messages: Skip Anthropic cache stamping until the
|
|
45
|
+
conversation has at least this many messages.
|
|
46
|
+
openai_prompt_cache_key: Optional routing key for OpenAI prompt caching
|
|
47
|
+
(improves hit rate when multiple clients share a backend). Ignored
|
|
48
|
+
for non-OpenAI providers.
|
|
37
49
|
|
|
38
50
|
Example::
|
|
39
51
|
|
|
@@ -66,12 +78,16 @@ class CompressionClient(BaseCompressionClient):
|
|
|
66
78
|
llm: Optional[str] = None,
|
|
67
79
|
llm_api_key: Optional[str] = None,
|
|
68
80
|
compression: Optional[Dict[str, Any]] = None,
|
|
81
|
+
enable_prompt_cache: bool = True,
|
|
82
|
+
prompt_cache_ttl: str = "5m",
|
|
83
|
+
prompt_cache_min_messages: int = 2,
|
|
84
|
+
openai_prompt_cache_key: Optional[str] = None,
|
|
69
85
|
):
|
|
70
86
|
super().__init__(api_key=api_key, base_url=base_url, timeout=timeout)
|
|
71
87
|
self._engine = None
|
|
72
88
|
if llm is not None:
|
|
73
|
-
#
|
|
74
|
-
#
|
|
89
|
+
# Lazy import: ``CompressionClient(api_key=...)`` without ``llm=``
|
|
90
|
+
# must keep working when langchain isn't installed.
|
|
75
91
|
from compresr.agents.engine import _Engine
|
|
76
92
|
from compresr.integrations._shared import CompressionPolicy
|
|
77
93
|
|
|
@@ -81,7 +97,29 @@ class CompressionClient(BaseCompressionClient):
|
|
|
81
97
|
llm=llm,
|
|
82
98
|
llm_api_key=llm_api_key,
|
|
83
99
|
policy=policy,
|
|
100
|
+
enable_prompt_cache=enable_prompt_cache,
|
|
101
|
+
prompt_cache_ttl=prompt_cache_ttl,
|
|
102
|
+
prompt_cache_min_messages=prompt_cache_min_messages,
|
|
103
|
+
openai_prompt_cache_key=openai_prompt_cache_key,
|
|
84
104
|
)
|
|
105
|
+
self._research_facade: Optional[Any] = None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def research(self) -> Any:
|
|
109
|
+
"""Research facade: ``client.research.run("question")``. Requires ``llm=``."""
|
|
110
|
+
if self._research_facade is None:
|
|
111
|
+
if self._engine is None:
|
|
112
|
+
from ..exceptions import CompresrError
|
|
113
|
+
|
|
114
|
+
raise CompresrError(
|
|
115
|
+
"client.research requires an LLM provider. "
|
|
116
|
+
"Construct with CompressionClient(api_key=..., llm='anthropic:...', llm_api_key=...).",
|
|
117
|
+
code="missing_llm",
|
|
118
|
+
)
|
|
119
|
+
from ..agents.research.facade import ResearchFacade
|
|
120
|
+
|
|
121
|
+
self._research_facade = ResearchFacade(self._engine)
|
|
122
|
+
return self._research_facade
|
|
85
123
|
|
|
86
124
|
def compress(
|
|
87
125
|
self,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: compresr
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.7.2
|
|
4
4
|
Summary: Python SDK for Compresr - Intelligent prompt compression service
|
|
5
5
|
Author-email: Compresr Team <founders@compresr.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -26,7 +26,7 @@ Requires-Dist: httpx>=0.27.0
|
|
|
26
26
|
Requires-Dist: pydantic>=2.10.0
|
|
27
27
|
Requires-Dist: langchain>=1.0
|
|
28
28
|
Requires-Dist: langchain-core>=0.3
|
|
29
|
-
Requires-Dist: langchain-anthropic>=0
|
|
29
|
+
Requires-Dist: langchain-anthropic>=1.0
|
|
30
30
|
Requires-Dist: langchain-openai>=0.3
|
|
31
31
|
Requires-Dist: langchain-google-genai>=4.0
|
|
32
32
|
Requires-Dist: langchain-tavily>=0.1
|
|
@@ -216,6 +216,42 @@ OpenAI's `web_search_preview`, and Gemini's `google_search` run server-side
|
|
|
216
216
|
and return encrypted/opaque content that Compresr cannot read or compress.
|
|
217
217
|
Use Tavily or Brave so the result is plaintext we can compress.
|
|
218
218
|
|
|
219
|
+
## Research agent
|
|
220
|
+
|
|
221
|
+
`client.research.run(question)` runs a multi-step web-research loop with
|
|
222
|
+
per-snippet `latte_v1` compression on tool results and multi-provider prompt
|
|
223
|
+
caching. Loop structure adapted from Perplexity `search_evals` (MIT).
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from compresr import CompressionClient
|
|
227
|
+
|
|
228
|
+
client = CompressionClient(
|
|
229
|
+
api_key="cmp_...",
|
|
230
|
+
llm="anthropic:claude-sonnet-4-6",
|
|
231
|
+
llm_api_key="sk-ant-...",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
result = client.research.run(
|
|
235
|
+
"What was the latest stable Python version released in 2025?",
|
|
236
|
+
search="tavily", # "tavily" | "brave" | a LangChain BaseTool
|
|
237
|
+
max_steps=10,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
print(result.answer) # parsed Exact Answer field
|
|
241
|
+
print(result.explanation) # parsed Explanation field
|
|
242
|
+
print(result.confidence) # parsed 0-1 confidence
|
|
243
|
+
print(result.citations) # list[Citation(url=...)]
|
|
244
|
+
print(result.usage.cache_read_tokens, result.usage.calls)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Single-shot mode: `client.research.search(question)` runs one search + a
|
|
248
|
+
forced final answer (equivalent to `run(..., max_steps=2)`).
|
|
249
|
+
|
|
250
|
+
The agent respects all the prompt-cache options on `CompressionClient`
|
|
251
|
+
(`enable_prompt_cache`, `prompt_cache_ttl`, `openai_prompt_cache_key`).
|
|
252
|
+
Tavily / Brave keys are read from `TAVILY_API_KEY` / `BRAVE_SEARCH_API_KEY`
|
|
253
|
+
(falls back to `BRAVE_API_KEY`).
|
|
254
|
+
|
|
219
255
|
## Compression options
|
|
220
256
|
|
|
221
257
|
| Param | Purpose |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "compresr"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.7.2"
|
|
8
8
|
description = "Python SDK for Compresr - Intelligent prompt compression service"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -35,7 +35,7 @@ dependencies = [
|
|
|
35
35
|
# Tavily + Brave search tools are all available out of the box.
|
|
36
36
|
"langchain>=1.0",
|
|
37
37
|
"langchain-core>=0.3",
|
|
38
|
-
"langchain-anthropic>=0
|
|
38
|
+
"langchain-anthropic>=1.0",
|
|
39
39
|
"langchain-openai>=0.3",
|
|
40
40
|
"langchain-google-genai>=4.0",
|
|
41
41
|
"langchain-tavily>=0.1",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|