contextpress 0.3.0__tar.gz → 0.5.0__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.3.0 → contextpress-0.5.0}/CHANGELOG.md +16 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/PKG-INFO +29 -3
- {contextpress-0.3.0 → contextpress-0.5.0}/README.md +27 -2
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/__init__.py +1 -1
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/core.py +91 -3
- contextpress-0.5.0/contextpress/llm/__init__.py +17 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/llm/adapters.py +88 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/pipeline.py +4 -1
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/stats.py +29 -0
- contextpress-0.5.0/contextpress/warnings_capture.py +16 -0
- contextpress-0.5.0/examples/benchmark_presets.py +23 -0
- contextpress-0.5.0/examples/dry_run_preview.py +23 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/pyproject.toml +2 -2
- contextpress-0.5.0/tests/test_v04.py +51 -0
- contextpress-0.5.0/tests/test_v05.py +66 -0
- contextpress-0.3.0/contextpress/llm/__init__.py +0 -4
- {contextpress-0.3.0 → contextpress-0.5.0}/.gitignore +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/CITATION.cff +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/CONTRIBUTING.md +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/LICENSE +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/NOTICE +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/_bootstrap.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/compression.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/llm/_helpers.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/llm/base.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/models.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/normalizer.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/profiles.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/py.typed +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/registry.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/__init__.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/base.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/budget.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/filler.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/recency.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/repetition.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/resolution.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/examples/estimate_and_stats.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/examples/llm_tier_ollama.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/examples/llm_tier_openai.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/__init__.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_budget.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_filler.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_llm_helpers.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_models.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_normalizer.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_pipeline.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_recency.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_repetition.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_resolution.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_stats.py +0 -0
- {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_v03.py +0 -0
|
@@ -4,6 +4,22 @@ 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.0] - 2026-07-22
|
|
8
|
+
|
|
9
|
+
- **`compress_async()`** — async wrapper around ``compress()`` for asyncio apps.
|
|
10
|
+
- **`compare_presets()`** — dry-run low/medium/high on the same messages and compare stats.
|
|
11
|
+
- **`CompressionStats.to_dict()` / `CompressionResult.to_dict()`** — JSON-friendly export.
|
|
12
|
+
- **`GeminiBackend`** — Google Gemini Tier 2 adapter (optional ``google-generativeai``).
|
|
13
|
+
- Example: `examples/benchmark_presets.py`.
|
|
14
|
+
|
|
15
|
+
## [0.4.0] - 2026-07-20
|
|
16
|
+
|
|
17
|
+
- **`preview()` / `dry_run=True`** — simulate compression and get stats without changing messages (Tier 1 only; no LLM calls).
|
|
18
|
+
- **`fits_budget()`** — check whether messages would fit a token budget after compression.
|
|
19
|
+
- **`CompressionStats.warnings_emitted`** — pipeline warnings collected during a run.
|
|
20
|
+
- **`OpenAICompatibleBackend`** — vLLM, LM Studio, LocalAI, and other OpenAI-compatible servers.
|
|
21
|
+
- Example: `examples/dry_run_preview.py`.
|
|
22
|
+
|
|
7
23
|
## [0.3.0] - 2026-07-14
|
|
8
24
|
|
|
9
25
|
- **`estimate_tokens(messages)`** — count tokens before compressing (same tiktoken encoding as budget).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contextpress
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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
|
|
@@ -296,6 +297,31 @@ Check token count **before** compressing:
|
|
|
296
297
|
before = cm.estimate_tokens(messages)
|
|
297
298
|
```
|
|
298
299
|
|
|
300
|
+
**Preview without changing messages** (0.4+):
|
|
301
|
+
|
|
302
|
+
```python
|
|
303
|
+
preview = cm.preview(messages, token_budget=500)
|
|
304
|
+
print(preview.stats.tokens_saved, preview.stats.warnings_emitted)
|
|
305
|
+
assert preview.messages == messages # unchanged
|
|
306
|
+
|
|
307
|
+
if cm.fits_budget(messages, 4000):
|
|
308
|
+
...
|
|
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
|
+
|
|
299
325
|
### Custom stages (0.3+)
|
|
300
326
|
|
|
301
327
|
Register a `BaseStrategy` and include it in `stages=`:
|
|
@@ -375,7 +401,7 @@ ContextManager(type="agent")
|
|
|
375
401
|
| **Techniques** | Rules, TF–IDF, cosine similarity, NLTK, Sumy extractive summarization, tiktoken | Your provider’s chat/completions API (you supply the client) |
|
|
376
402
|
| **API key** | None | Required for your chosen provider (OpenAI, Anthropic, …) |
|
|
377
403
|
| **Determinism** | Deterministic for a fixed input and settings | Non-deterministic (model sampling) |
|
|
378
|
-
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
|
|
404
|
+
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, `GeminiBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
|
|
379
405
|
|
|
380
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`**.
|
|
381
407
|
|
|
@@ -491,7 +517,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
|
|
|
491
517
|
|
|
492
518
|
## Research and citing
|
|
493
519
|
|
|
494
|
-
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff)
|
|
520
|
+
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff).
|
|
495
521
|
|
|
496
522
|
## Extension and growth
|
|
497
523
|
|
|
@@ -56,6 +56,31 @@ Check token count **before** compressing:
|
|
|
56
56
|
before = cm.estimate_tokens(messages)
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
**Preview without changing messages** (0.4+):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
preview = cm.preview(messages, token_budget=500)
|
|
63
|
+
print(preview.stats.tokens_saved, preview.stats.warnings_emitted)
|
|
64
|
+
assert preview.messages == messages # unchanged
|
|
65
|
+
|
|
66
|
+
if cm.fits_budget(messages, 4000):
|
|
67
|
+
...
|
|
68
|
+
```
|
|
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
|
+
|
|
59
84
|
### Custom stages (0.3+)
|
|
60
85
|
|
|
61
86
|
Register a `BaseStrategy` and include it in `stages=`:
|
|
@@ -135,7 +160,7 @@ ContextManager(type="agent")
|
|
|
135
160
|
| **Techniques** | Rules, TF–IDF, cosine similarity, NLTK, Sumy extractive summarization, tiktoken | Your provider’s chat/completions API (you supply the client) |
|
|
136
161
|
| **API key** | None | Required for your chosen provider (OpenAI, Anthropic, …) |
|
|
137
162
|
| **Determinism** | Deterministic for a fixed input and settings | Non-deterministic (model sampling) |
|
|
138
|
-
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
|
|
163
|
+
| **How to enable** | Default: `ContextManager()` runs Tier 1 only | Pass `llm_backend=` (`OpenAIBackend`, `AnthropicBackend`, `GeminiBackend`, **`OllamaBackend`**, or custom `LLMBackend`) |
|
|
139
164
|
|
|
140
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`**.
|
|
141
166
|
|
|
@@ -251,7 +276,7 @@ Long chat histories inflate token usage, bury important facts (lost-in-the-middl
|
|
|
251
276
|
|
|
252
277
|
## Research and citing
|
|
253
278
|
|
|
254
|
-
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff)
|
|
279
|
+
For academic use, cite this package in your paper’s software or methods section. A machine-readable citation file is provided as [`CITATION.cff`](CITATION.cff).
|
|
255
280
|
|
|
256
281
|
## Extension and growth
|
|
257
282
|
|
|
@@ -1,15 +1,17 @@
|
|
|
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
|
|
6
7
|
|
|
7
8
|
from contextpress.compression import apply_stage_selection, normalize_compression_level
|
|
8
9
|
from contextpress.normalizer import denormalize_output, normalize_messages
|
|
9
|
-
from contextpress.pipeline import VALID_LLM_MODES, Pipeline
|
|
10
|
+
from contextpress.pipeline import VALID_LLM_MODES, Pipeline, clone_conversation
|
|
10
11
|
from contextpress.profiles import PROFILES, Profile, StageConfig
|
|
11
12
|
from contextpress.registry import register_stage as _register_stage
|
|
12
13
|
from contextpress.stats import CompressionResult, CompressionStats, count_conversation_tokens
|
|
14
|
+
from contextpress.warnings_capture import capture_warnings
|
|
13
15
|
|
|
14
16
|
if TYPE_CHECKING:
|
|
15
17
|
from collections.abc import Callable
|
|
@@ -66,6 +68,59 @@ class ContextManager:
|
|
|
66
68
|
conv, _ = normalize_messages(messages, context_type=self._type)
|
|
67
69
|
return count_conversation_tokens(conv, model if model is not None else self.model)
|
|
68
70
|
|
|
71
|
+
def fits_budget(
|
|
72
|
+
self,
|
|
73
|
+
messages: Any,
|
|
74
|
+
token_budget: int,
|
|
75
|
+
*,
|
|
76
|
+
compression: str | None = None,
|
|
77
|
+
stages: list[str] | None = None,
|
|
78
|
+
disable: list[str] | None = None,
|
|
79
|
+
) -> bool:
|
|
80
|
+
"""Return True if ``messages`` would fit ``token_budget`` after compression (dry run)."""
|
|
81
|
+
preview = self.preview(
|
|
82
|
+
messages,
|
|
83
|
+
token_budget=token_budget,
|
|
84
|
+
compression=compression,
|
|
85
|
+
stages=stages,
|
|
86
|
+
disable=disable,
|
|
87
|
+
)
|
|
88
|
+
return preview.stats.tokens_after <= token_budget
|
|
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
|
+
|
|
104
|
+
def preview(
|
|
105
|
+
self,
|
|
106
|
+
messages: Any,
|
|
107
|
+
token_budget: int | None = None,
|
|
108
|
+
*,
|
|
109
|
+
compression: str | None = None,
|
|
110
|
+
stages: list[str] | None = None,
|
|
111
|
+
disable: list[str] | None = None,
|
|
112
|
+
) -> CompressionResult:
|
|
113
|
+
"""Dry-run compression: stats only, original ``messages`` returned unchanged."""
|
|
114
|
+
return self.compress(
|
|
115
|
+
messages,
|
|
116
|
+
token_budget=token_budget,
|
|
117
|
+
compression=compression,
|
|
118
|
+
stages=stages,
|
|
119
|
+
disable=disable,
|
|
120
|
+
return_stats=True,
|
|
121
|
+
dry_run=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
69
124
|
def register_stage(
|
|
70
125
|
self,
|
|
71
126
|
name: str,
|
|
@@ -87,12 +142,16 @@ class ContextManager:
|
|
|
87
142
|
stages: list[str] | None = None,
|
|
88
143
|
disable: list[str] | None = None,
|
|
89
144
|
return_stats: bool = False,
|
|
145
|
+
dry_run: bool = False,
|
|
90
146
|
) -> Any | CompressionResult:
|
|
91
147
|
"""Run the pipeline; return value matches input shape (dict list, tuples, strings, etc.).
|
|
92
148
|
|
|
93
149
|
``token_budget`` must be a positive int or None. Unknown keys in ``disable`` are ignored.
|
|
94
150
|
With ``return_stats=True``, returns a ``CompressionResult`` with ``messages`` and ``stats``.
|
|
151
|
+
With ``dry_run=True``, runs Tier 1 only (no LLM calls) and returns the original messages.
|
|
95
152
|
"""
|
|
153
|
+
if dry_run:
|
|
154
|
+
return_stats = True
|
|
96
155
|
_validate_token_budget(token_budget)
|
|
97
156
|
level = compression if compression is not None else self._compression
|
|
98
157
|
profile = copy.deepcopy(self._profile)
|
|
@@ -119,13 +178,42 @@ class ContextManager:
|
|
|
119
178
|
llm_mode=self.llm_mode,
|
|
120
179
|
custom_stages=custom_stages,
|
|
121
180
|
)
|
|
122
|
-
|
|
123
|
-
|
|
181
|
+
with capture_warnings() as captured:
|
|
182
|
+
out = pipeline.run(conv, stats=stats, dry_run=dry_run)
|
|
183
|
+
if stats is not None:
|
|
184
|
+
stats.warnings_emitted = captured
|
|
185
|
+
if dry_run:
|
|
186
|
+
messages_out = denormalize_output(clone_conversation(conv), ctx)
|
|
187
|
+
else:
|
|
188
|
+
messages_out = denormalize_output(out, ctx)
|
|
124
189
|
if return_stats:
|
|
125
190
|
assert stats is not None
|
|
126
191
|
return CompressionResult(messages=messages_out, stats=stats)
|
|
127
192
|
return messages_out
|
|
128
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
|
+
|
|
129
217
|
def set_compression(self, compression: str) -> None:
|
|
130
218
|
"""Change the default preset for subsequent ``compress()`` calls (low / medium / high)."""
|
|
131
219
|
self._compression = normalize_compression_level(compression)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from contextpress.llm.adapters import (
|
|
2
|
+
AnthropicBackend,
|
|
3
|
+
GeminiBackend,
|
|
4
|
+
OllamaBackend,
|
|
5
|
+
OpenAIBackend,
|
|
6
|
+
OpenAICompatibleBackend,
|
|
7
|
+
)
|
|
8
|
+
from contextpress.llm.base import LLMBackend
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LLMBackend",
|
|
12
|
+
"OpenAIBackend",
|
|
13
|
+
"OpenAICompatibleBackend",
|
|
14
|
+
"AnthropicBackend",
|
|
15
|
+
"GeminiBackend",
|
|
16
|
+
"OllamaBackend",
|
|
17
|
+
]
|
|
@@ -221,3 +221,91 @@ class OllamaBackend(LLMBackend):
|
|
|
221
221
|
except Exception as exc:
|
|
222
222
|
warnings.warn(f"contextpress OllamaBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
223
223
|
return list(range(len(turns)))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class OpenAICompatibleBackend(OpenAIBackend):
|
|
227
|
+
"""
|
|
228
|
+
Adapter for **OpenAI-compatible HTTP APIs** (vLLM, LM Studio, LocalAI, etc.).
|
|
229
|
+
|
|
230
|
+
Requires: ``pip install openai``
|
|
231
|
+
|
|
232
|
+
Usage::
|
|
233
|
+
|
|
234
|
+
from contextpress.llm.adapters import OpenAICompatibleBackend
|
|
235
|
+
|
|
236
|
+
backend = OpenAICompatibleBackend(
|
|
237
|
+
base_url="http://localhost:8000/v1",
|
|
238
|
+
model="mistral",
|
|
239
|
+
)
|
|
240
|
+
cm = ContextManager(type="chat", llm_backend=backend)
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
base_url: str,
|
|
246
|
+
model: str,
|
|
247
|
+
*,
|
|
248
|
+
api_key: str = "not-needed",
|
|
249
|
+
client: Any | None = None,
|
|
250
|
+
):
|
|
251
|
+
if client is not None:
|
|
252
|
+
super().__init__(client=client, model=model)
|
|
253
|
+
return
|
|
254
|
+
try:
|
|
255
|
+
from openai import OpenAI
|
|
256
|
+
except ImportError as exc:
|
|
257
|
+
raise ImportError(
|
|
258
|
+
"OpenAICompatibleBackend requires the 'openai' package. "
|
|
259
|
+
"Install with: pip install openai"
|
|
260
|
+
) from exc
|
|
261
|
+
super().__init__(
|
|
262
|
+
client=OpenAI(base_url=base_url, api_key=api_key),
|
|
263
|
+
model=model,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class GeminiBackend(LLMBackend):
|
|
268
|
+
"""
|
|
269
|
+
Adapter for Google Gemini via ``google-generativeai``.
|
|
270
|
+
|
|
271
|
+
Requires: ``pip install google-generativeai``
|
|
272
|
+
|
|
273
|
+
Usage::
|
|
274
|
+
|
|
275
|
+
import google.generativeai as genai
|
|
276
|
+
from contextpress.llm.adapters import GeminiBackend
|
|
277
|
+
|
|
278
|
+
genai.configure(api_key=\"...\")
|
|
279
|
+
backend = GeminiBackend(model=genai.GenerativeModel(\"gemini-2.0-flash\"))
|
|
280
|
+
cm = ContextManager(type=\"chat\", llm_backend=backend)
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
def __init__(self, model: Any):
|
|
284
|
+
self.model = model
|
|
285
|
+
|
|
286
|
+
def _generate(self, prompt: str, *, max_tokens: int) -> str:
|
|
287
|
+
resp = self.model.generate_content(
|
|
288
|
+
prompt,
|
|
289
|
+
generation_config={"max_output_tokens": max(64, int(max_tokens))},
|
|
290
|
+
)
|
|
291
|
+
text = getattr(resp, "text", None)
|
|
292
|
+
return str(text).strip() if text else ""
|
|
293
|
+
|
|
294
|
+
def summarize(self, text: str, max_tokens: int) -> str:
|
|
295
|
+
try:
|
|
296
|
+
out = self._generate(f"Summarize concisely:\n\n{text}", max_tokens=max_tokens)
|
|
297
|
+
return out if out else text
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
warnings.warn(f"contextpress GeminiBackend.summarize failed: {exc}", stacklevel=2)
|
|
300
|
+
raise
|
|
301
|
+
|
|
302
|
+
def deduplicate(self, turns: list[str]) -> list[int]:
|
|
303
|
+
if len(turns) <= 1:
|
|
304
|
+
return list(range(len(turns)))
|
|
305
|
+
prompt = f"{DEDUP_SYSTEM_PROMPT}\n\n{format_numbered_turns(turns)}"
|
|
306
|
+
try:
|
|
307
|
+
out = self._generate(prompt, max_tokens=128)
|
|
308
|
+
return parse_keep_indices(out, len(turns))
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
warnings.warn(f"contextpress GeminiBackend.deduplicate failed: {exc}", stacklevel=2)
|
|
311
|
+
return list(range(len(turns)))
|
|
@@ -107,12 +107,15 @@ class Pipeline:
|
|
|
107
107
|
self,
|
|
108
108
|
conversation: Conversation,
|
|
109
109
|
stats: CompressionStats | None = None,
|
|
110
|
+
*,
|
|
111
|
+
dry_run: bool = False,
|
|
110
112
|
) -> Conversation:
|
|
111
113
|
if stats is not None:
|
|
112
114
|
stats.turns_before = len(conversation.turns)
|
|
113
115
|
stats.tokens_before = count_conversation_tokens(conversation, self.model)
|
|
114
116
|
stats.context_type = conversation.type
|
|
115
117
|
stats.token_budget = self.token_budget
|
|
118
|
+
stats.dry_run = dry_run
|
|
116
119
|
|
|
117
120
|
result = clone_conversation(conversation)
|
|
118
121
|
stage_order = effective_stage_order()
|
|
@@ -131,7 +134,7 @@ class Pipeline:
|
|
|
131
134
|
if delta != 0:
|
|
132
135
|
stats.turn_delta_by_stage[stage_name] = delta
|
|
133
136
|
|
|
134
|
-
if self.llm_backend is not None:
|
|
137
|
+
if self.llm_backend is not None and not dry_run:
|
|
135
138
|
result = self._run_llm_tier(result, stats=stats)
|
|
136
139
|
|
|
137
140
|
if stats is not None:
|
|
@@ -49,6 +49,8 @@ class CompressionStats:
|
|
|
49
49
|
compression_level: str | None = None
|
|
50
50
|
context_type: str = "chat"
|
|
51
51
|
token_budget: int | None = None
|
|
52
|
+
dry_run: bool = False
|
|
53
|
+
warnings_emitted: list[str] = field(default_factory=list)
|
|
52
54
|
|
|
53
55
|
@property
|
|
54
56
|
def turns_removed(self) -> int:
|
|
@@ -58,6 +60,27 @@ class CompressionStats:
|
|
|
58
60
|
def tokens_saved(self) -> int:
|
|
59
61
|
return max(0, self.tokens_before - self.tokens_after)
|
|
60
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
|
+
|
|
61
84
|
|
|
62
85
|
@dataclass
|
|
63
86
|
class CompressionResult:
|
|
@@ -65,3 +88,9 @@ class CompressionResult:
|
|
|
65
88
|
|
|
66
89
|
messages: Any
|
|
67
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,16 @@
|
|
|
1
|
+
"""Capture warnings emitted during compression into ``CompressionStats``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextmanager
|
|
11
|
+
def capture_warnings() -> Generator[list[str], None, None]:
|
|
12
|
+
captured: list[str] = []
|
|
13
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
14
|
+
warnings.simplefilter("always")
|
|
15
|
+
yield captured
|
|
16
|
+
captured.extend(str(item.message) for item in caught)
|
|
@@ -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,23 @@
|
|
|
1
|
+
"""Dry-run preview: see compression stats without changing messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextpress import ContextManager
|
|
6
|
+
|
|
7
|
+
messages = [
|
|
8
|
+
{"role": "system", "content": "You are helpful."},
|
|
9
|
+
{"role": "user", "content": "Old topic: " + "blah " * 60},
|
|
10
|
+
{"role": "assistant", "content": "Basically, honestly, " + "yak " * 60},
|
|
11
|
+
{"role": "user", "content": "What is 2+2?"},
|
|
12
|
+
{"role": "assistant", "content": "Four."},
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
cm = ContextManager(type="chat", compression="medium")
|
|
16
|
+
preview = cm.preview(messages, token_budget=150)
|
|
17
|
+
|
|
18
|
+
print("dry_run:", preview.stats.dry_run)
|
|
19
|
+
print("tokens:", preview.stats.tokens_before, "->", preview.stats.tokens_after)
|
|
20
|
+
print("would fit budget 150:", cm.fits_budget(messages, 150))
|
|
21
|
+
print("messages unchanged:", preview.messages == messages)
|
|
22
|
+
if preview.stats.warnings_emitted:
|
|
23
|
+
print("warnings:", preview.stats.warnings_emitted)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "contextpress"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
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,51 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
from contextpress import ContextManager
|
|
4
|
+
from contextpress.llm.adapters import OpenAICompatibleBackend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_preview_returns_original_messages():
|
|
8
|
+
cm = ContextManager(type="chat")
|
|
9
|
+
messages = [
|
|
10
|
+
{"role": "user", "content": "hello basically there"},
|
|
11
|
+
{"role": "assistant", "content": "hi"},
|
|
12
|
+
]
|
|
13
|
+
preview = cm.preview(messages, token_budget=500)
|
|
14
|
+
assert preview.messages == messages
|
|
15
|
+
assert preview.stats.dry_run is True
|
|
16
|
+
assert preview.stats.tokens_before >= preview.stats.tokens_after
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_dry_run_skips_llm_backend():
|
|
20
|
+
backend = MagicMock()
|
|
21
|
+
cm = ContextManager(type="chat", llm_backend=backend, llm_min_input_chars=0)
|
|
22
|
+
messages = [{"role": "user", "content": "hello " * 500}]
|
|
23
|
+
preview = cm.preview(messages, token_budget=100)
|
|
24
|
+
backend.deduplicate.assert_not_called()
|
|
25
|
+
backend.summarize.assert_not_called()
|
|
26
|
+
assert preview.stats.llm_tier_applied is False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_fits_budget():
|
|
30
|
+
cm = ContextManager(type="chat", compression="low")
|
|
31
|
+
messages = [{"role": "user", "content": "short"}]
|
|
32
|
+
assert cm.fits_budget(messages, 500) is True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_warnings_captured_on_budget_truncation():
|
|
36
|
+
cm = ContextManager(type="chat")
|
|
37
|
+
messages = [
|
|
38
|
+
{"role": "user", "content": "word " * 200},
|
|
39
|
+
{"role": "assistant", "content": "reply " * 200},
|
|
40
|
+
{"role": "user", "content": "recent"},
|
|
41
|
+
{"role": "assistant", "content": "ok"},
|
|
42
|
+
]
|
|
43
|
+
preview = cm.preview(messages, token_budget=20, compression="low")
|
|
44
|
+
assert any("token budget" in w.lower() for w in preview.stats.warnings_emitted)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_openai_compatible_backend_init():
|
|
48
|
+
client = MagicMock()
|
|
49
|
+
backend = OpenAICompatibleBackend(base_url="http://localhost:8000/v1", model="x", client=client)
|
|
50
|
+
assert backend.model == "x"
|
|
51
|
+
assert backend.client is client
|
|
@@ -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]
|
|
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
|