contextpress 0.4.0__tar.gz → 0.5.1__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.
- {contextpress-0.4.0 → contextpress-0.5.1}/CHANGELOG.md +14 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/PKG-INFO +43 -2
- {contextpress-0.4.0 → contextpress-0.5.1}/README.md +41 -1
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/__init__.py +1 -1
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/core.py +38 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/llm/__init__.py +4 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/llm/adapters.py +108 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/stats.py +27 -0
- contextpress-0.5.1/examples/benchmark_presets.py +23 -0
- contextpress-0.5.1/examples/llm_tier_claude.py +50 -0
- contextpress-0.5.1/examples/llm_tier_gemini.py +50 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/pyproject.toml +2 -2
- contextpress-0.5.1/tests/test_v05.py +66 -0
- contextpress-0.5.1/tests/test_v051.py +50 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/.gitignore +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/CITATION.cff +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/CONTRIBUTING.md +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/LICENSE +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/NOTICE +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/_bootstrap.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/compression.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/llm/_helpers.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/llm/base.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/models.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/normalizer.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/pipeline.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/profiles.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/py.typed +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/registry.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/__init__.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/base.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/budget.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/filler.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/recency.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/repetition.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/strategies/resolution.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/contextpress/warnings_capture.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/examples/dry_run_preview.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/examples/estimate_and_stats.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/examples/llm_tier_ollama.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/examples/llm_tier_openai.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/__init__.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_budget.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_filler.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_llm_helpers.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_models.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_normalizer.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_pipeline.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_recency.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_repetition.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_resolution.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_stats.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_v03.py +0 -0
- {contextpress-0.4.0 → contextpress-0.5.1}/tests/test_v04.py +0 -0
|
@@ -4,6 +4,20 @@ All notable changes to `contextpress` are recorded here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.5.1] - 2026-07-22
|
|
8
|
+
|
|
9
|
+
- **`ClaudeBackend`** — shortcut for Anthropic Claude (`ANTHROPIC_API_KEY` or `api_key=`).
|
|
10
|
+
- **`GeminiBackend`** — optional `model_name` + `api_key` / env (`GOOGLE_API_KEY`, `GEMINI_API_KEY`).
|
|
11
|
+
- Examples: `examples/llm_tier_claude.py`, `examples/llm_tier_gemini.py`.
|
|
12
|
+
|
|
13
|
+
## [0.5.0] - 2026-07-22
|
|
14
|
+
|
|
15
|
+
- **`compress_async()`** — async wrapper around ``compress()`` for asyncio apps.
|
|
16
|
+
- **`compare_presets()`** — dry-run low/medium/high on the same messages and compare stats.
|
|
17
|
+
- **`CompressionStats.to_dict()` / `CompressionResult.to_dict()`** — JSON-friendly export.
|
|
18
|
+
- **`GeminiBackend`** — Google Gemini Tier 2 adapter (optional ``google-generativeai``).
|
|
19
|
+
- Example: `examples/benchmark_presets.py`.
|
|
20
|
+
|
|
7
21
|
## [0.4.0] - 2026-07-20
|
|
8
22
|
|
|
9
23
|
- **`preview()` / `dry_run=True`** — simulate compression and get stats without changing messages (Tier 1 only; no LLM calls).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextpress
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Deterministic context compression for LLM chat, RAG, and agent pipelines
|
|
5
5
|
Project-URL: Homepage, https://github.com/Taha-azizi/contextpress
|
|
6
6
|
Project-URL: Documentation, https://github.com/Taha-azizi/contextpress#readme
|
|
@@ -234,6 +234,7 @@ Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
|
234
234
|
Requires-Dist: ruff; extra == 'dev'
|
|
235
235
|
Provides-Extra: llm
|
|
236
236
|
Requires-Dist: anthropic>=0.18; extra == 'llm'
|
|
237
|
+
Requires-Dist: google-generativeai>=0.8.0; extra == 'llm'
|
|
237
238
|
Requires-Dist: ollama>=0.3.0; extra == 'llm'
|
|
238
239
|
Requires-Dist: openai>=1.0; extra == 'llm'
|
|
239
240
|
Description-Content-Type: text/markdown
|
|
@@ -307,6 +308,20 @@ if cm.fits_budget(messages, 4000):
|
|
|
307
308
|
...
|
|
308
309
|
```
|
|
309
310
|
|
|
311
|
+
**Compare presets** (0.5+):
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
rows = cm.compare_presets(messages, token_budget=500)
|
|
315
|
+
for preset, stats in rows.items():
|
|
316
|
+
print(preset, stats.tokens_saved)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Async** (0.5+):
|
|
320
|
+
|
|
321
|
+
```python
|
|
322
|
+
out = await cm.compress_async(messages, token_budget=2000)
|
|
323
|
+
```
|
|
324
|
+
|
|
310
325
|
### Custom stages (0.3+)
|
|
311
326
|
|
|
312
327
|
Register a `BaseStrategy` and include it in `stages=`:
|
|
@@ -386,7 +401,7 @@ ContextManager(type="agent")
|
|
|
386
401
|
| **Techniques** | Rules, TF–IDF, cosine similarity, NLTK, Sumy extractive summarization, tiktoken | Your provider’s chat/completions API (you supply the client) |
|
|
387
402
|
| **API key** | None | Required for your chosen provider (OpenAI, Anthropic, …) |
|
|
388
403
|
| **Determinism** | Deterministic for a fixed input and settings | Non-deterministic (model sampling) |
|
|
389
|
-
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `
|
|
404
|
+
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, **`ClaudeBackend`**, `GeminiBackend`, `OllamaBackend`, or custom `LLMBackend`) |
|
|
390
405
|
|
|
391
406
|
**Note:** `ContextManager(model="gpt-4")` is only for **tiktoken** encoding when counting tokens in the **budget** stage. It does **not** call that model unless you also pass **`llm_backend`**.
|
|
392
407
|
|
|
@@ -479,6 +494,32 @@ out = cm.compress(messages, token_budget=4000)
|
|
|
479
494
|
|
|
480
495
|
Runnable script: [`examples/llm_tier_ollama.py`](examples/llm_tier_ollama.py).
|
|
481
496
|
|
|
497
|
+
**Claude (Anthropic)** — `pip install anthropic`, set `ANTHROPIC_API_KEY`:
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
from contextpress import ContextManager
|
|
501
|
+
from contextpress.llm.adapters import ClaudeBackend
|
|
502
|
+
|
|
503
|
+
backend = ClaudeBackend(model="claude-haiku-4-5")
|
|
504
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=500)
|
|
505
|
+
out = cm.compress(messages, token_budget=4000)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Runnable script: [`examples/llm_tier_claude.py`](examples/llm_tier_claude.py).
|
|
509
|
+
|
|
510
|
+
**Gemini (Google)** — `pip install google-generativeai`, set `GOOGLE_API_KEY`:
|
|
511
|
+
|
|
512
|
+
```python
|
|
513
|
+
from contextpress import ContextManager
|
|
514
|
+
from contextpress.llm.adapters import GeminiBackend
|
|
515
|
+
|
|
516
|
+
backend = GeminiBackend(model_name="gemini-2.0-flash")
|
|
517
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=500)
|
|
518
|
+
out = cm.compress(messages, token_budget=4000)
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Runnable script: [`examples/llm_tier_gemini.py`](examples/llm_tier_gemini.py).
|
|
522
|
+
|
|
482
523
|
```bash
|
|
483
524
|
pip install ollama
|
|
484
525
|
ollama pull llama3.2
|
|
@@ -67,6 +67,20 @@ if cm.fits_budget(messages, 4000):
|
|
|
67
67
|
...
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
**Compare presets** (0.5+):
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
rows = cm.compare_presets(messages, token_budget=500)
|
|
74
|
+
for preset, stats in rows.items():
|
|
75
|
+
print(preset, stats.tokens_saved)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Async** (0.5+):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
out = await cm.compress_async(messages, token_budget=2000)
|
|
82
|
+
```
|
|
83
|
+
|
|
70
84
|
### Custom stages (0.3+)
|
|
71
85
|
|
|
72
86
|
Register a `BaseStrategy` and include it in `stages=`:
|
|
@@ -146,7 +160,7 @@ ContextManager(type="agent")
|
|
|
146
160
|
| **Techniques** | Rules, TF–IDF, cosine similarity, NLTK, Sumy extractive summarization, tiktoken | Your provider’s chat/completions API (you supply the client) |
|
|
147
161
|
| **API key** | None | Required for your chosen provider (OpenAI, Anthropic, …) |
|
|
148
162
|
| **Determinism** | Deterministic for a fixed input and settings | Non-deterministic (model sampling) |
|
|
149
|
-
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `
|
|
163
|
+
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, **`ClaudeBackend`**, `GeminiBackend`, `OllamaBackend`, or custom `LLMBackend`) |
|
|
150
164
|
|
|
151
165
|
**Note:** `ContextManager(model="gpt-4")` is only for **tiktoken** encoding when counting tokens in the **budget** stage. It does **not** call that model unless you also pass **`llm_backend`**.
|
|
152
166
|
|
|
@@ -239,6 +253,32 @@ out = cm.compress(messages, token_budget=4000)
|
|
|
239
253
|
|
|
240
254
|
Runnable script: [`examples/llm_tier_ollama.py`](examples/llm_tier_ollama.py).
|
|
241
255
|
|
|
256
|
+
**Claude (Anthropic)** — `pip install anthropic`, set `ANTHROPIC_API_KEY`:
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
from contextpress import ContextManager
|
|
260
|
+
from contextpress.llm.adapters import ClaudeBackend
|
|
261
|
+
|
|
262
|
+
backend = ClaudeBackend(model="claude-haiku-4-5")
|
|
263
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=500)
|
|
264
|
+
out = cm.compress(messages, token_budget=4000)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Runnable script: [`examples/llm_tier_claude.py`](examples/llm_tier_claude.py).
|
|
268
|
+
|
|
269
|
+
**Gemini (Google)** — `pip install google-generativeai`, set `GOOGLE_API_KEY`:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
from contextpress import ContextManager
|
|
273
|
+
from contextpress.llm.adapters import GeminiBackend
|
|
274
|
+
|
|
275
|
+
backend = GeminiBackend(model_name="gemini-2.0-flash")
|
|
276
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=500)
|
|
277
|
+
out = cm.compress(messages, token_budget=4000)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Runnable script: [`examples/llm_tier_gemini.py`](examples/llm_tier_gemini.py).
|
|
281
|
+
|
|
242
282
|
```bash
|
|
243
283
|
pip install ollama
|
|
244
284
|
ollama pull llama3.2
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import copy
|
|
4
5
|
import warnings
|
|
5
6
|
from typing import TYPE_CHECKING, Any
|
|
@@ -86,6 +87,20 @@ class ContextManager:
|
|
|
86
87
|
)
|
|
87
88
|
return preview.stats.tokens_after <= token_budget
|
|
88
89
|
|
|
90
|
+
def compare_presets(
|
|
91
|
+
self,
|
|
92
|
+
messages: Any,
|
|
93
|
+
token_budget: int | None = None,
|
|
94
|
+
*,
|
|
95
|
+
presets: tuple[str, ...] = ("low", "medium", "high"),
|
|
96
|
+
) -> dict[str, CompressionStats]:
|
|
97
|
+
"""Dry-run each compression preset and return stats keyed by preset name."""
|
|
98
|
+
results: dict[str, CompressionStats] = {}
|
|
99
|
+
for preset in presets:
|
|
100
|
+
preview = self.preview(messages, token_budget=token_budget, compression=preset)
|
|
101
|
+
results[preset] = preview.stats
|
|
102
|
+
return results
|
|
103
|
+
|
|
89
104
|
def preview(
|
|
90
105
|
self,
|
|
91
106
|
messages: Any,
|
|
@@ -176,6 +191,29 @@ class ContextManager:
|
|
|
176
191
|
return CompressionResult(messages=messages_out, stats=stats)
|
|
177
192
|
return messages_out
|
|
178
193
|
|
|
194
|
+
async def compress_async(
|
|
195
|
+
self,
|
|
196
|
+
messages: Any,
|
|
197
|
+
token_budget: int | None = None,
|
|
198
|
+
*,
|
|
199
|
+
compression: str | None = None,
|
|
200
|
+
stages: list[str] | None = None,
|
|
201
|
+
disable: list[str] | None = None,
|
|
202
|
+
return_stats: bool = False,
|
|
203
|
+
dry_run: bool = False,
|
|
204
|
+
) -> Any | CompressionResult:
|
|
205
|
+
"""Async wrapper around ``compress()`` (runs in a worker thread)."""
|
|
206
|
+
return await asyncio.to_thread(
|
|
207
|
+
self.compress,
|
|
208
|
+
messages,
|
|
209
|
+
token_budget,
|
|
210
|
+
compression=compression,
|
|
211
|
+
stages=stages,
|
|
212
|
+
disable=disable,
|
|
213
|
+
return_stats=return_stats,
|
|
214
|
+
dry_run=dry_run,
|
|
215
|
+
)
|
|
216
|
+
|
|
179
217
|
def set_compression(self, compression: str) -> None:
|
|
180
218
|
"""Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
|
|
181
219
|
self._compression = normalize_compression_level(compression)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from contextpress.llm.adapters import (
|
|
2
2
|
AnthropicBackend,
|
|
3
|
+
ClaudeBackend,
|
|
4
|
+
GeminiBackend,
|
|
3
5
|
OllamaBackend,
|
|
4
6
|
OpenAIBackend,
|
|
5
7
|
OpenAICompatibleBackend,
|
|
@@ -11,5 +13,7 @@ __all__ = [
|
|
|
11
13
|
"OpenAIBackend",
|
|
12
14
|
"OpenAICompatibleBackend",
|
|
13
15
|
"AnthropicBackend",
|
|
16
|
+
"ClaudeBackend",
|
|
17
|
+
"GeminiBackend",
|
|
14
18
|
"OllamaBackend",
|
|
15
19
|
]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import warnings
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
@@ -135,6 +136,43 @@ class AnthropicBackend(LLMBackend):
|
|
|
135
136
|
return list(range(len(turns)))
|
|
136
137
|
|
|
137
138
|
|
|
139
|
+
class ClaudeBackend(AnthropicBackend):
|
|
140
|
+
"""
|
|
141
|
+
Convenience adapter for **Anthropic Claude** (wraps ``AnthropicBackend``).
|
|
142
|
+
|
|
143
|
+
Requires: ``pip install anthropic``
|
|
144
|
+
|
|
145
|
+
Usage::
|
|
146
|
+
|
|
147
|
+
from contextpress.llm.adapters import ClaudeBackend
|
|
148
|
+
|
|
149
|
+
backend = ClaudeBackend(model=\"claude-haiku-4-5\") # uses ANTHROPIC_API_KEY
|
|
150
|
+
cm = ContextManager(type=\"chat\", llm_backend=backend)
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
model: str = "claude-haiku-4-5",
|
|
156
|
+
*,
|
|
157
|
+
api_key: str | None = None,
|
|
158
|
+
client: Any | None = None,
|
|
159
|
+
):
|
|
160
|
+
if client is not None:
|
|
161
|
+
super().__init__(client=client, model=model)
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
import anthropic
|
|
165
|
+
except ImportError as exc:
|
|
166
|
+
raise ImportError(
|
|
167
|
+
"ClaudeBackend requires the 'anthropic' package. "
|
|
168
|
+
"Install with: pip install anthropic"
|
|
169
|
+
) from exc
|
|
170
|
+
key = api_key or os.environ.get("ANTHROPIC_API_KEY")
|
|
171
|
+
if not key:
|
|
172
|
+
raise ValueError("Set ANTHROPIC_API_KEY or pass api_key= for ClaudeBackend")
|
|
173
|
+
super().__init__(client=anthropic.Anthropic(api_key=key), model=model)
|
|
174
|
+
|
|
175
|
+
|
|
138
176
|
class OllamaBackend(LLMBackend):
|
|
139
177
|
"""
|
|
140
178
|
Adapter for **Ollama** (local or remote) using the official ``ollama`` Python library.
|
|
@@ -262,3 +300,73 @@ class OpenAICompatibleBackend(OpenAIBackend):
|
|
|
262
300
|
client=OpenAI(base_url=base_url, api_key=api_key),
|
|
263
301
|
model=model,
|
|
264
302
|
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class GeminiBackend(LLMBackend):
|
|
306
|
+
"""
|
|
307
|
+
Adapter for Google Gemini via ``google-generativeai``.
|
|
308
|
+
|
|
309
|
+
Requires: ``pip install google-generativeai``
|
|
310
|
+
|
|
311
|
+
Usage (API key from env ``GOOGLE_API_KEY`` or ``GEMINI_API_KEY``)::
|
|
312
|
+
|
|
313
|
+
from contextpress.llm.adapters import GeminiBackend
|
|
314
|
+
|
|
315
|
+
backend = GeminiBackend(model_name=\"gemini-2.0-flash\")
|
|
316
|
+
cm = ContextManager(type=\"chat\", llm_backend=backend)
|
|
317
|
+
|
|
318
|
+
Or pass a preconfigured model instance::
|
|
319
|
+
|
|
320
|
+
import google.generativeai as genai
|
|
321
|
+
genai.configure(api_key=\"...\")
|
|
322
|
+
backend = GeminiBackend(model=genai.GenerativeModel(\"gemini-2.0-flash\"))
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
model: Any | None = None,
|
|
328
|
+
*,
|
|
329
|
+
model_name: str = "gemini-2.0-flash",
|
|
330
|
+
api_key: str | None = None,
|
|
331
|
+
):
|
|
332
|
+
if model is not None:
|
|
333
|
+
self.model = model
|
|
334
|
+
return
|
|
335
|
+
try:
|
|
336
|
+
import google.generativeai as genai
|
|
337
|
+
except ImportError as exc:
|
|
338
|
+
raise ImportError(
|
|
339
|
+
"GeminiBackend requires 'google-generativeai'. "
|
|
340
|
+
"Install with: pip install google-generativeai"
|
|
341
|
+
) from exc
|
|
342
|
+
key = api_key or os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
|
|
343
|
+
if key:
|
|
344
|
+
genai.configure(api_key=key)
|
|
345
|
+
self.model = genai.GenerativeModel(model_name)
|
|
346
|
+
|
|
347
|
+
def _generate(self, prompt: str, *, max_tokens: int) -> str:
|
|
348
|
+
resp = self.model.generate_content(
|
|
349
|
+
prompt,
|
|
350
|
+
generation_config={"max_output_tokens": max(64, int(max_tokens))},
|
|
351
|
+
)
|
|
352
|
+
text = getattr(resp, "text", None)
|
|
353
|
+
return str(text).strip() if text else ""
|
|
354
|
+
|
|
355
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
356
|
+
try:
|
|
357
|
+
out = self._generate(f"Summarize concisely:\n\n{text}", max_tokens=max_tokens)
|
|
358
|
+
return out if out else text
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
warnings.warn(f"contextpress GeminiBackend.summarize failed: {exc}", stacklevel=2)
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
364
|
+
if len(turns) <= 1:
|
|
365
|
+
return list(range(len(turns)))
|
|
366
|
+
prompt = f"{DEDUP_SYSTEM_PROMPT}\n\n{format_numbered_turns(turns)}"
|
|
367
|
+
try:
|
|
368
|
+
out = self._generate(prompt, max_tokens=128)
|
|
369
|
+
return parse_keep_indices(out, len(turns))
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
warnings.warn(f"contextpress GeminiBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
372
|
+
return list(range(len(turns)))
|
|
@@ -60,6 +60,27 @@ class CompressionStats:
|
|
|
60
60
|
def tokens_saved(self) -> int:
|
|
61
61
|
return max(0, self.tokens_before - self.tokens_after)
|
|
62
62
|
|
|
63
|
+
def to_dict(self) -> dict[str, Any]:
|
|
64
|
+
"""JSON-serializable snapshot of this run."""
|
|
65
|
+
return {
|
|
66
|
+
"turns_before": self.turns_before,
|
|
67
|
+
"turns_after": self.turns_after,
|
|
68
|
+
"turns_removed": self.turns_removed,
|
|
69
|
+
"tokens_before": self.tokens_before,
|
|
70
|
+
"tokens_after": self.tokens_after,
|
|
71
|
+
"tokens_saved": self.tokens_saved,
|
|
72
|
+
"stages_run": list(self.stages_run),
|
|
73
|
+
"turn_delta_by_stage": dict(self.turn_delta_by_stage),
|
|
74
|
+
"llm_tier_applied": self.llm_tier_applied,
|
|
75
|
+
"llm_dedup_turns_before": self.llm_dedup_turns_before,
|
|
76
|
+
"llm_dedup_turns_after": self.llm_dedup_turns_after,
|
|
77
|
+
"compression_level": self.compression_level,
|
|
78
|
+
"context_type": self.context_type,
|
|
79
|
+
"token_budget": self.token_budget,
|
|
80
|
+
"dry_run": self.dry_run,
|
|
81
|
+
"warnings_emitted": list(self.warnings_emitted),
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
|
|
64
85
|
@dataclass
|
|
65
86
|
class CompressionResult:
|
|
@@ -67,3 +88,9 @@ class CompressionResult:
|
|
|
67
88
|
|
|
68
89
|
messages: Any
|
|
69
90
|
stats: CompressionStats
|
|
91
|
+
|
|
92
|
+
def to_dict(self, *, include_messages: bool = True) -> dict[str, Any]:
|
|
93
|
+
data = {"stats": self.stats.to_dict()}
|
|
94
|
+
if include_messages:
|
|
95
|
+
data["messages"] = self.messages
|
|
96
|
+
return data
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Compare compression presets on the same fixture (dry run)."""
|
|
2
|
+
|
|
3
|
+
from contextpress import ContextManager
|
|
4
|
+
|
|
5
|
+
messages = [
|
|
6
|
+
{"role": "system", "content": "You are helpful."},
|
|
7
|
+
{"role": "user", "content": "Old topic: " + "blah " * 40},
|
|
8
|
+
{"role": "assistant", "content": "Basically, honestly, " + "yak " * 40},
|
|
9
|
+
{"role": "user", "content": "What is 2+2?"},
|
|
10
|
+
{"role": "assistant", "content": "Four."},
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
cm = ContextManager(type="chat")
|
|
14
|
+
rows = cm.compare_presets(messages, token_budget=200)
|
|
15
|
+
|
|
16
|
+
print(f"{'preset':<8} {'tokens':>12} {'saved':>8} {'turns':>12}")
|
|
17
|
+
for preset, stats in rows.items():
|
|
18
|
+
print(
|
|
19
|
+
f"{preset:<8} "
|
|
20
|
+
f"{stats.tokens_before:>5}->{stats.tokens_after:<5} "
|
|
21
|
+
f"{stats.tokens_saved:>8} "
|
|
22
|
+
f"{stats.turns_before:>5}->{stats.turns_after:<5}"
|
|
23
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tier 2 compression with Anthropic Claude.
|
|
3
|
+
|
|
4
|
+
Requires ``pip install anthropic`` and ``ANTHROPIC_API_KEY``.
|
|
5
|
+
|
|
6
|
+
Run:
|
|
7
|
+
set ANTHROPIC_API_KEY=sk-ant-... # Windows
|
|
8
|
+
python examples/llm_tier_claude.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from contextpress import ContextManager
|
|
17
|
+
from contextpress.llm.adapters import ClaudeBackend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
22
|
+
print("Set ANTHROPIC_API_KEY to run this example.", file=sys.stderr)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
messages = [
|
|
26
|
+
{"role": "system", "content": "You are a concise assistant."},
|
|
27
|
+
{"role": "user", "content": "What is Python? " + ("Explain briefly. " * 200)},
|
|
28
|
+
{"role": "assistant", "content": "Python is a programming language. " * 150},
|
|
29
|
+
{"role": "user", "content": "And numpy? " * 80},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
backend = ClaudeBackend(model="claude-haiku-4-5")
|
|
33
|
+
cm = ContextManager(
|
|
34
|
+
type="chat",
|
|
35
|
+
llm_backend=backend,
|
|
36
|
+
compression="medium",
|
|
37
|
+
llm_min_input_chars=500,
|
|
38
|
+
llm_max_summary_tokens=512,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
out = cm.compress(messages, token_budget=2000)
|
|
42
|
+
print("turns in:", len(messages), "out:", len(out))
|
|
43
|
+
for i, m in enumerate(out):
|
|
44
|
+
c = m["content"]
|
|
45
|
+
preview = (c[:200] + "…") if isinstance(c, str) and len(c) > 200 else c
|
|
46
|
+
print(i, m["role"] + ":", preview)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tier 2 compression with Google Gemini.
|
|
3
|
+
|
|
4
|
+
Requires ``pip install google-generativeai`` and ``GOOGLE_API_KEY``.
|
|
5
|
+
|
|
6
|
+
Run:
|
|
7
|
+
set GOOGLE_API_KEY=... # or GEMINI_API_KEY
|
|
8
|
+
python examples/llm_tier_gemini.py
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from contextpress import ContextManager
|
|
17
|
+
from contextpress.llm.adapters import GeminiBackend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
if not (os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")):
|
|
22
|
+
print("Set GOOGLE_API_KEY or GEMINI_API_KEY to run this example.", file=sys.stderr)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
messages = [
|
|
26
|
+
{"role": "system", "content": "You are a concise assistant."},
|
|
27
|
+
{"role": "user", "content": "What is Python? " + ("Explain briefly. " * 200)},
|
|
28
|
+
{"role": "assistant", "content": "Python is a programming language. " * 150},
|
|
29
|
+
{"role": "user", "content": "And numpy? " * 80},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
backend = GeminiBackend(model_name="gemini-2.0-flash")
|
|
33
|
+
cm = ContextManager(
|
|
34
|
+
type="chat",
|
|
35
|
+
llm_backend=backend,
|
|
36
|
+
compression="medium",
|
|
37
|
+
llm_min_input_chars=500,
|
|
38
|
+
llm_max_summary_tokens=512,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
out = cm.compress(messages, token_budget=2000)
|
|
42
|
+
print("turns in:", len(messages), "out:", len(out))
|
|
43
|
+
for i, m in enumerate(out):
|
|
44
|
+
c = m["content"]
|
|
45
|
+
preview = (c[:200] + "…") if isinstance(c, str) and len(c) > 200 else c
|
|
46
|
+
print(i, m["role"] + ":", preview)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "contextpress"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.1"
|
|
8
8
|
description = "Deterministic context compression for LLM chat, RAG, and agent pipelines"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -44,7 +44,7 @@ dependencies = [
|
|
|
44
44
|
|
|
45
45
|
[project.optional-dependencies]
|
|
46
46
|
dev = ["pytest>=7.0", "pytest-cov>=4.0", "black", "ruff"]
|
|
47
|
-
llm = ["openai>=1.0", "anthropic>=0.18", "ollama>=0.3.0"]
|
|
47
|
+
llm = ["openai>=1.0", "anthropic>=0.18", "ollama>=0.3.0", "google-generativeai>=0.8.0"]
|
|
48
48
|
|
|
49
49
|
[project.urls]
|
|
50
50
|
Homepage = "https://github.com/Taha-azizi/contextpress"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from contextpress import ContextManager
|
|
5
|
+
from contextpress.llm.adapters import GeminiBackend
|
|
6
|
+
from contextpress.stats import CompressionStats
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_stats_to_dict():
|
|
10
|
+
stats = CompressionStats(
|
|
11
|
+
turns_before=5,
|
|
12
|
+
turns_after=3,
|
|
13
|
+
tokens_before=100,
|
|
14
|
+
tokens_after=60,
|
|
15
|
+
stages_run=["filler", "budget"],
|
|
16
|
+
compression_level="medium",
|
|
17
|
+
)
|
|
18
|
+
d = stats.to_dict()
|
|
19
|
+
assert d["turns_removed"] == 2
|
|
20
|
+
assert d["tokens_saved"] == 40
|
|
21
|
+
assert d["stages_run"] == ["filler", "budget"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_result_to_dict():
|
|
25
|
+
cm = ContextManager()
|
|
26
|
+
result = cm.preview([{"role": "user", "content": "hello basically"}], token_budget=100)
|
|
27
|
+
d = result.to_dict()
|
|
28
|
+
assert "stats" in d and "messages" in d
|
|
29
|
+
assert d["stats"]["dry_run"] is True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_compare_presets():
|
|
33
|
+
cm = ContextManager(type="chat")
|
|
34
|
+
messages = [
|
|
35
|
+
{"role": "user", "content": "hello basically there"},
|
|
36
|
+
{"role": "assistant", "content": "sounds good"},
|
|
37
|
+
]
|
|
38
|
+
rows = cm.compare_presets(messages, token_budget=500)
|
|
39
|
+
assert set(rows) == {"low", "medium", "high"}
|
|
40
|
+
assert all(isinstance(v, CompressionStats) for v in rows.values())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_compress_async():
|
|
44
|
+
async def _run() -> list:
|
|
45
|
+
cm = ContextManager(type="chat")
|
|
46
|
+
return await cm.compress_async(
|
|
47
|
+
[{"role": "user", "content": "hello basically"}],
|
|
48
|
+
token_budget=200,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
out = asyncio.run(_run())
|
|
52
|
+
assert isinstance(out, list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_gemini_backend_summarize():
|
|
56
|
+
model = MagicMock()
|
|
57
|
+
model.generate_content.return_value = MagicMock(text="summary")
|
|
58
|
+
backend = GeminiBackend(model=model)
|
|
59
|
+
assert backend.summarize("long text", 50) == "summary"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_gemini_backend_deduplicate():
|
|
63
|
+
model = MagicMock()
|
|
64
|
+
model.generate_content.return_value = MagicMock(text="0, 2")
|
|
65
|
+
backend = GeminiBackend(model=model)
|
|
66
|
+
assert backend.deduplicate(["a", "b", "c", "d"]) == [0, 2]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from unittest.mock import MagicMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from contextpress.llm.adapters import ClaudeBackend, GeminiBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_claude_backend_with_client():
|
|
10
|
+
client = MagicMock()
|
|
11
|
+
backend = ClaudeBackend(model="claude-haiku-4-5", client=client)
|
|
12
|
+
assert backend.client is client
|
|
13
|
+
assert backend.model == "claude-haiku-4-5"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_claude_backend_requires_api_key():
|
|
17
|
+
mock_anthropic = MagicMock()
|
|
18
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
19
|
+
with patch.dict("sys.modules", {"anthropic": mock_anthropic}):
|
|
20
|
+
with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"):
|
|
21
|
+
ClaudeBackend()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_claude_backend_from_api_key():
|
|
25
|
+
mock_module = MagicMock()
|
|
26
|
+
mock_client = MagicMock()
|
|
27
|
+
mock_module.Anthropic.return_value = mock_client
|
|
28
|
+
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-test"}):
|
|
29
|
+
with patch.dict("sys.modules", {"anthropic": mock_module}):
|
|
30
|
+
backend = ClaudeBackend(model="claude-haiku-4-5")
|
|
31
|
+
assert backend.client is mock_client
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_gemini_backend_with_model_instance():
|
|
35
|
+
model = MagicMock()
|
|
36
|
+
backend = GeminiBackend(model=model)
|
|
37
|
+
assert backend.model is model
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_gemini_backend_from_model_name():
|
|
41
|
+
mock_genai = MagicMock()
|
|
42
|
+
mock_model = MagicMock()
|
|
43
|
+
mock_genai.GenerativeModel.return_value = mock_model
|
|
44
|
+
fake_google = MagicMock()
|
|
45
|
+
fake_google.generativeai = mock_genai
|
|
46
|
+
with patch.dict(os.environ, {"GOOGLE_API_KEY": "key-test"}):
|
|
47
|
+
with patch.dict("sys.modules", {"google": fake_google, "google.generativeai": mock_genai}):
|
|
48
|
+
backend = GeminiBackend(model_name="gemini-2.0-flash")
|
|
49
|
+
assert backend.model is mock_model
|
|
50
|
+
mock_genai.configure.assert_called_once_with(api_key="key-test")
|
|
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
|