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.
Files changed (52) hide show
  1. {contextpress-0.3.0 → contextpress-0.5.0}/CHANGELOG.md +16 -0
  2. {contextpress-0.3.0 → contextpress-0.5.0}/PKG-INFO +29 -3
  3. {contextpress-0.3.0 → contextpress-0.5.0}/README.md +27 -2
  4. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/__init__.py +1 -1
  5. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/core.py +91 -3
  6. contextpress-0.5.0/contextpress/llm/__init__.py +17 -0
  7. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/llm/adapters.py +88 -0
  8. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/pipeline.py +4 -1
  9. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/stats.py +29 -0
  10. contextpress-0.5.0/contextpress/warnings_capture.py +16 -0
  11. contextpress-0.5.0/examples/benchmark_presets.py +23 -0
  12. contextpress-0.5.0/examples/dry_run_preview.py +23 -0
  13. {contextpress-0.3.0 → contextpress-0.5.0}/pyproject.toml +2 -2
  14. contextpress-0.5.0/tests/test_v04.py +51 -0
  15. contextpress-0.5.0/tests/test_v05.py +66 -0
  16. contextpress-0.3.0/contextpress/llm/__init__.py +0 -4
  17. {contextpress-0.3.0 → contextpress-0.5.0}/.gitignore +0 -0
  18. {contextpress-0.3.0 → contextpress-0.5.0}/CITATION.cff +0 -0
  19. {contextpress-0.3.0 → contextpress-0.5.0}/CONTRIBUTING.md +0 -0
  20. {contextpress-0.3.0 → contextpress-0.5.0}/LICENSE +0 -0
  21. {contextpress-0.3.0 → contextpress-0.5.0}/NOTICE +0 -0
  22. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/_bootstrap.py +0 -0
  23. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/compression.py +0 -0
  24. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/llm/_helpers.py +0 -0
  25. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/llm/base.py +0 -0
  26. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/models.py +0 -0
  27. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/normalizer.py +0 -0
  28. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/profiles.py +0 -0
  29. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/py.typed +0 -0
  30. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/registry.py +0 -0
  31. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/__init__.py +0 -0
  32. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/base.py +0 -0
  33. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/budget.py +0 -0
  34. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/filler.py +0 -0
  35. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/recency.py +0 -0
  36. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/repetition.py +0 -0
  37. {contextpress-0.3.0 → contextpress-0.5.0}/contextpress/strategies/resolution.py +0 -0
  38. {contextpress-0.3.0 → contextpress-0.5.0}/examples/estimate_and_stats.py +0 -0
  39. {contextpress-0.3.0 → contextpress-0.5.0}/examples/llm_tier_ollama.py +0 -0
  40. {contextpress-0.3.0 → contextpress-0.5.0}/examples/llm_tier_openai.py +0 -0
  41. {contextpress-0.3.0 → contextpress-0.5.0}/tests/__init__.py +0 -0
  42. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_budget.py +0 -0
  43. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_filler.py +0 -0
  44. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_llm_helpers.py +0 -0
  45. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_models.py +0 -0
  46. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_normalizer.py +0 -0
  47. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_pipeline.py +0 -0
  48. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_recency.py +0 -0
  49. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_repetition.py +0 -0
  50. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_resolution.py +0 -0
  51. {contextpress-0.3.0 → contextpress-0.5.0}/tests/test_stats.py +0 -0
  52. {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.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) (GitHub and Zenodo can ingest it). Replace the placeholder repository URL in that file with your fork’s URL when you publish.
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) (GitHub and Zenodo can ingest it). Replace the placeholder repository URL in that file with your fork’s URL when you publish.
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
 
@@ -16,7 +16,7 @@ __all__ = [
16
16
  "CompressionResult",
17
17
  "CompressionStats",
18
18
  ]
19
- __version__ = "0.3.0"
19
+ __version__ = "0.5.0"
20
20
 
21
21
 
22
22
  def __getattr__(name: str) -> Any:
@@ -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
- out = pipeline.run(conv, stats=stats)
123
- messages_out = denormalize_output(out, ctx)
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.3.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]
@@ -1,4 +0,0 @@
1
- from contextpress.llm.adapters import AnthropicBackend, OllamaBackend, OpenAIBackend
2
- from contextpress.llm.base import LLMBackend
3
-
4
- __all__ = ["LLMBackend", "OpenAIBackend", "AnthropicBackend", "OllamaBackend"]
File without changes
File without changes
File without changes
File without changes